`env["puma.mark_as_io_bound"]` によるIO待ちスレッドのプール分離機能を実装
Pumaのスレッドプールに「IOバウンドスレッド」という概念を導入し、ストリーミングやプロキシのような長時間IO待ちが発生するリクエストを通常の max_threads 制限から切り離して管理できるようになりました。
背景
トランザクション処理が中心のアプリケーションでも、ストリーミングやプロキシのエンドポイントが混在するケースは珍しくありません。#3777 では、こうしたCPU処理がほぼ不要なリクエストが通常のスレッドと同じ max_threads カウントを消費し続けることで、実質的なスループットが低下する問題が提起されました。
Railsの ActionController::Live はバックグラウンドスレッドへリクエスト処理を移譲することで同様の問題に対応しようとしましたが、スレッドローカル状態との競合など多くの問題を抱えています。本PRはこの問題をWebサーバー層で解決するアプローチを採用しています。Issue内でも「サーバーレベルで解くべき問題」と明示されており、UnicornやFalconのような異なる並行モデルを持つサーバーでは不要・または自明な機能であるため、Puma固有のAPIとして設計されています。
アプリケーションコードからPumaに対してシンプルなシグナルを送れるAPIとして、env["puma.mark_as_io_bound"] というCallableが実装されました。
技術的な変更
ProcessorThreadクラスの導入
変更の核心は Puma::ThreadPool::ProcessorThread という新しいラッパークラスの導入です。従来はスレッドプール内の各ワーカーが Thread オブジェクトとして直接管理されていましたが、本PRでは Thread を内包する ProcessorThread がスレッドプールの管理単位になります。
class ProcessorThread
attr_accessor :thread
attr_writer :marked_as_io_thread
def initialize(pool)
@pool = pool
@thread = nil
@marked_as_io_thread = false
end
def mark_as_io_thread!
unless @marked_as_io_thread
@marked_as_io_thread = true
# Immediately signal the pool that it can spawn a new thread
# if there's some work in the queue.
@pool.spawn_thread_if_needed
end
end
def marked_as_io_thread?
@marked_as_io_thread
end
# ...
end
mark_as_io_thread! が呼ばれると、そのスレッドは即座にIOバウンドとしてマークされ、pool.spawn_thread_if_needed が呼ばれることでキューに積まれたリクエストを処理する新たなスレッドの起動がトリガーされます。これにより、長期IO待ちに入ったスレッドが「空き」として扱われ、CPUを必要とするリクエストが詰まらないようになります。
Rack envへのCallable注入
handle_request メソッドのシグネチャが変更され、ProcessorThread を第1引数として受け取るようになりました。これにより、リクエスト処理の開始時に env["puma.mark_as_io_bound"] としてCallableが注入されます。
変更前:
def handle_request(client, requests)
env = client.env
# ...
end
変更後:
def handle_request(processor, client, requests)
env = client.env
# ...
env["puma.mark_as_io_bound"] = -> { processor.mark_as_io_thread! }
# ...
end
アプリケーション側からは以下のように呼び出せます:
env["puma.mark_as_io_bound"].call
このCallableはリクエストスコープのlambdaであり、対応する ProcessorThread インスタンスをクロージャでキャプチャしているため、アプリケーションコードがPumaの内部オブジェクトを直接参照する必要はありません。
max_io_threads設定の追加
max_io_threads という新しい設定キーが追加され、デフォルト値は 0 です。設定ファイルでは以下のように記述します:
threads 5
max_io_threads 5
スレッド数のカウント方法は次のルールに従います:
-
max_io_threadsの上限内に収まるIOバウンドスレッドはmax_threadsのカウントに含まれない - IOバウンドスレッドが
max_io_threadsを超えた分は通常スレッドとして扱われる - リクエスト終了後、プールはそのスレッドを通常スレッドに戻すか終了させるかを判断する
例として max_threads: 5, max_io_threads: 10 の場合、通常スレッド3本・IOバウンドスレッド12本の状態では新規スレッドは起動しませんが、次にIOバウンドスレッドが完了すると通常スレッドが4本に戻ります。
シグネチャ変更の波及
handle_request および process_client のシグネチャ変更は lib/puma/server.rb にも波及しており、FiberPerRequest モジュールと通常の Server クラス双方で第1引数に processor を受け取るよう更新されています。スレッドプールのブロック呼び出しも |client| から |processor, client| に変更され、テストコードでも @workers への参照が @processors に置き換えられています。
設計判断
「マーク」方式 が採用されており、スレッドをプールから完全に切り離すのではなく、状態を付与してプールが適切に管理し続ける設計になっています。
Issue #3777 では env["puma.release_thread"].call という命名も候補として挙がっていましたが、最終的に mark_as_io_bound という名称が採用されました。「スレッドを手放す」というセマンティクスよりも「このスレッドの特性を宣言する」という意図を名前で表現しており、スレッドの所有権がアプリケーションに移らない設計方針と一致しています。
IOバウンドとしてマークされたスレッドはリクエスト終了まで状態を保持し、終了後にプールが通常スレッドとして再利用するか終了させるかを判断します。この設計により、スレッドのライフサイクル管理はPumaが一元的に担い続け、アプリケーションコードはシグナルを送るだけで済みます。max_io_threads: 0(デフォルト)の場合は全IOバウンドスレッドが通常スレッドとして扱われるため、既存の動作に影響はありません。
まとめ
env["puma.mark_as_io_bound"] の導入により、ストリーミングやプロキシのような長期IO待ちリクエストを max_threads の制限から分離して管理できるようになりました。ProcessorThread というラッパーでスレッドの状態を抽象化し、アプリケーション側には単純なCallableだけを公開する設計は、Pumaのスレッドプール管理の一元性を保ちながら柔軟なスループット最適化を実現しています。