Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby 3: FiberやRactorでHTTPサーバーを手作りする(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

FiberとRactorについては以下もどうぞ。

Ruby 3: FiberやRactorでHTTPサーバーを手作りする(翻訳)

はじめに

本記事はRubyでHTTPを学ぶシリーズ(part #1)の続編です。

RubyでHTTPサーバーをゼロから手作りする(翻訳)

動機

Rubyは歴史的にコンカレンシーを欠いていましたが、現在のRubyには「ネイティブ」スレッドがあります(Ruby 1.9より前は「グリーンスレッド」 のみでした)。ネイティブスレッドは、OS によって制御されるスレッドが複数存在することを意味しますが、一度に実行できるスレッドは 1 つだけで、GIL(Global Interpreter Lock)によって管理されます。ただし、ネイティブ呼び出しやI/O呼び出しはパラレルに実行できます。I/O呼び出しの間、スレッドは制御を手放し、I/Oが終了したというシグナルを待ちます。つまり、I/Oを多用するアプリケーションではマルチスレッドを使えるということです。

本記事では、HTTPサーバのさまざまなコンカレンシーモードについて解説します。

  • シングルスレッド
  • マルチスレッド
  • Fiber
  • Ractor

シングルスレッド

まずはシンプルに、シングルスレッドのHTTPサーバーがどのようなものかを見ていきましょう(完全なコード)。

def start
    socket = TCPServer.new(HOST, PORT)
    socket.listen(SOCKET_READ_BACKLOG)
    loop do
      conn, _addr_info = socket.accept
      request = RequestParser.call(conn)
      status, headers, body = app.call(request)
      HttpResponder.call(conn, status, headers, body)
    rescue => e
      puts e.message
    ensure
      conn&.close
    end
end

マルチスレッド

前述のように、RubyのスレッドはI/Oが多い場合にパフォーマンスが向上します。現実のアプリケーションのほとんどがそのようになっています。I/Oには「受信TCP接続の読み込み」「データベースの呼び出し」「外部APIの呼び出し」「TCP接続経由でのレスポンス」などがあります。

それでは、マルチスレッド版サーバーとスレッドプールをそれぞれ実装してみましょう(完全なコード)。

def start
  pool = ThreadPool.new(size: WORKERS_COUNT)
  socket = TCPServer.new(HOST, PORT)
  socket.listen(SOCKET_READ_BACKLOG)
  loop do
    conn, _addr_info = socket.accept
    # スレッドの1つがリクエストを実行する
    pool.perform do
      begin
        request = RequestParser.call(conn)
        status, headers, body = app.call(request)
        HttpResponder.call(conn, status, headers, body)
      rescue => e
        puts e.message
      ensure
        conn&.close
      end
    end
  end
ensure
  pool&.shutdown
end

スレッドプール(シンプルな実装)

class ThreadPool
  attr_accessor :queue, :running, :size

  def initialize(size:)
    self.size = size

    # 処理を管理するスレッドセーフなキュー
    self.queue = Queue.new

    size.times do
      Thread.new(self.queue) do |queue|
        # Rubyのcatch()はあまり知られていないが
        # 例外の伝搬と似た方法で
        # プログラムのフローを変える方法のひとつ
        catch(:exit) do
          loop do
            # popはキューに何かが入るまでブロックする
            task = queue.pop
            task.call
          end
        end
      end
    end
  end

  def perform(&block)
    self.queue << block
  end

  def shutdown
    size.times do
      # ここでスレッドを無限ループから脱出させる
      perform { throw :exit }
    end
  end
end

Webサーバーのマルチスレッドアーキテクチャについては、Pumaドキュメントの"アーキテクチャ"をご覧ください。私たちの単純な実装とは異なり、リクエストを読み込んでスレッドプールにコネクションを送信する追加のスレッドがあります。このパターンは後ほどRactorを扱うときに実装する予定です。

Fiber

Fiberはあまり知られていませんが、Ruby 3に追加された機能です(Fiber::SchedulerInterfaceを実装して使います)。Fiberは、goroutineのようなルーチンの一種と考えられます。Fiberはスレッドに似ていますが、Fiberを管理するのはOSではなくRubyです。Fiberのメリットは、コンテキストの切り替えが少ないことであり、OSによるスレッド切り替えよりもFiber切り替えの方がパフォーマンスのペナルティが小さくなります。

スレッドと異なるのは、Fiberを使う場合はスケジューラを実装する責任が生じる点です。ありがたいことに、以下のような既存のスケジューラgemがあります。

dsh0416/evt - GitHub

digital-fabric/libev_scheduler - GitHub

socketry/async - GitHub

それでは、Fiber版のHTTPサーバーがどうなるかを見てみましょう(完全なコード)。

def start
  # Fiberはスケジューラがないと動かない
  # スケジューラはカレントのスレッドでオンになる
  Fiber.set_scheduler(Libev::Scheduler.new)

  Fiber.schedule do
    server = TCPServer.new(HOST, PORT)
    server.listen(SOCKET_READ_BACKLOG)
    loop do
      conn, _addr_info = server.accept
      # 理想的にはFiberの個数をスレッドプールで制限する必要がある
      # 以下の理由から、リクエストを無制限に受け付けるのはよくない
      # ・メモリなどのリソースが不足する可能性がある
      # ・Fiberの個数に比べて戻りが減少する
      # ・リクエスト送信への背圧がないと適切なロードバランスや
      #   リクエストのキューイングが困難になる
      Fiber.schedule do
        request = RequestParser.call(conn)
        status, headers, body = app.call(request)
        HttpResponder.call(conn, status, headers, body)
      rescue => e
        puts e.message
      ensure
        conn&.close
      end
    end
  end
end

参考

Ractor

Ractorは、Ruby 3に追加された最も魅力的な機能です。Ractor はスレッドに似ていますが、複数のRactorをパラレルに実行「可能」で、Ractorごとに独自のGILを持つ点が異なります。

原注

残念ながら「おもちゃ」という言葉は暗喩ではなく、Ractorsが使えるようになるにはまだまだかかります。私の実験では、macOSでセグメンテーションフォールトが発生します。Linuxではセグメンテーションフォールトは発生しないものの、依然としてバグがあります。たとえばRactorの中で例外がrescueされていないとアプリケーションがクラッシュする可能性があります。さらに重要な点は、明示的にsharedとマーキングされていない限り、2つのRactorが同一のオブジェクトを扱えないということです。本記事執筆時点では、Ruby標準ライブラリのグローバルオブジェクトが多すぎるため、HTTPリクエストを送信できません。

それでは、Ractorを使うとどうなるかを見ていきましょう(完全なコード)。

def start
  # このキューは受信リクエストを
  # 公平にディスパッチするのに使われる
  # キューをワーカーに渡すと最初に空いたワーカーが
  # yieldされたリクエストを受け取る
  queue = Ractor.new do
    loop do
      conn = Ractor.receive
      Ractor.yield(conn, move: true)
    end
  end
  # ワーカーがコンカレンシーを決定する
  WORKERS_COUNT.times.map do
    # キューとサーバーを渡して
    # Ractor内部で利用可能にする必要がある
    Ractor.new(queue, self) do |queue, server|
      loop do
        # コネクションがyieldされるまでこのメソッドはブロックされる
        conn = queue.take
        request = RequestParser.call(conn)
        status, headers, body = server.app.call(request)
        HttpResponder.call(conn, status, headers, body)
        # エラーをrescueしないとRactorが死ぬだけでなく
        # `allocator undefined for Ractor::MovedObject`がランダムに発生して
        # プログラム全体がクラッシュすることがわかった
      rescue => e
        puts e.message
      ensure
        conn&.close
      end
    end
  end
  # リスナーは新しいコネクションを受け取ってキューに渡す
  # キュー内のyieldはブロッキング操作なのでこれを別のRactorに分けたが、
  # それまでのコネクションがすべて処理完了するまでは
  # 新しいコネクションを受け取れず、
  # リクエスト送信先のワーカーがビジーな可能性があるため
  # ワーカーへのコネクション送信にsendが使えない
  listener = Ractor.new(queue) do |queue|
    socket = TCPServer.new(HOST, PORT)
    socket.listen(SOCKET_READ_BACKLOG)
    loop do
      conn, _addr_info = socket.accept
      queue.send(conn, move: true)
    end
  end
  Ractor.select(listener)
end

参考

パフォーマンステスト

サーバーを実際に動かさないとこの記事は終わりません。

そこで以下のタスクについてサーバーごとにテストを行いました。

以下の表は1秒あたりのリクエスト数です(多いほどよい)。サーバーには4つのコンカレントなリクエストで負荷をかけました。

CPU負荷 ファイル読み込み HTTPリクエスト
シングルスレッド 112.95 10932.28 2.84
マルチスレッド 99.91 7207.42 10.92
Fiber 113.45 9922.96 10.89
Ractor 389.97 18391.25 -1

結果について

CPU負荷が高いタスクについては、Ractorが他のサーバーを4倍近く(つまりコンカレンシーの個数)上回っていました。マルチスレッドのパフォーマンスは、スレッド切り替えのオーバーヘッドのためシングルスレッドよりも低く、Fiberはシングルスレッドとほぼ同等のパフォーマンスでした(オーバーヘッドが少ないということなので、よいことです)。
ひとつ非常に嬉しくなかった点は、以下のコードを実行するとRactorのパフォーマンスが悪化したことでした。原因については皆目見当がつきません。Rubyチームの仕事はまだまだ山積みです。

10.times do |i|
  1000.downto(1) do |j|
    Math.sqrt(j) * i / 0.2
  end
end

ファイル操作についても申し添えておかなければなりません。私が使っているNVMe SSDは極めて高速なので、待ち時間が非常に小さくなってCPU負荷タスクに近い結果が得られた可能性があります。

HTTPタスクで最も遅かったのは予想どおりシングルスレッドサーバーで、Fiberサーバーとマルチスレッドサーバーは4倍近く高速でした(繰り返しますが、コンカレンシーを4にするとさらに改善する可能性もあります)。

結論

一般的なRailsサーバーの動作を考えれば、単にマルチスレッド化するだけでも大きな成果が得られるでしょう。最も遅い部分はI/Oが足かせになることが多いためです。

CPU負荷の高い計算が必要な場合は、おそらくRactorsが答えになりそうですが、大幅な変更が必要なので実用化されるまでには何年もかかりそうです。

その意味で、実際に最も有用な追加機能はFiberかもしれません。スレッドより小さいオーバーヘッドで多数のWebリクエストを処理するなど、限られた範囲であればすぐにでもFiberを利用できます。ただしFiberには独自のスレッドローカル変数があるので引き続き注意が必要です。

関連記事

Ruby: Ractorによる安全な非同期通信の実験(翻訳)


  1. 原注: Ractorは上述の制約のせいで動かせず、標準ライブラリにはHTTPリクエスト送信機能がまだありません。サーバーからの読み込みをTCPで実装してみてもよかったかもしれませんが、ここでやめておきました。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。