Rails のスレッドを増やしても速くならないのに、なぜ Go の goroutine は速くなるのか?
Puma の RAILS_MAX_THREADS を 5 から 20 に増やしてもレスポンスタイムが変わらなかった一方、Go で go を並べただけの処理は CPU を使い切って速くなった——そんな体感の違いに覚えはないだろうか。両方とも「並行処理」と呼ばれているのに、なぜ結果がこうも違うのか。
- プロセスは独立したメモリ空間を持ち、スレッドは同じプロセス内のメモリ空間を共有する。共有ゆえにスレッドは軽いが、Ruby(MRI) には GVL があり CPU バウンドな処理は結局 1 スレッドずつ順番にしか進まない。
- GVL は I/O 待ちの間は解放されるため、スレッドを増やすことは I/O バウンドな待ち時間の重ね合わせには効くが、CPU バウンドな処理を速くはしない。真の並列が要る処理には複数プロセス(Puma の
workers)が要る。 - Go の goroutine は OS スレッドそのものではなく、ランタイムが少数の OS スレッドに多数の goroutine を割り当てる M:N スケジューラの上で動く。
GOMAXPROCS個の OS スレッドが並列に goroutine を実行するため、CPU バウンドな処理でも複数コアを使い切れる。
プロセスとスレッド、何が「重さ」を分けるのか
プロセスは OS がメモリ空間・ファイルディスクリプタ・シグナルハンドラなどを丸ごと 1 セット割り当てる実行単位で、他のプロセスとメモリを共有しない。スレッドは 1 つのプロセスの中に複数作れる実行の流れで、同じプロセスが持つメモリ空間(ヒープやグローバル変数)を共有する。共有しているからこそスレッドはプロセスより生成コストが低く、切り替え(コンテキストスイッチ:CPU が実行対象を切り替える際にレジスタやスタックポインタを退避・復元する処理)も軽い。
この軽さの代償が安全性だ。プロセスは OS が強制的にメモリを隔離してくれるので 1 つが暴走しても他へ影響しにくいが、スレッド間は同じ変数を素で触れるため、複数スレッドが同時に書き換えると競合が起きる。言語やランタイムはこの競合を「ロック」や「そもそも並列実行させない」という形で制御しており、Ruby と Go はこの制御方針が正反対になっている。
Ruby(MRI) のスレッドと GVL という関所
標準の Ruby 処理系である MRI(CRuby) には GVL(Global VM Lock、旧称 GIL)という機構があり、Ruby のバイトコードを実行できるのは常に GVL を保持した 1 スレッドだけに制限される。スレッド自体は OS スレッドとして本物の並行実行が可能だが、Ruby コードを動かす権利という 1 本の関所を全スレッドが奪い合う形になる。MRI はスレッドが GVL を最大 100 ミリ秒保持したら他の待機スレッドへ強制的に譲るようスケジューリングしており、この時間は Ruby 3.3 以降 RUBY_THREAD_TIMESLICE 環境変数で調整できる。
ただし GVL は I/O 待ちの間は明示的に解放される。DB へのクエリ発行やネットワーク応答待ちのようにスレッドが「待つだけ」の時間は、その間に別のスレッドが GVL を得て Ruby コードを進められる。つまりスレッドを増やす効果は、CPU バウンドな計算では頭打ちになる一方、I/O バウンドな待ち時間が多い処理では素直に効く。Sidekiq のバックグラウンドジョブでスレッド数を多めに取る設計が有効なのはこのためだ。
┌ MRI: GVLは1本しかない ────────┐ │ thread A ██░░██░░██ (Ruby実行)│ │ thread B ░░██░░██░░ (Ruby実行)│ │ ░=I/O待ち(GVL解放中)│ │ → CPU計算区間(█)は重ならない │ └───────────────────────────────┘
GVL はレジが 1 台しかないスーパーのようなものだ。客(スレッド)は何人も店内にいて自由に動けるが、会計(Ruby コードの実行)ができるのはレジを握っている 1 人だけ。ある客が「電子マネーの読み取り待ち」で立ち止まっている間(I/O 待ち)は、その隙にレジを他の客に譲れる。しかし暗算で割引計算をしている客(CPU バウンドな処理)は他の誰にもレジを譲らず居座り続けるので、行列は客を増やしても短くならない。
Puma がプロセスで GVL を回避する仕組み
1 つの GVL の中でどれだけスレッドを増やしても CPU バウンドな処理は並列化されないので、複数コアを使い切るにはプロセスを複数立てるしかない。Puma の cluster mode はこれを実現する仕組みで、workers の数だけ fork によって OS プロセスを複製する。各プロセスは独立した GVL を持つため、workers 数だけ真の並列度が得られる。
デフォルトでは各 worker のスレッド数は MRI 上で 0:5(最小 0・最大 5)で、-w 2 -t 5:5 なら合計 10 スレッドが 2 プロセスに分散する。preload_app! を使うと fork 前にアプリコードを読み込んでおけるため、変更されないメモリページを親子プロセスで共有する copy-on-write が効き、worker を増やしてもメモリ使用量は単純な倍増にはならない。ただし Ruby の参照カウント方式のガベージコレクションはオブジェクトを触るたびにカウンタを書き換えるため、リクエストが進むにつれて共有ページが徐々に複製され、copy-on-write の効果は時間とともに薄れていく。
# Puma: workers=2, 各workerに5スレッド(config/puma.rb) workers 2 threads 5, 5 preload_app!
Go の goroutine はスレッドの薄いラッパーではない
Go の goroutine は一見軽量スレッドのように見えるが、実体は OS スレッドと 1 対 1 で対応しない。Go ランタイムは M:N スケジューラ(G-M-P モデル)を持ち、G(goroutine)・M(OS スレッド)・P(スケジューリング用の実行コンテキスト)を組み合わせて、多数の goroutine を少数の OS スレッドの上で動かす。並列に Go コードを実行できる OS スレッド数は GOMAXPROCS で決まり、既定値は論理 CPU 数(Linux では cgroup の CPU クォータも考慮される)になる。
goroutine は初期スタックがわずか 2KB で、必要になれば伸長するため、数万個作ってもメモリを圧迫しにくい。さらに goroutine がブロッキングなシステムコールに入ると、ランタイムはその goroutine が使っていた M をそのまま待たせ、P だけを空いている別の M に付け替えて他の goroutine を動かし続ける。これにより GVL のような単一の関所を持たず、CPU バウンドな処理でも GOMAXPROCS 個の OS スレッドで文字通り並列に計算が進む。
Rails アプリで「レスポンスが遅いから RAILS_MAX_THREADS を増やそう」という対処が効くのは、ボトルネックが DB クエリや外部 API 呼び出しのような I/O 待ちのときだけだ。画像のリサイズや JSON の大量シリアライズのような CPU バウンドな処理がボトルネックなら、スレッドをいくら増やしても改善せず、WEB_CONCURRENCY(Puma の worker 数)を増やすかバックグラウンドジョブに逃がす必要がある。Kubernetes 上で CPU の limits を絞っている場合は workers を増やしてもコンテナに割り当てられた CPU 時間そのものが増えるわけではない点にも注意が要る。
同じ CPU バウンドな処理を「スレッド 2 本」と「プロセス 2 本」で並行実行し、所要時間を比較する。GVL がある限りスレッド版は速くならないはずだ。
# スレッド版(GVLのため2本でもほぼ1本分の時間がかかる)
ruby -e '
def burn
x = 0
50_000_000.times { x += 1 }
end
start = Time.now
t1 = Thread.new { burn }
t2 = Thread.new { burn }
t1.join; t2.join
puts "2 threads: #{(Time.now - start).round(2)}s"
'
# プロセス版(GVLを共有しないため複数コアで並列に進む)
ruby -e '
def burn
x = 0
50_000_000.times { x += 1 }
end
start = Time.now
pid1 = fork { burn }
pid2 = fork { burn }
Process.wait(pid1)
Process.wait(pid2)
puts "2 processes: #{(Time.now - start).round(2)}s"
'
マルチコアの Mac であれば、プロセス版はスレッド版よりはっきり短い時間で終わるはずだ(環境や Ruby のバージョンによって差の大きさは変わる)。スレッド版の所要時間が burn を 1 回だけ実行した場合とほぼ変わらないなら、GVL によって 2 つのスレッドが順番に実行されている証拠になる。
- Ruby(MRI) はマルチスレッドに対応していない — スレッド自体は OS スレッドとして動き、I/O 待ちの間は複数スレッドが実質的に並行して進む。対応していないのは GVL による CPU バウンドな処理の「真の並列実行」だけだ。
- goroutine は Go 版の軽量スレッドで、1 つの goroutine が 1 つの OS スレッドを使う — 実際は M:N スケジューラが多数の goroutine を少数の OS スレッドに動的に割り当てており、goroutine と OS スレッドは 1 対 1 に対応しない。
- Puma の workers を増やせば増やすほど得 — workers はプロセスなので基本的にメモリ使用量が線形に増える。copy-on-write で緩和されるが、リクエスト処理で共有ページが書き換わるほど効果は薄れ、コンテナのメモリ上限を超えて OOM Kill される原因にもなる。
- プロセス
- OS が独立したメモリ空間を割り当てる実行単位。他のプロセスとメモリを共有しない。
- スレッド
- 1 つのプロセス内で動く実行の流れ。同じプロセスのメモリ空間を他のスレッドと共有する。
- コンテキストスイッチ
- CPU が実行対象を切り替える際にレジスタやスタックの状態を退避・復元する処理。
- GVL(Global VM Lock)
- MRI で Ruby コードを実行できるスレッドを常に 1 つに制限するロック。I/O 待ち中は解放される。
- M:N スケジューラ
- 多数のタスク(N)を少数の実行資源(M)に動的に割り当てる方式。Go の goroutine はこれで OS スレッドに割り当てられる。
- copy-on-write
- fork 直後は親子プロセスでメモリページを共有し、どちらかが書き換えた時点だけそのページを複製する OS の仕組み。
- GVL in Ruby and the impact of GVL in scaling Rails applications - BigBinary Blog — GVL のタイムスライス(100ms)と `RUBY_THREAD_TIMESLICE` の挙動を具体的に解説している。
- Puma README — workers/threads のデフォルト値と preload_app! による copy-on-write の仕組みの一次情報。
- runtime package - GOMAXPROCS - Go公式ドキュメント — GOMAXPROCS のデフォルト決定ロジック(論理CPU数・cgroup CPUクォータ考慮)を定義した一次資料。