hasumikin.com

hasumikin is a programmer.

ダイナミックSpinel(4) マルチスレッドRails編

(3) Railsコンソール編のつづきです。前回、Fluxpinelを組み込んだバイナリには「Rubyのコードを動かすOSのスレッドが1本になる」という難点がありました。今回はこれをなんとかします。Railsのリクエストは複数のコアに並べたまま、コンソールの評価だけを1本ずつ動かす形です。

先に用語を揃えてから、何が共有できなかったのか、どう直したのか、コンソールで重い処理を打ったときにリクエストがどうなるのか、を書きます。

用語の整理

Spinelのスレッドの文書1は、3つの言葉を使い分けています。本記事のタイトルは「マルチスレッド」と言っていますが、これだけだとちょっとミスリーディングです。ポイントは「ワーカー」です。

用語 何か 作るもの 数
OSスレッド カーネルがスケジュールするスレッド(pthread) Spinelのランタイム ワーカーN本、監視スレッド1本、GCのスイーパなど
ワーカー Rubyのコードを動かすOSスレッド ランタイム。mainも1本に数える SPINEL_WORKERS。既定はコア数
グリーンスレッド RubyのThread。ランタイムが自分で切り替える実行の単位 プログラムのThread.new プログラムが作った数

ワーカーはOSスレッドの一種です。OSスレッドのうち、Rubyのコードを動かすものだけをワーカーと呼びます。タイマーを鳴らしたり、長く走るスレッドに「順番を譲れ」と合図したりする監視スレッドはOSスレッドではありますが、Spinel用語ではワーカーとは呼びません。

グリーンスレッドは、ワーカーの上で走ります。M本のグリーンスレッドをN本のワーカーに載せる、M:Nの形です。GVLはなく、ワーカーの間は本当に並列です。

グリーンスレッドのライフサイクル

「グリーンスレッドが走る」と言うときの「走る」は、作られた瞬間ではありません。ワーカーが実行キューから取り出して、その上に切り替えた瞬間です。

  1. 作られる: Thread.newが呼ばれると、グリーンスレッド(ファイバーとそのCスタック)が作られ、グローバルの実行キューに入る。まだどのワーカーのものでもない
  2. 最初に走る: 手の空いたワーカーがキューから取り出し、ブロックの本体が始まる。このときのワーカーに、以後ずっと固定される。Cのフレームがそのワーカーのスレッドローカルな領域(GCのルート、例外のスタック)を指している状態
  3. 待つ: I/Oやsleepで止まると、ワーカーから外れる。ワーカーは別のグリーンスレッドを動かす
  4. 再開する: 待ちが終わると、固定されたワーカーのキューに戻り、順番が来たら続きが走る
  5. 終わる: ブロックが返ると消える

ブログのサーバに当てはめると、こうなります。起動時にmainがトップレベルを実行し、acceptを待つスレッドとConsole.listenのスレッドを作ります。接続が来るたびに、acceptのスレッドがその接続のためのグリーンスレッドを作ります。空いているワーカーがそれを拾って走らせ、応答を書いて終わります。接続ごとに新しいグリーンスレッドができて、どのワーカーが拾うかはそのときの空き次第です。ncでコンソールにつなぐと、セッションのグリーンスレッドができます。拾ったワーカーに固定されるので、そのセッションの評価はずっと同じワーカーの上で走ります。

何が共有できなかったのか

(3) Railsコンソール編のFluxpinelは、組み込んだプログラムのSpinelワーカーを1本に固定していました(SPINEL_WORKERS=1)。Fluxpinelの内部の状態が、ワーカーをまたいで共有できる作りになっていなかったからです。共有できない状態は、2種類ありました。

  • 実行中の状態: breakやreturnのジャンプの保留、キーワード引数のハッシュ、フレームスタックのキャッシュなど。ウォーカー2がひとつのプログラムを順に評価するあいだだけ意味を持つ値で、ひとつの大域の構造体に入っている。2本のワーカーが同時に書くと壊れる
  • 共有の構造: クラスとメソッドの表、定数、シンボル表、呼び出しのキャッシュ。読みは多く、書きはdefやincludeのときだけ

いっぽう、フレームスタック(Rubyのローカル変数の置き場)は、はじめからファイバーごとに管理されています。グリーンスレッドの切り替えに耐えるために、そうしてあったからです。GCのルートもランタイムの側でワーカーごとに分かれています。

直し方は2つ

形 内容 得られるもの
A. ウォーカーに1本のロック ウォーカーへの入口ロックを取る。リクエストはNワーカー並列 コンソールの評価は1本ずつ。リクエストは並列
B. ウォーカー自体を並列に 実行中の状態をスレッドローカルにし、共有の構造ごとにロックを入れる ウォーカーのコードも並列に走る

Aを選びました。Fluxpinelの役目は、コンパイル済みのプログラムの「のり(glue)」と「のぞき窓」です。ホットパスを動かすものではありません。コンソールの評価が1本ずつになるのは、CRubyのrails consoleと同じ性質です。Bは工数が大きく、それに見合う使い方がいまのところなさそうです(あったら教えて)。

Aの実装

ウォーカーの入口でロック

入口は、ぜんぶ数えられます。

  • コンパイル済みのコードが呼ぶFluxpinel.feed、Fluxpinel.load、Fluxpinel.eval、Fluxpinel.call
  • ウォーカーのコードのThread.newが作ったグリーンスレッドの本体
  • コンパイル済みのコードがFluxpinelのオブジェクトに触れたとき、ランタイムがFluxpinelに問い合わせるフック(inspect、to_s、==、数値への変換など)

この口のすべてで、ひとつのロックを取ります。ロックはSpinelのランタイムのMutex、つまりRubyのMutexと同じものです。pthreadのmutexではありません。待つグリーンスレッドはスケジューラの中でparkして、ワーカーを解放します。pthreadのmutexで待つと、ワーカーごとOSの中で止まり、GCのstop-the-worldがそのワーカーを待ち続けてしまいます。

ロックには持ち主と深さを持たせました。ウォーカーがコンパイル済みのコードを呼び、そのコードがFluxpinel.callでウォーカーに戻ってくることがあるからです。同じグリーンスレッドからの再入は、深さを1増やすだけで通します。

待つところでロックを放す

ロックを取ったまま待たれると、ほかのセッションが止まります。セッションがsock.getsで次の行を待っているあいだ、別のセッションが何も評価できないのでは困ります。

そこで、ウォーカーがランタイムの中で待つ箇所では、ロックを放してから待ち、起きたら取り直します。I/Oの読み書き、sleep、QueueとMutexとConditionVariableの待ち、Thread#join、IO.selectです。CRubyがGVLを放すblocking regionと同じ。

もっとも、コンソールの場合、待つのはほとんどコンパイル済みの側(sock.gets)です。Fluxpinel.feedは1行を評価して返るだけだから、戻ったときにはロックを放しています。ウォーカーの中で待つのは、コンソールでsleepやgetsを打ったときです。

ワーカーの数

Fluxpinelを組み込んだプログラムから、SPINEL_WORKERS=1の固定を消しました。組み込んだプログラムは、Spinelがコンパイルしたプログラムのふだんの数、つまりコアの数だけワーカーを持ちます。

単体のfluxpinelコマンド(Rubyインタプリタ)はワーカー1本のままです。Rubyのコードをすべてウォーカーが動かすため、ワーカーを増やしてもロックによって順序処理になり、並列になりません。

GC

心配していたのはGCでした。ワーカーが複数になると、別のワーカーの割り当てが引き金になって、ウォーカーが構造を書き換えている途中でstop-the-worldが来るのではないか?

大丈夫、来ません(たぶん)。stop-the-worldは、すべてのワーカーがsafepointに着くまで待ちます。ウォーカーがsafepointを確かめるのは、whileの折り返しとブロックの呼び出しです。どちらも文の境目。メソッドの表や定数の配列を書き換えている途中ではありません。待っているセッションは、parkした時点でルートを公開ずみです。ウォーカーの構造を印づけするフックは、これまでどおりcollectorがstop-the-worldの中で呼びます。

検証

ブログのサーバを、ワーカーの数を指定せずに起動します。12コアのマシンだと、ワーカーは12本です。

$ FLUXPINEL_CONSOLE=4000 "$FX/build/roundhouse/host/blog" &
blog-rails (spinel) listening on http://localhost:3000, pid 1360211
  one green thread per connection; OS workers: one per core (SPINEL_WORKERS=N to cap); processes: 1
fluxpinel console on 127.0.0.1:4000

コンソールにつないで、20秒ほどかかる処理を打ちます。

$ nc 127.0.0.1 4000
fx> s = 0; 300_000_000.times { |i| s += i }; s

そして別の端末からGET /articlesを5秒間叩き続けて、1秒あたりの応答数を数えます。叩く側はCRubyのNet::HTTPで、8プロセス×8スレッドの64接続です。コンソールが何もしていないときと、上の処理の最中とを比べます。前回の形(SPINEL_WORKERS=1)でも同じことをしました。

  コンソールが暇なとき コンソールが重い処理の最中
ワーカー12本(今回) 9900 req/s 9450 req/s
ワーカー1本(前回の形) 3050 req/s 1800 req/s

今回の形では、重い処理の最中もスループットはほとんど変わりません(5%の差)。コンソールの評価が占めるのは、1本のワーカーだけだからです。前回の形では、同じ処理でスループットが4割落ちました。1本のワーカーをコンソールと分け合っていたからです。

重い処理の最中に、もうひとつコンソールをつないで6 * 7を打つと、すぐに42が返ります。重い評価のグリーンスレッドは、ループの折り返しのたびにsafepointを確かめ、タイムスライスが切れていれば、ロックを放して順番を譲るからです。

コンソールで重い処理を打つと、Railsは止まるのか

止まりません。CPUを分け合う度合いが、前回の形と変わるだけです。

前回の形(ワーカー1本)では、コンソールの処理もリクエストも、1本のワーカーの上を順番に走りました。監視スレッドが約10 msごとにタイムスライスし、ウォーカーはwhileの折り返しとブロックの呼び出しで順番を譲ります。リクエストは受けられ、応答もしますが、CPUはコンソールと半分ずつでした。

今回の形では、コンソールの処理は、そのセッションのグリーンスレッドが固定されたワーカーを1本占めます。ほかのワーカーは影響を受けません。新しいリクエストはグローバルの実行キューに入り、空いているワーカーが拾うので、忙しいワーカーを避けます。忙しいワーカーで走り始めていて、まだ終わっていないリクエストだけが、タイムスライスを分け合います。接続は短命だから、ふつうはごく少数です。

どちらの形でも止まる処理はあります。safepointを通らない処理です。ループもブロックも使わない深い再帰、またはランタイムの1回の呼び出しが長く続く場合(巨大な配列のsortなど)は、その呼び出しが戻るまで順番を譲りません。前回の形ではこの間のすべてのリクエストが待ちました。今回の形では、そのワーカーに固定されたスレッドだけが待ちます。これはAOTのコードも同じ性質で、Fluxpinelに限った話ではありません。

できないこと、残ること

  • ウォーカーのコードは1本ずつ: 2つのセッションが同時に重い評価をすると、順番に処理される。Rubyのコードを並列に走らせたいなら、コンパイル済みの側に書くべき
  • 共有の値の競合: コンソールが持つ配列やハッシュを、コンパイル済みのコードと同時に書き換えると壊れる。SpinelのThreadの約束と同じく、Mutexはプログラムの責任
  • ウォーカーの中の待ち: ロックを放す箇所は、ウォーカーがランタイムを呼ぶ場所を(Claude Code先輩が)洗い出した。これに漏れ(バグ)があれば、そこで待つセッションがほかのセッションを止めてしまう。漏れは次の節のスクリプトが探す

もしもSpinel側に仕組みを持つなら

「ウォーカーの中の待ち」は、Fluxpinelがランタイムの関数名を知っていることに依存しています。ランタイムのどの関数がparkするかは、Spinelのソースにしか書いてありません。Fluxpinelはそれをスクリプトによって拾っています。tools/park_audit.rbは、Spinelのlib/*.cとlib/*.hから呼び出しグラフを作り、sp_sched_block系とI/Oの待ちとsp_native_enterに到達する関数(178個くらいある)を列挙して、FluxpinelがそれをFX_UNLOCKEDの外から呼んでいる箇所を報告します。make testのたびに走ります。

これはSpinelの更新に日々追従するための腕力スクリプトです。これをなくすには、Spinelの側に「グリーンスレッドが待つ前と後」を知らせる口があればよさそう。ランタイムが組み込み先に「いまから待つよ」と伝えるフックです。こんなかんじ:

/* sp_sched.h */
extern void *(*sp_park_enter_hook)(const void *on);               /* before a wait that may park */
extern void  (*sp_park_leave_hook)(const void *on, void *token);  /* after it */
  • 関数ポインタ2本。NULLのままならランタイムの動きは変わらず、待ちの関数の入口に分岐が1回増えるだけ。sp_safepoint_publish_hookと同じ流儀
  • 呼ぶ場所は、待ちの公開関数の入口と出口。sp_sleep、sp_Thread_join/pass/value、sp_Mutex_lock、sp_CondVar_wait、sp_Queue_push/pop、sp_sched_wait_io_impl(I/Oの待ちはここ1か所に集まる)、sp_sched_wait_child、sp_safepoint、sp_native_enter/leave。Claude Code調べでは18か所
  • onは待つ対象。組み込み先が自分のロックを待つとき、素通りさせるための引数。FluxpinelのロックはSpinelのMutexそのもので、これがないとフックが自分を呼び続ける
  • tokenは組み込み先が持ち運ぶ値。Fluxpinelはロックの深さを入れる

実際にparkする直前、sp_sched_blockの中にはフックを置けません。そこはスケジューラのロックか待ちリストのロックを握っています。組み込み先のフックがMonitorを放すと、待ち手を起こすためにスケジューラのロックを取ろうとします。すでに握っているロックをもう一度取ることになり、止まります。入口なら何も握っていません。

割り切る点が2つあります。Mutex#lockは競合しないときも入口でフックが走ります。Thread#raiseやkillが待ちから抜けるときはleaveが走りません。組み込み先が例外のハンドラでロックを戻します(FluxpinelのFX_TRY_LANDEDがいまやっていることです)。

これが入ると、Fluxpinelはフックの実装20行を足して、FX_UNLOCKED48か所のうち45か所と、生成ラッパのための関数名の表を消せます。残るのはランタイムの待ちではない3か所(ffi_callとカーネルの入口)です。監査スクリプトも要らなくなります。この機構の受益者がほかにもいるか、あるいは受益者がFluxpinelだけだとしても価値があるか、をまつもとさんに聞いてみる予定です。

まとめ

Fluxpinelを組み込んだRailsアプリは、コアの数だけSpinelワーカーを持ち、コンソールの評価だけが1本のロックの下で動くようになりました。前回の表の最後の行「複数のOSスレッドで動くプログラムに組み込む」が「できている」になります。

コンソールで何を打っても、ほかのコアのリクエストは走り続けます。rails consoleの便利さとSpinelのバイナリの並列さが、両立するようになりました🎉


  1. Spinelのdocs/thread.mdとdocs/internals/thread-mn-design.md。 ↩

  2. Fluxpinelの中核の、Prismの構文木をたどって評価する部分。 ↩