`env["puma.mark_as_io_bound"]` によるIO待ちスレッドのプール分離機能を実装

puma/puma

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のスレッドプール管理の一元性を保ちながら柔軟なスループット最適化を実現しています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
3ee24d8f

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文(総論)→背景・技術詳細・設計判断(各論)→まとめ(結論)の3部構成が明確に適用されています。各セクションの役割が明確で、理想的な記事構成です。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライト(```ruby:ファイルパス)およびGitHubのIssue/PRリンク記法([#番号](URL))がすべて正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

Pumaのスレッドプール管理という専門的なトピックを扱い、用語選択や説明の粒度が専門知識を持つエンジニア向けに適切に調整されています。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクション、各パラグラフが総論→各論の構成になっており、すべての段落がトピックセンテンスで始まっています。1段落1トピックが守られ、段落長も適切です。非常に高い可読性を実現しています。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内のコード引用は、提供されたDiffの内容と完全に一致しています。ProcessorThreadクラス、handle_requestのシグネチャ変更、設定例など、すべてのコードが正確に反映されています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「IOバウンド」「Callable」「クロージャ」「シグネチャ」など、技術用語が正確かつ文脈に即して適切に使用されています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

スレッド数のカウントルールや、`mark_as_io_thread!`が呼ばれた際の挙動など、技術的な説明がPR DescriptionおよびDiffの内容と一致しており、論理的に正確です。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内のすべての主張は、PRのTitle, Description, Diff、または参照されているIssue(#3777)の内容に裏付けられています。根拠のない推測や憶測は見られません。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#3816)やIssue番号(#3777)などの固有名詞はすべて正確に記載されています。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトルはPRのタイトル「Implement `env["puma.mark_as_io_bound"]`」を正確に反映し、その機能の目的を補足しており、PRの内容と完全に一致しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

記事内で言及されているRailsの`ActionController::Live`や他のWebサーバーとの比較は、PRが修正対象としているIssue(#3777)の文脈を説明するためのものであり、PRに紐づく情報源に基づいています。捏造された外部知識ではありません。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

記事内での時間表現に誤りはなく、機能が実装されたという事実を正確に伝えています。時間的な歪曲は見られません。