@ledsun blog

無味の味は佳境に入らざればすなわち知れず

Real World Applications with the Ruby Fiber Scheduler

rubykaigi.org

https://github.com/socketry/rubydns を作っていました。 多数のリクエストに対応するために GitHub - eventmachine/eventmachine: EventMachine: fast, simple event-processing library for Ruby programs の導入を考えました。

DNSは一つのリクエストを受信すると複数の親サーバーにリクエストを送ります。 同期的に順番に問い合わせると時間が掛かります。 同時並行で問い合わせたいです。 現状のRubyで素直に実装すると送信するリクエスト毎にスレッドを立てて、レスポンスを待ちます。 IO待ちの場合は複数スレッドが同時に動きます。 これで同時並行の問い合わせが実現できます。 僕は LODQA : Question-Answering over Linked Open Data という検索エンジンをこんな構成で実装しました。 多分、似たようなだったのでしょう。

この方法の場合、処理できる問い合わせ数をスケールさせようとするとスレッドを作れる数が制約になります。 C10K問題では、スレッドのメモリ消費量が大きく、10000スレッド作れないと言ってた記憶しています。 C10K問題へんの典型的な回答がイベントループの実装です。 Rubyでイベントループを実装したものがEventMachineです。 つまり、もっともスタンダードな手法を試したという事だと思います。

EventMachineを導入するには、既存のコードを大きく書き換えないといけません。 アプリケーション全体を書き換えるのはやりたくないです。 そこで、Fiber Schedulerを作りました。 またFiber schedulerを使いやすくするために GitHub - socketry/async: An awesome asynchronous event-driven reactor for Ruby. を作りました。

https://socketry.github.io/async/guides/getting-started/index.html のサンプルコードを参考にして簡単なスクリプトを動かして見ます。

require 'async'

def slow_function
  Async do |task|
    task.sleep 1
  end
end

Async do
  slow_function
  slow_function
end

動かしてみて気がつきました。 これはJavaScriptのPromiseですね。 Asyncで包むとPromiseが返ってくるので、特別なことをしないと並行に実行されます。

Async do
  slow_function.wait
  slow_function.wait
end

待ちたい場合はwaitします。

もしかすると、いまはメモリ消費量はそんなに問題なくて、ネイティブスレッドを作ることが問題なのかもしれません。 そう考えるとMaNyでM:Nスレッドが実現すると、Asyncに置き換える必要すらなくスレッドで書いたコードがそのままスケールするようになるのかもしれません。

そういえばスレッドを使った非同期メソッドではRSpecのテストが失敗しなくて苦労した思い出があります。 これについては2018年のRubyKaigiでLTしてます。 Test asynchronous functions with RSpec - Speaker Deck スライドみてて思い出しました。 EventMachineのイベントループ中で遅い処理を挟むとサーバーが受けているすべてのリクエストへのレスポンスが遅くなるのでした。 それでスレッド使って非同期関数を使ってしのいでいました。 そしてRSpecでテストを書いたら、テストが失敗しなくて困りました。 なるほど、この用途で非同期関数を簡単に作れるのは嬉しいです。

あとはHTTPクライアントだけでなくて、ActiveRecordも速くなるそうです。 Rails 7のload_asyncと似たようなことを、Asyncをつかってやるのでしょうか? load_asyncはDBとのコネクションプールを余計につかうので、この辺も簡単にできるのは良さそうです。 イベントループをどこではじめるのか?などは、いまいちイメージがつかめていません。 あ、ちがいますね。 同一スレッドでうごいたらDBコネクションがわけられないですね。 それで Introduce `ActiveSupport::IsolatedExecutionState` for internal use by casperisfine · Pull Request #43596 · rails/rails · GitHub で、Thread.curretとFiber.currentを使い分けられるようにしたようです。

あとRails 7がFalconで動くみたいなことも言ってたので、機会をみて試してみたいです。

参考

追記

Help understanding Falcon/Rails/ActiveRecord Limitations and Future · Discussion #186 · socketry/falcon · GitHub を読む限りでは、現時点ではRails 7というかActiveRecordがFalcon上ではスッとは動かなさそうです。 たぶん、動くけどDBコネクションを共有するので、すべてのHTTPリクエストのSQLが直列に実行されて遅くなるのでしょうか? やっぱり、この辺は動かしてみないとイメージがつかめません。

https://github.com/rails/rails/pull/44219https://github.com/rails/rails/issues/42271で、対応を進めていそうです。 んー、でもReaperで死んだスレッドと紐付いているコネクションを自動開放する機能はFiberでどうやって実装するのでしょうか? AsyncでもFiberが死んだ状態を取れるのでしょうか?謎です。