はじめに
ThreadPool の性能を測るとき、「キューに入れてから何 ms で終わったか」だけでは、何が遅かったのか分かりません。JIT(Just-In-Time コンパイル)の初回コスト、キュー待ち、ワーカースレッドの供給、処理本体の時間がひとつの値に混ざるからです。
そこで .NET 10 の小さな計測ハーネスを使い、コールド / ウォーム、Minimum Worker Threads(ワーカースレッドの最小数)、ワークロード、スケジューラーを別条件として実行しました。さらに、定常状態の API(Application Programming Interface)往復を BenchmarkDotNet で測りました。
先に結論を書くと、ウォームアップを 1 つのスイッチで扱うだけでは、JIT と ThreadPool の成長を完全には分離できません。どのコード経路をウォームアップで実行したか、スレッド数をどう要求したか、測定境界をどこに置いたかを記録する必要があります。
この記事で分かること
- キュー待ち時間、処理時間、スレッド占有時間を分ける方法
- コールド / ウォームと Minimum Worker Threads を別条件にする理由
- Blocking、CPU-bound、Async の測定値を同じ意味として扱えない理由
- この環境で実際に得られた結果と、その結果から言えないこと
- ThreadPool 計測と BenchmarkDotNet の測定境界
実行環境
今回の計測は次の環境で行いました。
| 項目 | 値 |
|---|---|
| OS(Operating System) | Windows 11 x64 |
| CPU | Intel Core Ultra 7 265KF |
| 論理プロセッサー数 | 20 |
| .NET SDK / Runtime | 10.0.402 / 10.0.13 |
| 構成 | Release、x64 |
| BenchmarkDotNet | 0.15.8 |
コンテナー / 仮想マシン(VM)の有無や CPU / メモリ制限は確実に判定できなかったため、環境 JSON では unknown と記録しています。条件ごとの ThreadPool 設定、GC(Garbage Collection、ガベージコレクション)設定、プロセス ID(PID)、試行番号もそれぞれ保存しました。
なぜ 1 つの経過時間では足りないのか
ThreadPool は、ワーク項目をキューに追加してから実行し、完了させます。処理の経過時間を少なくとも次の区間に分けると、遅延の位置を読み取りやすくなります。
| 区間 | 計算 | 意味 |
|---|---|---|
| Queue wait | StartedTimestamp - EnqueuedTimestamp |
投入から処理開始まで |
| Execution duration | EndedTimestamp - StartedTimestamp |
開始から完了までの実時間(wall-clock time) |
| Thread occupied | 同期処理区間の合計 | ワーカースレッドを占有していた時間の推定 |
Async(非同期)ワークロードでは、ExecutionDurationMilliseconds に Task.Delay の待機が含まれます。その間はスレッドを占有しません。そこでハーネスは await 前後の同期区間を ThreadOccupiedMilliseconds として別に記録します。ただし、Delay が完了可能になった正確な時刻は取得できません。継続開始時刻は観測できますが、Async の実行時間を純粋な CPU 時間として読むことはできません。
ThreadPool の CompletedWorkItemCount も、対象のワーク項目数と 1 対 1 には対応しません。ランタイムや計測ハーネスの作業もカウンターに含まれ得るため、項目数の検算には ID と成功件数を使います。
条件をどう分けたか
新しいプロセスで条件を実行する
SetMinThreads は ThreadPool の最小スレッド数を設定する API です。呼び出し時点でその数のスレッドを即時に作る API ではありません。設定値、戻り値、直後に GetMinThreads で取得した値を記録し、ワーク要求後の ThreadPool.ThreadCount も観測しました。公式ドキュメント
各条件は独立した子プロセスで実行します。同じプロセス内で最小スレッド数を順に変えると、前の条件で作られたスレッド、JIT 済みコード、GC、キューの状態が次の条件に残るためです。比較した設定は default、2、cpu(Environment.ProcessorCount)、32、128 です。I/O Completion Threads は変更していません。
Cold と Warm は別条件にする
Cold はユーザー処理経路の事前実行なしで計測します。プロセス全体が完全に JIT 前という意味ではありません。ランタイムやハーネス自身の初期化は測定に含まれ得ます。
Warm は同じスケジューラー / ワークロード経路を新しいプロセス内で指定回数実行してから測ります。今回の条件では 8 回または 16 回を使いました。負の ID を付けたウォームアップ項目は集計から除きます。
重要なのは、この Warm は JIT だけを温める操作ではないことです。同じ ThreadPool 経路を実行するため、コードの JIT に加えて ThreadPool のスレッド供給やキューの状態も変わります。したがって、今回の Cold / Warm 比較から「差は JIT だけが原因」と結論づけることはできません。Tiered Compilation(段階的コンパイル)や Dynamic PGO(動的プロファイル誘導最適化)が最終状態に達した保証もありません。
JIT とスレッド供給をさらに独立させたい場合は、コード経路の準備と ThreadPool の負荷生成を別の実験要因にします。ウォームアップがどのスレッド上でどの API を通ったかも記録し、単に「Warm」と呼んで済ませないことが大切です。
ワークロードとスケジューラーを分ける
ハーネスには次の処理を実装しました。
| ワークロード | 計測する処理 | 解釈上の注意 |
|---|---|---|
| Blocking |
Thread.Sleep による待機 |
待機中もワーカースレッドを占有します。キャンセル確認のため最大 50 ms ごとに分割します |
| CPU-bound(CPU 処理が主体) | 期限まで checksum を更新するループ | CPU を使う処理です。指定時間を超える分はループとスケジューリングの分解能によります |
| Async |
await Task.Delay の後に継続を記録 |
待機中はスレッドを占有しません。wall-clock 時間と占有時間を分けます |
スケジューラーは専用 Thread、QueueUserWorkItem、UnsafeQueueUserWorkItem、Task.Run です。Thread と Async の組み合わせは、await 後の継続が専用スレッド上で継続する条件ではないためスキップします。QueueUserWorkItem、UnsafeQueueUserWorkItem、Task.Run、Task.Delay
実測 1:最小スレッド数と Blocking のキュー待ち
256 項目をそれぞれ約 10 ms の Blocking ワークロードで実行し、Cold / Warm と最小ワーカースレッド設定を組み合わせました。各条件を独立した子プロセスで 1 回実行し、診断カウンターは無効にしました。P50 は中央値、P99 は 99 パーセンタイルです。
| 最小ワーカー設定 | Cold Queue wait P50 / P99 | Cold 最大 ThreadPool 数 | Warm Queue wait P50 / P99 | Warm 最大 ThreadPool 数 |
|---|---|---|---|---|
| default | 70.163 / 133.174 ms | 21 | 65.363 / 129.397 ms | 21 |
| 2 | 67.552 / 132.887 ms | 20 | 63.512 / 126.833 ms | 21 |
cpu(20) |
68.378 / 130.448 ms | 21 | 63.770 / 128.248 ms | 21 |
| 32 | 41.429 / 78.852 ms | 32 | 38.697 / 77.146 ms | 33 |
| 128 | 13.527 / 23.576 ms | 128 | 14.327 / 24.805 ms | 128 |
最大 ThreadPool 数は、各項目の開始時および境界スナップショットで観測した ThreadPool.ThreadCount の最大値です。ThreadPool のスレッド数を指定値に固定した意味ではありません。
この 1 回の実験では、32 と 128 の最小値を要求した条件ほど Blocking 中のキュー待ちが短くなりました。その一方で、最大 ThreadPool 数も増えています。これは「最小値は大きいほどよい」という結論ではありません。少数プロセスの Blocking 条件において、スレッド供給の設定がキュー待ちと同時に観測スレッド数を変えた例です。
また、Warm の中央値が Cold より短い設定が多い一方、128 では Warm のほうがわずかに長くなりました。各条件がプロセス 1 回なので、Warm の効果や JIT の寄与を推定するには不十分です。256 個のワーク項目を独立サンプル 256 個とみなして、プロセス間のばらつきまで推定することもできません。
生データ、環境 JSON、summary は計測時に生成しましたが、計測用資源の整理にあたりリポジトリから削除しています。本記事では、計測ログから転記した値と測定条件を示します。
実測 2:ワークロードで時間の意味が変わる
Warm、default、処理時間 5 ms、16 項目 / プロセス、独立プロセス 2 回の例です。次の表は 32 項目をまとめた P50 であり、プロセス間の信頼区間ではありません。
| 条件 | Queue wait P50 | Execution duration P50 | Thread occupied P50 |
|---|---|---|---|
| Blocking / ThreadPool | 0.831 ms | 5.493 ms | 5.493 ms |
| CPU-bound / ThreadPool | 1.888 ms | 5.003 ms | 5.003 ms |
| Async / Task | 0.026 ms | 5.071 ms | 0.0005 ms |
Async / Task の実行時間は約 5 ms ですが、占有時間の推定値は約 0.0005 ms でした。ここでは「処理完了までの時間」と「スレッドが仕事をしていた時間」は別物だと読み取れます。
この数値は特定の負荷・マシン・試行回数に限った観測です。スケジューラーの一般的な順位を示すものではありません。計測では専用 Thread、通常の ThreadPool、Unsafe ThreadPool、Task を比較しました。Async と専用 Thread の組み合わせは、意味が揃わないためスキップしました。
実測 3:EventCounters の有無
System.Runtime の EventCounters は EventListener で 1 秒間隔に有効化しました。診断あり / なしを同じワークロードの別条件にして、各 2 プロセスで比較しています。
| 診断 | Queue wait P50 | Execution duration P50 |
|---|---|---|
| Off | 1.082 ms | 251.346 ms |
| On | 1.081 ms | 251.013 ms |
この小規模な実験では値が近く見えますが、差がないと証明したわけではありません。診断あり側の JSONL には各試行 27 件の EventCounter が記録されました。カウンターは定期サンプルなので、短い実験では 0 件になる場合もあります。レイテンシの主要計器として扱わず、観測のための負荷も独立した条件として記録します。EventCounters の概要も参照してください。
dotnet-counters と dotnet-trace は実行環境で検出できなかったため、外部 EventPipe 収集は実施していません。
BenchmarkDotNet で定常往復を測る
独立したベンチマークでは、QueueUserWorkItem、UnsafeQueueUserWorkItem、Task.Run それぞれについて、投入から完了通知までを測りました。各呼び出しには ManualResetEventSlim の Reset / Signal / Wait を含めています。ThreadPool の Cold 起動や最小値変更後の過渡応答を測るものではありません。
BenchmarkDotNet 0.15.8 にウォームアップと測定の反復数を自動選択させ、メモリ、スレッド、逆アセンブリーを記録する診断器(diagnoser)を有効にしました。結果は次のとおりです。
| API | Mean(平均) | 99.9% 信頼区間の半幅 | 標準偏差(StdDev) | 1 回あたりの割り当て | 測定数 N |
|---|---|---|---|---|---|
QueueUserWorkItem |
912.7 ns | 18.13 ns | 51.72 ns | 40 B | 94 |
UnsafeQueueUserWorkItem |
893.6 ns | 17.83 ns | 41.68 ns | 32 B | 65 |
Task.Run |
935.3 ns | 18.72 ns | 44.12 ns | 144 B | 66 |
このベンチマークでは Unsafe の平均が約 2% 小さく、割り当ても小さく見えました。しかし、測定数(N)はそれぞれ自動調整され、完了通知と待機を含みます。この結果だけから API の一般的な速さを順位づけしません。特に Task.Run の 144 B は、この完了方法を含むベンチマークの 1 operation あたりの割り当てです。
通常 API の呼び出し側には AsyncLocal の値を設定していますが、コールバック自体はその値を読みません。したがって、この値は今回の完了通知を含む呼び出しパターンの結果であり、実アプリケーションにおける ExecutionContext の利用全体を代表する値ではありません。
Threading diagnoser では 3 メソッドすべてが 1 operation あたり 1 件の ThreadPool work item を記録しました。ロック競合は Unsafe と Task.Run で 0 でした(QueueUserWorkItem は未報告です)。Disassembly diagnoser はネイティブコードを出力しました。BenchmarkDotNet は診断器がある場合に別実行することがあり、処理時間も 3 メソッドで約 4 分 20 秒でした。以下の数値は計測時のログから転記しています。
BenchmarkDotNet の Job は実行環境・反復・ウォームアップなどを定義し、Diagnoser はメモリなどの補助情報を収集します。診断器付きの実行が通常の計測と分かれる場合があること、標準の Job が測定を調整することは公式 Job ドキュメントとDiagnoser ドキュメントに記載されています。
再計測する場合
今回の計測では条件ごとに独立した子プロセスを使い、ワークロードや最小ワーカースレッド数、診断設定を別条件として扱いました。再計測する際は、同じランタイム、Release 構成、プロセス分離、試行回数、測定境界を揃えてください。
計測に使ったハーネス、ベンチマーク、テスト、出力データ、詳細な設計・再現資料はこのリポジトリには含めていません。そのため、ここに示した結果は記事内の実験条件を参考にした独立実装で再検証する必要があります。
注意点
- Queue wait の P50 / P99 は各プロセス内の項目分布です。独立プロセス間の信頼区間とは異なります。
- 条件ごとのプロセス数が 1〜2 回の比較は、傾向の確認にとどめます。
-
ThreadPool.ThreadCountは観測した ThreadPool スレッド数です。OS 全体のスレッド数や同時 CPU 実行数ではありません。 -
CompletedWorkItemCountの差分は対象項目数の代用になりません。 - Warm は同じ実行経路を事前実行するため、JIT と ThreadPool の状態変化を完全には分離しません。
- 専用 Thread はスレッドを作るコストを含みます。ThreadPool API と同じ種類のキュー待ちとは限りません。
- EventCounters と BenchmarkDotNet diagnoser は測定対象とは別の観測負荷を伴います。
おわりに
ThreadPool の測定値を解釈するには、処理の始点と終点を分け、コールド / ウォーム、最小スレッド数、ワークロード、スケジューラー、診断設定を独立した条件として記録する必要があります。
今回の Blocking 実験では最小ワーカー数を大きくした条件でキュー待ちが短くなりましたが、ThreadPool の観測スレッド数も増えました。Async では完了までの時間の多くがスレッド占有時間ではありませんでした。どちらもこの環境と負荷の一例であり、アプリケーションの設定を決めるには実際のワークロードで追加検証が必要です。