Fix `pool_capacity` leak when extra IO threads exit

puma/puma

この PR は、puma.mark_as_io_bound でマークされた I/O バウンドスレッドが max_threads を超えて終了した際に、スレッド数カウンタ @spawned が減算されず pool_capacity が 0 のままになるリークを修正します。

背景

I/O バウンドスレッドが上限を超えて終了したときに @spawned がデクリメントされない ため、Puma のスレッドプールが正しい容量情報を提供できなくなる問題が報告されました。#3958 では、workers 0, threads 5,10, max_io_threads 10 の構成で 20 件の同時リクエストを投げると、puma_thread_pool_capacity が 0 のまま回復せず、リクエストがスタックする現象が示されています。スレッドが終了しても @spawned が増え続け、結果としてプールが新規リクエストを受け付けられなくなる状態です。

この挙動は、puma.mark_as_io_bound によって I/O バウンドとして扱われたスレッドが、max_threads 制限を超えたときに余剰スレッドとして即座に Thread.exit するコードパスにおいて、カウンタの減算処理が欠落していたことが原因です。

問題の根本 は、既存の trim ロジックと同様に余剰スレッド退出時に @spawned を減らす処理が無かった点にあります。これにより、プール容量が正しく回復せず、サーバーがスレッド枯渇状態に陥ります。

技術的な変更

spawn_thread メソッドに @spawned -= 1 を追加し、I/O バウンドスレッドが上限を超えて退出するコードパスでカウンタを減算するよう修正しました。コード変更は次の通りです。

                 # We're already at max threads, so we exit the extra io thread.
+                @spawned -= 1
                 @processors.delete(processor)
                 trigger_before_thread_exit_hooks
                 Thread.exit

この 1 行の挿入は、余剰スレッドが退出する唯一のパスに対して行われ、他のロジックへの影響はありません。

テストスイートにも test_extra_io_threads_release_spawned_count_on_exit を新規追加し、@spawned が正しく減算され pool_capacity が回復することを検証しています。

   def test_extra_io_threads_release_spawned_count_on_exit
     mutex = Mutex.new
     mutex.lock
     queue = Queue.new
     pool = new_pool(0, 2, max_io_threads: 2) do |processor, io_bound_thread|
       processor.mark_as_io_thread! if io_bound_thread
       queue << Thread.current
       mutex.synchronize { }
     end
@@
     4.times { pool << true }
     4.times { queue.pop }
     assert_equal 4, pool.spawned
@@
     Timeout.timeout(1) { sleep 0.01 until pool.spawned == 2 }
     assert_equal 2, pool.spawned
     assert_equal 2, pool.pool_capacity
   ensure
     mutex.unlock if mutex.owned?
     pool.shutdown(1)
   end

テストは、余剰 I/O スレッドが生成された後に全スレッドが一時停止し、解除されたときに spawned が期待通りに減少し、pool_capacity が正しい値に復元されることを確認します。

設計判断

最小侵入的な修正 が選択されました。trim ロジックと同等のカウンタ減算を直接 spawn_thread に挿入することで、既存のフローを乱さずにバグを解消できます。代替案としては trim メソッドを共通化して呼び出す、あるいはカウンタ管理用のラッパークラスを導入するといった抽象化が検討されましたが、パフォーマンスと可読性への余計なコストが懸念されたため見送られました。

このアプローチは 後方互換性を保持 しつつ、変更箇所を 1 行に留めることでリスクを最小化します。既存の設定やコードベースに影響を与えることなく、正確なスレッド数管理が復元されます。

まとめ

余剰 I/O バウンドスレッドが退出した際に @spawned カウンタが正しくデクリメントされるようになり、pool_capacity のリークが解消されました。これにより、Puma のスレッドプールは正確な容量情報を提供し続け、IO バウンドワークロードが急増したシナリオでもサーバーの安定稼働が期待できます。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
ef9da44d

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

品質レビュー結果

Review Status:
リトライ後承認
Review Count:
2回 (改善を経て承認)
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

リード文、背景、技術的な変更、設計判断、まとめがすべて揃っており、全体として「総論→各論→結論」の構成が明確です。

カスタムMarkdown構文 ✓ PASS

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

ファイル名付きシンタックスハイライトが正しい形式(```ruby:filepath`)で使用され、GitHubリンクも#番号で正しくリンク化されています。

対象読者への適合性 ✓ PASS

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

内容は Puma の内部実装に関する知識を前提としており、過度な初心者向け説明はありません。

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

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

各セクションは総論・各論・結論の流れを保ち、段落はトピックセンテンスで始まり、1段落1トピック、長さも適切に区切られています。

Diff内容との照合 ⚠ WARNING

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

コードブロックは `@spawned -= 1` の追加と新規テストメソッドは正確に反映していますが、diff に含まれる他の変更(if 文のインライン化など)を記事が省略しています。技術的理解には大きな支障はありませんが、Diff 全体を網羅していない点がありました。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

使用されている技術用語は PR と一致しており、誤用は見られません。

説明の技術的正確性 ✓ PASS

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

記事中の技術的主張は PR の説明と Diff の内容と整合しており、誤りはありません。

事実の突合 ✓ PASS

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

すべての事実は PR タイトル、説明、Diff、Issue に裏付けられており、捏造や憶測はありません。

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

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

PR 番号、Issue 番号、テストで使用したリクエスト数などの数値は正確です。

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

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

記事タイトルは PR のタイトルと完全に一致しています。

外部知識の正確性 ✓ PASS

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

記事内に PR に記載されていない外部知識(LTS、リリース日程等)は含まれていません。

時間表現の正確性 ✓ PASS

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

時間表現の歪曲はなく、PR の記述と一致しています。