Rubyの並行モデルやGVLの役割、Pumaサーバのスレッド・プロセス設計、IO/CPUバウンドの捉え方、計測手法によるボトルネック把握、Rails/Pumaデフォルト設定変更背景などを整理し、適切なチューニング方針を提示する。
Ruby(MRI/CRuby)にはGlobal VM Lock(GVL)があり、同一プロセス内のRubyコード実行を同時に一スレッドに制限する。GVLはRuby VMがC言語で実装されていることに起因し、内部のメモリ管理やオブジェクト管理、GC(ガベージコレクション)の整合性を保つために存在する。たとえば、オブジェクト割り当てや解放時のヒープ操作、マーク&スイープ型GCでのオブジェクトトラバース、メソッドキャッシュの更新、内部テーブルの操作などはスレッドセーフではなく、GVLによって同時実行を防ぐことでクラッシュやデータ破損を回避している。
C拡張(ネイティブ拡張)も多くがGVL下で動作する前提で設計されており、GVLを外すには拡張側のスレッド安全性担保が必要となるため、VM全体の整合性維持コストが非常に高い。アプリケーションレベルのスレッド安全性は開発者がMutexなどで担保する必要がある一方、GVLはVM内部の一貫性確保のための大域的なロックとして機能している。
GVL下ではCPUバウンドなRubyコードは同一プロセス内で複数スレッドが並列実行できず、一度に一スレッドのみが実行される。一方、DBアクセスや外部API呼び出しなどでI/O待ちが発生するとGVLが解放され、他スレッドが実行を継続できるため、I/Oバウンド混在ワークロードではスレッド並行が有効になる。しかしGVL争奪のオーバーヘッドやスレッド切り替え遅延、GC実行時の一時停止などが絡むと、見かけ上I/O待ちに見えても実はCPU飢餓による待ちが含まれるケースがある。
TruffleRubyやJRubyなどはGVLを持たないが、VM内部やJVMによるメモリ管理・スレッド管理方式に依存している。MRIを単純にGVLなしへ改造するのは膨大かつ困難であり、Rails利用者はマルチプロセスと適度なスレッド並行を活用する運用モデルで大抵のWebワークロードを十分扱える。
PumaはRails標準サーバとして広く使われる。マスタープロセスがfork
で複数ワーカープロセスを生成し、各プロセス内でスレッドプールを利用してリクエストを処理する。I/O待ちでGVLが解放されスレッド切り替えが活きる場面がある一方、CPUバウンド部分ではプロセス並列により並列性能を発揮する。
以下は新規Railsアプリで生成されるconfig/puma.rb
の該当部分の抜粋例。デフォルトではスレッド数が環境変数RAILS_MAX_THREADS
で設定され、ワーカー数はWEB_CONCURRENCY
で制御される。
# config/puma.rb
threads_count = ENV.fetch("RAILS_MAX_THREADS") { 3 }.to_i
threads threads_count, threads_count
workers ENV.fetch("WEB_CONCURRENCY") { 2 }
preload_app!
on_worker_boot do
ActiveRecord::Base.establish_connection if defined?(ActiveRecord)
end
forkモデル(プロセス並列)とスレッドモデル(スレッド並行)のメリット・デメリットを簡単にまとめると以下の通り。
モデル | メリット | デメリット |
---|---|---|
プロセス並列 (fork) | - GVL制限を回避し、CPUバウンド処理で真の並列性能を発揮 - メモリ空間が独立し、クラッシュ影響を隔離 |
- メモリ使用量が増加しやすい - プロセス起動コストがかかる |
スレッド並行 | - メモリオーバーヘッドが小さく軽量 - I/O待ち中に他スレッドが動作しやすい |
- GVLの影響でCPUバウンド並列性能は制限される - スレッド競合やGVL争奪による待ちが発生し得る |
上記を踏まえ、Pumaではプロセス数(workers
)とスレッド数(threads
)の組み合わせを、アプリのワークロード特性とインフラリソースに応じて調整することが重要である。プロセス数(workers
)とスレッド数(threads
)の組み合わせを、アプリのワークロード特性とインフラリソースに応じて調整することが重要である。
GitHub Issue #50450で議論されたように、Railsの新規アプリ生成時のPumaスレッド数デフォルトは従来の5から3に変更された。IssueではDHHが自身の運用経験を基に「ワーカーあたりスレッド数1が低レイテンシに寄与する」と提案し、多数の開発者が自アプリのベンチマーク結果やAmdahlの法則を用いた考察を共有した。主な検討ポイントはレイテンシとスループットのトレードオフ、I/O/CPU特性別の最適スレッド数、Heroku Dynoやコンテナ環境などリソース制約下での安全マージン確保などであった。結果として多くのアプリで3スレッド程度がバランスの良い妥当値と合意され、Rails 7.2でデフォルトが5から3に引き下げられた。既存アプリは明示的にRAILS_MAX_THREADS
やWEB_CONCURRENCY
を設定している場合影響を受けず、新規プロジェクトではまず3スレッドで開始し、モニタリングやベンチマーク結果に応じて適宜調整することが推奨される。
RailsログやAPM計測で「Query took: XX ms」と記録される時間には、実際のDB応答時間以外にスレッドスケジューリング待ち、GVL待ち、GC実行時間などが含まれる可能性がある。これを「DB待ちが支配的」と誤認すると、スレッド数を過度に増やしてGVL争奪を悪化させ、逆にパフォーマンスを低下させる恐れがある。
Ruby 3.x以降ではGC.total_time
がナノ秒単位の累積カウンタとして提供され、特定ブロック前後の差分でGCに要した時間を把握できる。Rails 7.2以降ではActiveSupport::Notifications経由でリクエストログにGC時間が含まれるようになり、GC負荷の影響を可視化できる。
Ruby 3.2以降のGVL Instrumentation APIと専用gem(例: gvltoolsなど)を使い、I/O部分とGVL待ち時間を分離計測する手法がある。これにより、バックグラウンドでCPU負荷が高い状況下でのGVL待ち増大を具体的に把握し、誤認を減らせる。
OSレベルのスケジューラ待ち時間もI/O計測に含まれる場合があるが、個別I/Oごとの正確な計測は困難。Linuxの/proc/<pid>/schedstat
などを活用し、コンテナやホスト全体のrunqueue待ち状況を監視することで、プロセス数やスレッド数の過不足を判断する指針となる。
上記各種計測によりアプリケーションのI/O/CPU比率やGVL待ちの実態を把握し、Amdahlの法則的視点でスレッド数やプロセス数を決める。デフォルトに従うだけでなく、自身のワークロード特性(外部API呼び出し頻度、DBアクセスパターン、レンダリング負荷など)をプロファイリングして最適化することが重要である。
Sidekiqなどのジョブ処理ではI/O集約的な処理(外部API呼び出し、ファイル操作、メール送信など)が多いため、高めの並行度設定(例えばconcurrency: 10〜25程度)を採用するケースがある。しかし以下の点に注意が必要である。
Sidekiqのconcurrency設定例
sidekiq.yml
で設定可能:
:concurrency: 15
環境変数で上書く場合:
export SIDEKIQ_CONCURRENCY=15
bundle exec sidekiq
並行度を上げるとI/O待ち中に他スレッドが動作しやすくなり、理論的にはスループット向上が期待できるが、GVL争奪やGC負荷増大による副作用もある。
GVL影響の測定ケーススタディ(擬似例)
class BenchmarkJob
include Sidekiq::Job
def perform
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
# I/O模擬: sleepや小規模HTTPリクエスト
sleep 0.02
# CPU模擬: 計算負荷
(1..200_000).each { |i| i*i }
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
logger.info("Job duration: #{(duration*1000).round(1)}ms")
end
end
require 'gvltools'
class BenchmarkJob
include Sidekiq::Job
def perform
GVLTools::LocalTimer.enable
start_io = GVLTools::LocalTimer.monotonic_time
sleep 0.02
io_wait = GVLTools::LocalTimer.monotonic_time - start_io
start_cpu = GVLTools::LocalTimer.monotonic_time
(1..200_000).each { |i| i*i }
cpu_time = GVLTools::LocalTimer.monotonic_time - start_cpu
gvl_wait = GVLTools::LocalTimer.gvl_wait_time
logger.info("I/O time: #{io_wait.round(3)}s, CPU time: #{cpu_time.round(3)}s, GVL wait: #{gvl_wait.round(3)}s")
ensure
GVLTools::LocalTimer.disable
end
end
監視指標の設定
ベンチマークとチューニング手順
これにより、SidekiqなどのバックグラウンドジョブでもGVL影響を把握し、最適な並行度設定を導き出すことが可能となる。
YJIT導入によるレイテンシ改善事例は多数あり、I/O待ちが多い前提でも多くのアプリで15-30%程度の改善が見られることから、Rubyコード実行コストも無視できない。
GVL削除の議論はあるものの、MRI RubyでGVLを完全に消すのはC拡張やVM内部変更を含め膨大かつリスクの高い作業となる。TruffleRuby/JRubyやPythonのGIL削除事例から学びつつ、多くのWebワークロードではGVL下でのマルチプロセス・適度なスレッド並行で十分対応可能である。
Ruby/Railsのパフォーマンス最適化にはGVLやスレッド、プロセス、I/O/CPUバウンド特性、GC、OSスケジューラ待ちなど多面的な理解が求められる。計測に基づく実態把握と適切なチューニングを継続的に行うことで、レイテンシやスループット要件に柔軟に対応できるシステムを構築できる。
関連書籍