🍣

RactorベースのWebサーバーを書く(2025年版)

に公開

Ruby の並行処理 Ractor は 2025 年 7 月現在どのくらいよくありそうな Ruby プログラミングで書けそうになっているかを測るため、Ractor を使った簡潔な Web Server を作り、その次に Rack に対応させてみる。

後述する Ractor の新しい API、Ractor::Port を使うために、Ruby は開発版を使っている。Ruby 3.5 には入る予定のようなので Ruby 3.5 以降であれば開発版でなくても動くはずだ。

2020 年の 9 月に書かれたWriting a Ractor-based web server、その続編として 2020 年の 12 月に書かれたWriting a Ractor-based web server: part IIが、整理されていて読みやすかったので、今回の記事の話の進め方もこれを真似している。

Web サーバー

Web サーバーというのは TCP ソケットを accept し、そこから read し、HTTP ヘッダを解析し、HTTP ボディを返すような、実装が容易なテキストベースのプロトコルだ。

以下がクライアントから Web サーバーに送られてくるリクエストの例だ。実装の面から考えると、Web サーバーは、クライアントのソケットからこの文字列を read することになる。

GET / HTTP/1.1
Host: localhost:10000
User-Agent: curl/7.64.1
Accept: */*

そしてレスポンスの例だ。実装の面から考えると、クライアントのソケットにこの文字列を write することになる。

HTTP/1.1 200
Content-Type: text/html

Hello world

私たちも、AppSignal が投稿したBuilding a 30 line HTTP server in Rubyから始める。

require 'socket'
server = TCPServer.new(8080)

while session = server.accept
  request = session.gets
  puts request

  session.print "HTTP/1.1 200\r\n"
  session.print "Content-Type: text/html\r\n"
  session.print "\r\n"
  session.print "Hello world! The time is #{Time.now}"

  session.close
end

実際に irb と curl で試してもうまく動く。

Ractor を導入する

Ractor を始めるなら Ruby のリポジトリにある ドキュメントを読んで概要を掴んでからがおすすめだ。
ただこのドキュメントも 2025 年 7 月 14 日現在では最新化されていないところがある。最近 Ractor::Port という機能が導入された。こちらは Ractor::Port ― Ractor の API を一新した話 に詳しい。今回は積極的に一新された API の方を使っていく。

さてそれでは先程の例を Ractor と組み合わせていこう。

require 'etc'
require 'socket'
server = TCPServer.new(8080)
CPU_COUNT = Etc.nprocessors
workers = CPU_COUNT.times.map do
  Ractor.new do
    loop do
      # receive TCPSocket
      s = Ractor.receive

      request = s.gets
      puts request

      s.print "HTTP/1.1 200\r\n"
      s.print "Content-Type: text/html\r\n"
      s.print "\r\n"
      s.print "Hello world! The time is #{Time.now}\n"
      s.close
    end
  end
end

loop do
  conn, _ = server.accept
  # pass TCPSocket to one of the workers
  workers.sample.send(conn, move: true)
end

このコードでは最初に CPU の数と同数のワーカーを動かし、メインスレッドでソケットの接続を listen、accept したコネクションをランダムな Ractor に送っている。このコードを実行して、curl でアクセスすると期待通りに動いていることが確かめられる。

手があいているワーカーがリクエストをとる

worker.sample を使う方式は動作するものの、効率的とは言えない。ランダムに割り当てられたワーカーがまだ過去のリクエストを処理している最中かもしれないためだ。そこで、手があいているワーカーがリクエストをとる形に変更する。

require 'etc'
require 'socket'
controller = Ractor::Port.new
server = TCPServer.new(8080)
CPU_COUNT = Etc.nprocessors
workers = CPU_COUNT.times.map do
  Ractor.new(controller) do |controller|
    loop do
      controller.send(Ractor.current)
      s = Ractor.receive

      request = s.gets
      puts request

      s.print "HTTP/1.1 200\r\n"
      s.print "Content-Type: text/html\r\n"
      s.print "\r\n"
      s.print "Hello world! The time is #{Time.now}\n"
      s.close
    end
  end
end

loop do
  conn, _ = server.accept
  worker = controller.receive
  worker.send(conn, move: true)
end
元記事との違い

https://kirshatrov.com/posts/ruby-ractor-web-server の記事は以下のコードで共有キューを表現していた。(pipe を宣言しているところが Queue として動作している)
Ractor::Port を導入した際に Ractor#take は廃止となったので、今回は Ractor::Port での表現に変えている。

require 'socket'

# pipe aka a queue
pipe = Ractor.new do
  loop do
    Ractor.yield(Ractor.recv, move: true)
  end
end

CPU_COUNT = 4
workers = CPU_COUNT.times.map do
  Ractor.new(pipe) do |pipe|
    loop do
      s = pipe.take

      data = s.recv(1024)
      puts data.inspect

      s.print "HTTP/1.1 200\r\n"
      s.print "Content-Type: text/html\r\n"
      s.print "\r\n"
      s.print "Hello world!\n"
      s.close
    end
  end
end

server = TCPServer.new(8080)
loop do
  conn, _ = server.accept
  pipe.send(conn, move: true)
end

上のコードでの controller.receive で取得する worker は、処理をすぐに開始できる。controller.send(Ractor.current) で controller に登録した後にだけその worker は呼び出されることになるが、その時点で worker は必ずリクエストを処理できる状態(リクエスト処理完了後か、初期化済の状態)になっているためである。

こうして処理中のワーカーにさらに処理を送ることがなくなり、負荷分散できるようになった。

空いているワーカーがいなくても accept をすぐにすませる

先ほどのコードの最後

loop do
  conn, _ = server.accept
  worker = controller.receive
  worker.send(conn, move: true)
end

に注目しよう。このコードでは、空いているワーカーがでてくるまで controller.receive のところで処理が止まる(ブロッキングする)。処理が止まっているので、サーバーに空いているワーカーがいない状態で新しいクライアントが接続してきても server.accept は行われない。

効率のためには server.accept は常に実施したい。そこで Puma の Architecture に似せて、常に accept を続ける別の Ractor (Puma で言うところの Reactor クラス)を立ち上げて、ソケットの準備が整うのを待ってから処理担当のワーカーに渡す方式へと改善する。

このプログラムは全ての処理がメインの Ractor 以外で行われるようになっている。そのため最後の Ractor.select(dispatcher, *workers, listener) を書かないと、処理が終わっていないのにプログラムが終わってしまう。また副次的な効果として Ractor.select(dispatcher, *workers, listener) を含むループで、dispatcher, worker, listener それぞれの処理が意図せずに終わってしまった場合の対処を書けるようになった。(このコードではコメントにとどめている)

require 'etc'
require 'socket'

port = Ractor::Port.new
dispatcher = Ractor.new(port) do |port|
  controller = Ractor::Port.new
  todo = Ractor::Port.new
  port.send([controller, todo])
  loop do
    worker = controller.receive
    worker.send(todo.receive, move: true)
  end
end
controller, todo = port.receive
port.close

CPU_COUNT = Etc.nprocessors
workers = CPU_COUNT.times.map do
  Ractor.new(controller) do |controller|
    loop do
      controller.send(Ractor.current)
      s = Ractor.receive

      request = s.gets
      puts request

      s.print "HTTP/1.1 200\r\n"
      s.print "Content-Type: text/html\r\n"
      s.print "\r\n"
      s.print "Hello world! The time is #{Time.now}\n"
      s.close
    end
  end
end

listener = Ractor.new(todo) do |todo|
  server = TCPServer.new(8080)
  loop do
    conn, _ = server.accept
    todo.send(conn, move: true)
  end
end

loop do
  Ractor.select(dispatcher, *workers, listener)
  # if the line above returned, one of the dispatcher, the workers or the listener has crashed
end

リクエストヘッダを解析する

Web サーバー実装の次のステップとして、リクエストヘッダーを読みこんで解析する HTTP パーサーを組み込む。手近な HTTP パーサーとして、Ruby で Web サーバーを実装した WEBrick がある。この gem の一部を利用する。

ただ、そのままでは使えず、簡単な以下のコードでもエラーになる。

require 'webrick'
Ractor.shareable?(WEBrick::Config::HTTP) # => false
Ractor.new {  WEBrick::HTTPRequest.new(WEBrick::Config::HTTP) }
# can not access non-shareable objects in constant WEBrick::Config::HTTP by non-main Ractor. (Ractor::IsolationError)

これは WEBrick::Config::HTTP

  • main の Ractor で定義されている、つまりある Ractor 上で定義されたものを別の Ractor 上で使おうとしている
  • 設定のためのオブジェクトをいくつか持つ、可変する(mutable な)ハッシュオブジェクトなので、sharable ではない

の 2 つの条件に合致するためだ。

定数を同じ Ractor 上で定義するか、オブジェクトを不変(immutable)へと変更することで sharable にするとこの問題は解決する。この定数はライブラリの中で定義されているので、どの Ractor で定義するかを操作するのは難しい。sharable になるようにオブジェクトを操作することで解決を目指す。Ractor にはそれをするのに便利な Ractor.make_shareable という関数がある。これを使うと、以下のようにエラーが解消される。

require 'webrick'
Ractor.shareable?(WEBrick::Config::HTTP) # => false
Ractor.make_shareable(WEBrick::Config::HTTP)
Ractor.shareable?(WEBrick::Config::HTTP) # => true
Ractor.new {  WEBrick::HTTPRequest.new(WEBrick::Config::HTTP) }

同様に、処理を進めると WEBrick::HTTPUtils::HEADER_CLASSES も同じエラーになるので、shareable なオブジェクトへと強制変更しておく。

require 'etc'
require 'socket'
require 'webrick'

Ractor.make_shareable(WEBrick::Config::HTTP)
Ractor.make_shareable(WEBrick::HTTPUtils::HEADER_CLASSES)

port = Ractor::Port.new
dispatcher = Ractor.new(port) do |port|
  controller = Ractor::Port.new
  todo = Ractor::Port.new
  port.send([controller, todo])
  loop do
    worker = controller.receive
    worker.send(todo.receive, move: true)
  end
end
controller, todo = port.receive
port.close

CPU_COUNT = Etc.nprocessors
workers = CPU_COUNT.times.map do
  Ractor.new(controller) do |controller|
    loop do
      controller.send(Ractor.current)
      s = Ractor.receive

      request = WEBrick::HTTPRequest.new(WEBrick::Config::HTTP.merge(RequestTimeout: nil))
      request.parse(s)
      puts request.inspect

      s.print "HTTP/1.1 200\r\n"
      s.print "Content-Type: text/html\r\n"
      s.print "\r\n"
      s.print "Hello world! The time is #{Time.now}\n"
      s.close
    end
  end
end

listener = Ractor.new(todo) do |todo|
  server = TCPServer.new(8080)
  loop do
    conn, _ = server.accept
    todo.send(conn, move: true)
  end
end

loop do
  Ractor.select(dispatcher, *workers, listener)
  # if the line above returned, one of the dispatcher, the workers or the listener has crashed
end

Rack アプリケーションを動かす

Ruby で書かれた実用的な Web アプリケーションは、殆どの場合 Web サーバーとのインターフェースとして Rack を利用している。
今作っている Ractor を使った Web サーバーも、Rack と互換性を持つものにしてみよう。

この Web サーバーと Rack インターフェースを取り持つ部分は第 3 章: 自作の Rack サーバを実装してみようでわかりやすく解説されている。同じように Rack インターフェースに適合するように Web サーバーの Handler というものを作って登録する。

既に実装されている WEBrick の場合では Rack::Handler::WeBrick#service メソッドで主要な処理を行っている。

  1. WEBrick::HTTPRequest を入力として受け取り、Rack env に変換する
  2. その env を使って Rack app を call する
  3. Rack app の結果を WEBrick::HTTPResponse に詰めて出力する

この 2 つの資料を参考に、簡潔な Rack アプリケーションを動かせるような Handler を作る。

require 'etc'
require 'socket'
require 'webrick'

Ractor.make_shareable(WEBrick::Config::HTTP)
Ractor.make_shareable(WEBrick::HTTPUtils::HEADER_CLASSES)
Ractor.make_shareable(WEBrick::HTTPStatus::StatusMessage)

class App
  def call(env)
    if env["PATH_INFO"] == "/"
      [200, {}, ["It works!"]]
    else
      [404, {}, ["Not Found"]]
    end
  end
end

class RactorServer
  CPU_COUNT = Etc.nprocessors

  def self.run(app, **options)
    new(app, options).start
  end

  def initialize(app, options)
    @app = app
    @options = options
  end

  def start
    port = Ractor::Port.new
    dispatcher = Ractor.new(port) do |port|
      controller = Ractor::Port.new
      todo = Ractor::Port.new
      port.send([controller, todo])
      loop do
        worker = controller.receive
        worker.send(todo.receive, move: true)
      end
    end
    controller, todo = port.receive
    port.close

    workers = CPU_COUNT.times.map do
      Ractor.new(@app, @options, controller) do |app, options, controller|
        loop do
          controller.send(Ractor.current)
          s = Ractor.receive

          request = WEBrick::HTTPRequest.new(WEBrick::Config::HTTP.merge(RequestTimeout: nil))
          request.parse(s)

          env = request.meta_vars
          status, headers, body = app.call(env)

          response = WEBrick::HTTPResponse.new(WEBrick::Config::HTTP)
          response.status = status
          body.each { |part| response.body << part }
          response.send_response(s)
        end
      end
    end

    listener = Ractor.new(todo) do |todo|
      server = TCPServer.new(8080)
      loop do
        conn, _ = server.accept
        todo.send(conn, move: true)
      end
    end

    loop do
      Ractor.select(dispatcher, *workers, listener)
      # if the line above returned, one of the dispatcher, the workers or the listener has crashed
    end
  end
end

Rackup::Handler.register :ractor_server, RactorServer

run App.new

このファイルを config.ru という名前で保存して rack アプリケーションとして動かす。rack アプリケーションは rackup コマンドで起動する。
起動オプションに何もつけないと、lint が含まれるデフォルトのスタックで起動するのだが、今回は簡易的な実装で Rack アプリケーションが必要とするパラメータを網羅しておらず lint でエラーになるため --env none で外している。

コードは https://gist.github.com/niku/44878506db183eeb82d7e14da4f558c7 にあるので、手元でも以下のようにすると動作を試せる。

gem install rackup
git clone https://gist.github.com/niku/44878506db183eeb82d7e14da4f558c7 ractor_web_server
cd ractor_web_server
rackup --server ractor_server --env none config.ru

まとめ

「Ruby の並行処理 Ractor は 2025 年 7 月現在どのくらいよくありそうな Ruby プログラミングで書けそうになっているか」というのを知るために試した。

Writing a Ractor-based web server が書かれた 2020 年から、Ractor 本体の改善や周囲のライブラリの Ractor 対応によって、簡易的な Web サーバーであればよくありそうな Ruby プログラミングとして、すごく特別なワークアラウンドなしでも書けることがわかった。ただ、まだいくつかの部分で Ractor.make_shareable が必要だった。このあたりはライブラリ側に地道に PR を送っていくことになるだろう。とはいえ Ractor.make_shareable のおかげで手元での対処は容易だった。

新しい Ractor::Port の API は私には理解しやすかった。
私の場合は Ractor の考えの元になっているアクターモデルの考え方に Erlang や Elixir を通じて親しんでいたので、ギャップが少なかったのかもしれない。
それでも、Ractor::Port を紹介するブログにも、欠点として「典型的な producer-consumer 型では、Port を土台にもう一段の抽象化が必要になる」と書かれていた通り

port = Ractor::Port.new
dispatcher = Ractor.new(port) do |port|
  controller = Ractor::Port.new
  todo = Ractor::Port.new
  port.send([controller, todo])
  loop do
    worker = controller.receive
    worker.send(todo.receive, move: true)
  end
end
controller, todo = port.receive
port.close

のようなコードを思いつくのには少し時間がかかったので、便利なラッパーがあってもいいのかもしれない。(一回思いついたら平気になるような、馴れの問題かもしれない)

GitHubで編集を提案

Discussion