Ruby 3: FiberやRactorで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があります。
それでは、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
参考
- Ractor API: class
Ractor
- Documentation for Ruby master - 仕様: ruby/ractor.md at dc7f421bbb129a7288fade62afe581279f4d06cd · ko1/ruby
パフォーマンステスト
サーバーを実際に動かさないとこの記事は終わりません。
そこで以下のタスクについてサーバーごとにテストを行いました。
以下の表は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には独自のスレッドローカル変数があるので引き続き注意が必要です。
概要
原著者の許諾を得て翻訳・公開いたします。
FiberとRactorについては以下もどうぞ。
Fiber
(Ruby 3.0.0 リファレンスマニュアル)