ThreadPoolExecutorがコアスレッドとバウンスされたキューを飽和させると、CallerRunsPolicyは拒否されたタスクを提出者スレッドに直ちに実行させるために委任します。もしその提出者スレッドが提出したばかりのタスクの結果を同期的に待つために**Future.get()**を呼び出していた場合、そして提出されたタスクのロジックが内部で追加のタスクを同じエグゼキューターに提出し、これらのタスクの完了を待つことになると、循環待機が発生します。
提出者スレッドはそのタスクの完了まで**get()**から戻ることができませんが、タスクはさらにキューに待機しているサブタスクの完了を待っているため、完了することができません。すべてのワーカースレッドは他のタスクに占有されているため、キューを排出するためのスレッドは利用できません。これは効果的に提出者をデッドロックさせてしまいます。なぜなら、そのスレッドはサブタスクを実行できる唯一のスレッドであり(ポリシーを介して)、同時にそれらのサブタスクの完了を待つためにブロックされているからです。
私たちは、ThreadPoolExecutorがCallerRunsPolicyを使ってPDFレンダリングタスクを処理する分散文書処理パイプラインでこれに遭遇しました。各文書タスクはメタデータを解析し、画像抽出のためのサブタスクを生成し、次にそれらのサブタスクに対して**Future.get()**を即座に呼び出して最終結果を組み立てました。
高負荷時にキューが飽和し、CallerRunsPolicyがウェブリクエストハンドラースレッド内で文書タスクを実行することを引き起こしました。そのスレッドは画像抽出タスクを提出し、**get()**でブロックされましたが、全てのワーカースレッドは他の文書で忙しかったのです。新しいサブタスクはキューの末尾で、割り当てられることなく待機していました。
ハンドラースレッドは、サブタスクを待っているため実行できず、サブタスクはすべてのスレッドが利用できないため実行できなかった。このことで自己強化的なデッドロックが発生し、サービスが手動介入でJVMを再起動するまで機能しなくなりました。
以下のコードは、その危険なパターンを示しています:
ExecutorService executor = new ThreadPoolExecutor( 2, 2, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(2), new ThreadPoolExecutor.CallerRunsPolicy() ); // メインリクエストハンドラースレッドから提出されました Future<?> parent = executor.submit(() -> { // プールが飽和すると、これはハンドラスレッドで実行されます(CallerRunsPolicy) Future<?> child = executor.submit(() -> "extracted image"); // ハンドラスレッドはここでブロックし、子を待っています // しかし子はキューにいて、ワーカースレッドは自由ではありません // ハンドラーはブロックされているため子を実行できません return child.get(); }); parent.get(); // デッドロック: ハンドラスレッドは永遠に待機
私たちは4つの異なるアーキテクチャソリューションを評価しました。最初のアプローチはCallerRunsPolicyをAbortPolicyに置き換え、クライアント内に指数バックオフリトライループを実装しました。これにより呼び出しスレッドの可用性が維持されましたが、一時的な失敗や複雑なリトライロジックが追加され、冪等性の保証が複雑になりました。
2つ目のソリューションは、飽和を完全に防ぐために、無制限のLinkedBlockingQueueに拡張しました。これにより拒否を排除しましたが、トラフィックの急増時にOutOfMemoryErrorを引き起こすリスクがあり、バックプレッシャー信号を隠すことで、明示的な失敗よりも過度の遅延につながりました。
3つ目のオプションは、バウンスされたキューを維持しましたが、maximumPoolSizeをcorePoolSizeを大きく超えて増加させ、スレッドの増殖に依存して負荷を吸収しました。これによりスループットは改善されましたが、過剰なコンテキストスイッチとメモリ消費の代償を伴い、最終的にはCPUキャッシュのスラッシングによってパフォーマンスが低下しました。
4つ目のアプローチは、ワークフローをExecutorCompletionServiceと非同期コールバックを使用して再構築することでした。これにより、元の文書タスクはサブタスクの提出の際にワーカースレッドを解放し、CompletionServiceが完了を示すまで再開しませんでした。
私たちは、提出と完了を根本的に切り離したため、4番目のソリューションを選択しました。これにより、バウンスされたキューのバックプレッシャーが保持され、循環待機条件が排除され、ワーカースレッドがサブタスクを処理するために再利用できるようになり、元のタスクは軽量の条件変数で通知を待つことができるようになりました。
この変更によりデッドロックが解消され、平均レイテンシが40パーセント削減され、ピーク負荷下でメモリ使用量が安定して維持され、バウンスされたキューの失敗セマンティクスを犠牲にすることなく実現されました。
なぜThreadPoolExecutorは、無制限のBlockingQueueで構成されている場合、corePoolSizeを超えてスレッドをインスタンス化することを拒否するのですか?
エグゼキュータは、execute()が待機中のワーカースレッドにタスクを即座に手渡せないか、キューに挿入できない場合にのみ新しいスレッドを作成しようとします。無制限のキューのoffer()メソッドは常にfalseを返さないため、エグゼキュータは飽和を認識せず、その結果、コアカウントを超えてスレッドを割り当てることはありません。この設計は、リソース管理のためにスレッドの作成よりもキューイングが好ましいと仮定していますが、待機中の作業があるにもかかわらずプールが過小評価されているという盲点を生み出します。候補者はしばしば、maximumPoolSizeがキューの容量に関係なくハードシーリングとして機能することを誤って仮定し、キューのバウンスがスレッドの拡張のゲートキーパーとして機能することを認識しません。
CallerRunsPolicyは、単なる拒否ハンドラではなく、どのように暗黙のフロークontrolメカニズムとして機能しますか?**
タスクを提出者スレッドで実行することにより、ポリシーはそのスレッドに対して提出率を一時停止させて作業を行わせ、自然に流入の流れをプールの処理能力に合わせて調整します。これはバックプレッシャーが元の生産者にまで伝播し、明示的なレート制限コードなしにスピードを落とすようになります。多くの候補者は、このポリシーをドロップされたタスクのためのフェイルセーフとしてのみ捉え、そのリソース枯渇を防ぐために意図的に生産者をブロックすることを見落とします。この意味論上の区別を理解することは、レイテンシが負荷の急増による完全な拒絶よりも好ましいシステムを設計するために重要です。
shutdown()とCallerRunsPolicyの間の微妙な相互作用は、エグゼキュータの終了時に優雅な劣化を防ぎます。**
一度shutdown()が呼び出されると、エグゼキュータは新しい提出をRejectedExecutionExceptionで拒否する状態に遷移し、設定された拒否ポリシーを完全にバイパスします。候補者はしばしばCallerRunsPolicyがシャットダウン中に呼び出し元でタスクを実行し続けると仮定しますが、エグゼキュータはポリシーを参照する前にシャットダウン状態を確認します。これは、優雅なシャットダウンフェーズ中に提出されたタスクが即座に失敗し、クライアントが例外を処理しない場合、進行中の作業が失われる可能性があることを意味します。適切なシャットダウンシーケンスは、**awaitTermination()**を介してキューを排出するか、拒否されたタスクをフェイルオーバー構造にキャプチャする必要があります。ポリシーメカニズムは、シャットダウンフラグが設定されると無効になります。