更新履歴(3件・最終更新 2026年08月02日)
この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。
- この記事の知識マップを見直し、本文が述べている以上に広い意味になっていた関係を修正しました。本文の主張は変えていません。
- 記事の冒頭に「この記事の知識マップ」を追加しました。本文で扱う概念どうしの関係を一枚の図で見渡せます。関係の全一覧(根拠のURL・確度・確認日つき)と主要概念の定義は知識マップ詳細ページにまとめ、機械可読データをJSON-LDとTurtleで公開しています。
- ConfigureAwait(false)をスレッドプール行きと誤解したコードと、意図どおりに書いたコードの対比例を5.3節に追加しました。あわせて冒頭の3つの疑問がどの節で回収されるかの案内と、完了ポートのワーカーループの骨格コードを追加しています。
- 初版公開
この記事を引用する(DOI(登録済みアーカイブ): 10.5281/zenodo.21739472)
以下のDOIは過去に登録されたアーカイブを指しており、現在の本文とは一致しない場合があります。現在の本文を参照するときは、このページのURLを使用してください。
小村 豪(2026)「Windows I/Oの深層(第3回) ── I/O完了ポート(IOCP)と.NETスレッドプール:async/awaitの地下室」合同会社小村ソフト. https://comcomponent.com/blog/windows-iocp-dotnet-threadpool/
- DOI(登録済みアーカイブ)
- 10.5281/zenodo.21739472
- DOI(前回登録した版)
- 10.5281/zenodo.21739473
多数の接続を少数のスレッドで扱うには、I/Oの完了をどう集めればよいのでしょうか。awaitで待っている間は誰が働き、完了後のコードはどのスレッドで再開するのでしょうか。
この回では、I/O完了ポート(IOCP)が完了通知を集める仕組みから、.NETがその通知を受け、awaitの続きを実行するまでを追います。「完了を受け取ること」と「続きの実行場所を決めること」を分けると、ConfigureAwait(false)やスレッドプール飢餓も同じ流れの中で理解できます。
前回(第2回)では、非同期I/Oの発行と、完了を受け取る4つの経路を見ました。そのうち「多数の同時I/Oを少数のスレッドで受ける」仕組みとして取り上げるのがIOCPです。
連載「Windows I/Oの深層」の第3回です。全体の構成は第1回の冒頭に置いています。
知りたいことから読む
順に読む場合は、2章で接続ごとにスレッドを用意する設計の限界を確認し、3〜4章でWin32、5章で.NETとの対応を見てください。調査中の場合は、次の案内から必要な説明へ進めます。
| 知りたいこと・困っていること | 読むところ |
|---|---|
| 数千の同時接続に、同じ数のスレッドが要らないのはなぜか | 2章: 接続数とスレッド数、3章: 完了キューとスレッド制御 |
| どのハンドルの、どのI/Oが完了したかを識別したい | 3.1節: CompletionKeyとOVERLAPPED |
| 完了取得がFALSEになった。後始末してよいのか分からない | 3.2節: 失敗したI/Oと取得失敗の区別 |
| FIFOとLIFO、コンカレンシー値の関係を知りたい | 3.3〜3.4節: 待機スレッドの選び方と実行可能数 |
| 終了通知・まとめ取り・同期完了をどう扱うか | 4章: 目的別のAPI |
| await ReadAsyncの発行から再開までを追いたい | 5.1節: awaitの一往復 |
| 「I/O待ちはスレッドを消費しない」の範囲を知りたい | 5.2節: 待つスレッドと処理するスレッド |
| ConfigureAwait(false)を付けてもUIが固まる・UIを触れない | 5.3節: 継続先とCPU処理の切り分け |
| 負荷が増えると非同期処理全体が遅くなる | 5.4節: 同期待ちとスレッドプール飢餓 |
1. まず結論
IOCPが担当すること
- IOCPは「完了通知のキュー」と「スレッド数の制御」を一体化した仕組みです。完了パケットはFIFOでキューに積まれ、ワーカースレッドが
GetQueuedCompletionStatusで取り出します(3章)。1 - スレッドはLIFOで起こされます。直前まで働いていた「温まった」スレッドが次のパケットも拾うので、キューが埋まっている限りコンテキストスイッチがほぼ起きません(3.3節)。1
- コンカレンシー値は「実行可能なスレッド数」の上限です。推奨の出発点はCPU数(指定0でプロセッサ数)。実行中のスレッドがブロックすると、待機中のスレッドを起こして穴を埋めます(3.4節)。12
APIを選ぶときの要点
- ポートは自前の通知にも使えます。
PostQueuedCompletionStatusでI/Oとは無関係のパケットを積めるので、ワーカーへの仕事依頼や終了指示も同じキューで流せます(4章)。3 - 新規のサーバー実装なら、生のIOCPよりWindowsスレッドプールAPI(
CreateThreadpoolIo)が推奨です。内部はIOCPのまま、スレッド管理を肩代わりしてくれます(4章)。1
.NETとawaitで区別すること
- .NETスレッドプールはワーカースレッドとI/O完了スレッドの2階建てで、非同期I/Oのハンドルはスレッドプール(のIOCP)に結び付けられます。
awaitのI/O待ちにスレッドは存在せず、完了後の継続だけがスレッドに乗ります(5章)。456 - 継続の行き先は「捕捉されたコンテキスト」が決めます。UIスレッドで
awaitすれば続きはUIスレッドへ、捕捉するものがなければスレッドプール(または完了させたスレッド上)で続きます。ConfigureAwait(false)は捕捉をやめる指示であって、スレッドプール行きの保証ではありません(5.3節)。6
図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全32件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle
2. スレッドで殴る設計はどこで破綻するか
まず、IOCPが解こうとした問題を確認します。素朴なサーバーは「接続1本にスレッド1本」で書けます。同期I/Oで読んで、処理して、書くという分かりやすい設計です。
問題は、接続が増えたときです。待っている接続の数に合わせてスレッドも増やす設計と、完了した仕事だけを少数のスレッドで処理する設計を、図1で比べてください。
flowchart TB
subgraph A["接続ごとにスレッド1本(同期I/O)"]
T1["スレッド1: 接続1のread待ち"]
T2["スレッド2: 接続2のread待ち"]
T3["スレッド3: 接続3のread待ち"]
TN["…接続の数だけスレッドが増える<br/>大半はI/O待ちで眠っているだけ"]
end
subgraph B["IOCPモデル(非同期I/O)"]
Q["完了キュー<br/>(全接続の完了通知が集まる)"]
W1["ワーカー1"]
W2["ワーカー2"]
WN["ワーカーはCPU数程度の少数"]
Q --> W1
Q --> W2
Q --> WN
end
図1: 左は接続数に比例してスレッドが増える。右は「起きた出来事」だけを少数のスレッドで処理する
待っているだけのスレッドにも資源が要る
1本ごとにスタック(既定で予約1MB)とカーネルオブジェクトを消費し、数が増えるほどスケジューラーとコンテキストスイッチの負担が積み上がります。数千接続=数千スレッドは、その大半が「読めるのを待って眠っているだけ」でも高くつきます。
実行可能なスレッドを増やしてもCPUは増えない
同時に走れるスレッドは物理的にCPUの数までです。それ以上のスレッドを実行可能にしても、切り替え損が増えるだけです。
第2回までで、I/Oの完了通知を「イベント」や「APC」で受ける方法は見ました。しかしイベント方式は WaitForMultipleObjects の64個制限や待ち合わせの設計が煩雑になり、APCは発行スレッドに縛られます。多数のI/O × 少数のスレッドという形に最初から合わせて設計されたのがIOCPです。1
3. IOCPの設計 ── キューとスレッド制御の一体化
3.1. CreateIoCompletionPortの2つの顔
CreateIoCompletionPort は名前に反して2つの仕事をします。ポートの新規作成と、既存ポートへのハンドルの関連付けです。2
flowchart LR
subgraph SRC["関連付けたハンドル(何本でも)"]
H1["ファイル"]
H2["ソケット"]
H3["名前付きパイプ"]
end
subgraph PORT["I/O完了ポート"]
Q["完了パケットのキュー(FIFO)<br/>パケット = 転送バイト数 +<br/>CompletionKey + OVERLAPPEDポインター"]
C["コンカレンシー制御<br/>実行可能スレッド数 ≦ 上限"]
end
subgraph W["ワーカースレッド群"]
G1["GetQueuedCompletionStatusで待つ"]
G2["GetQueuedCompletionStatusで待つ"]
end
H1 --> Q
H2 --> Q
H3 --> Q
Q --> G1
Q --> G2
C -.制御.-> W
図2: IOCPの構成。多数のハンドルの完了が1つのキューに集まり、取り出す側のスレッド数まで制御される
関連付けのときに渡す CompletionKey は、「このハンドルからの完了です」をワーカーに伝えるための自由な値です。接続オブジェクトのポインターを入れるのが定石です。2
完了パケットでは、次の3つを組み合わせて操作を識別します。27
| 受け取る情報 | 識別・確認するもの |
|---|---|
| CompletionKey | どのハンドル・接続からの完了か |
| OVERLAPPEDポインター | その接続の、どの操作が完了したか |
| 転送バイト数 | どれだけのデータが転送されたか |
第2回で見た「操作の伝票」を、完了時に回収するための対応です。
対象は「ファイル」に限りません。ソケット、名前付きパイプ、メールスロットなど、オーバーラップI/Oを話せるハンドルなら何でも関連付けられます。1 第1回で見た「すべてがファイルに見える」設計が、ここでも効いています。
3.2. 完了パケットの旅
sequenceDiagram
participant DRV as カーネル(IRP完了)
participant Q as ポートのキュー(FIFO)
participant W as ワーカースレッド
Note over W: GetQueuedCompletionStatusで待機
DRV->>Q: 完了パケットを積む<br/>(バイト数 / CompletionKey / OVERLAPPED)
Q->>W: 待機中のスレッドを1本起こして手渡す
Note over W: パケットを見て完了処理<br/>(継続の実行・次のI/Oの発行など)
W->>Q: 処理を終えて再びGetQueuedCompletionStatus
Note over Q: キューにパケットが残っていれば<br/>待たずに次を受け取る
図3: 完了パケットはFIFOで積まれ、ワーカーは取り出しては処理するループを回す
非同期I/Oが完了すると、完了パケットがポートのキューにFIFO順で積まれます。ワーカーは次のループを繰り返します。17
GetQueuedCompletionStatusでパケットを1つ受け取る。- 受け取った操作の完了処理を行う。
- 処理を終えたら、再び
GetQueuedCompletionStatusを呼ぶ。
一度に複数のパケットをまとめて取り出すGetQueuedCompletionStatusExもあり、高頻度I/Oでは呼び出し回数を減らせます。8
FALSEだけを見てループを抜けない
GetQueuedCompletionStatusの戻り値がFALSEでも、失敗したI/Oの完了パケットを取り出せている場合があります。OVERLAPPEDポインターと組み合わせて判定してください。7
| 戻り値 | OVERLAPPED | 意味と処理 |
|---|---|---|
FALSE |
非NULL | 失敗したI/Oの完了を取得した。エラー処理と、伝票・バッファの後始末が必要 |
FALSE |
NULL | パケットを取得できなかった。タイムアウトやポートのクローズなど |
失敗した操作にも後始末は必要です。if (!GetQueuedCompletionStatus(...)) break;だけで処理すると、失敗したI/Oを取りこぼしてリークさせます。第2回で見た伝票・バッファの寿命管理は、正常完了だけの話ではありません。
判定の順番をワーカーループで見る
次の骨格コードは、上の表の2通りを先に判定し、その後で終了用パケットと通常の完了を処理します。
/* IOCPワーカーループの骨格(C / Win32) */
for (;;) {
DWORD bytes = 0;
ULONG_PTR key = 0;
OVERLAPPED *ov = NULL;
BOOL ok = GetQueuedCompletionStatus(port, &bytes, &key, &ov, INFINITE);
if (!ok && ov == NULL) {
/* パケットを取り出せていない(ポートが閉じられた等)。ここだけが抜けてよい条件 */
break;
}
if (!ok) {
/* ov != NULL → 「失敗したI/Oの完了パケット」を取り出せた。
後始末(エラー処理・伝票とバッファの解放)は必要なので、抜けずに処理する */
DWORD err = GetLastError();
handle_failed_io(key, ov, err);
continue;
}
if (key == SHUTDOWN_KEY) {
/* PostQueuedCompletionStatusで積んだ終了用パケット(4章) */
break;
}
handle_completed_io(key, ov, bytes); /* 通常の完了処理。短く保つ(3.4節) */
}
ok == FALSE を1つの分岐で片付けず、ov がNULLかどうかで2つに割るのが要点です。タイムアウト付き(INFINITE 以外)にする場合も判定は同じで、時間切れは ok == FALSE かつ ov == NULL として現れます。
なお、あるスレッドが GetQueuedCompletionStatus を最初に呼ぶと、そのスレッドはそのポートに関連付けられます(1スレッドが同時に関連付けられるポートは1つ)。1 「専属のワーカーチームがポートに付く」という絵で覚えておくと正確です。
3.3. スレッドはLIFOで起こされる
ここでは、パケットのキューに入る順番と、待機中のスレッドを起こす順番を分けて見てください。1
| 対象 | 順番 | 着目すること |
|---|---|---|
| 完了パケット | FIFO | 完了通知をキューへ積む順番 |
| 待機スレッド | LIFO | 最後に待機に入ったスレッドから起こす |
パケットとスレッドで順番が違うため、直前まで働いていたスレッドが次のパケットも拾う、という動きになります。
flowchart TB
Q["キュー: P1 → P2 → P3 (FIFO)"]
subgraph TH["待機スレッド(LIFOスタック)"]
A["スレッドA(直前まで実行、温かい)"]
B["スレッドB(しばらく寝ている)"]
C["スレッドC(ずっと寝ている)"]
end
Q -->|"P1もP2もP3も、空いていれば<br/>まずスレッドAへ"| A
B -.->|"Aが埋まったときだけ"| Q
C -.->|"めったに起きない"| Q
図4: LIFO解放。忙しいときほど同じスレッドが回り続け、暇なスレッドは寝たままでいられる
この設計の利益は2つあります。
- コンテキストスイッチが起きない。キューにパケットが残っている限り、処理を終えたスレッドが
GetQueuedCompletionStatusを呼ぶと待たずにそのまま次のパケットを受け取り、走り続けます。ドキュメントはコンカレンシー値1のシナリオで「スレッドの切り替えは発生しない」と明記しています。1 - キャッシュが温かいまま使える。同じスレッドが回り続けるので、スタックやスケジューリング上の状態がCPUキャッシュに残っている可能性が高くなります。寝ているスレッドは、負荷のピークに備えた予備として安く維持されます。
3.4. コンカレンシー値 ── 「実行可能」を数える
ポート作成時のNumberOfConcurrentThreadsがコンカレンシー値です。数えるのは、そのポートに関連付いた実行可能(runnable)なスレッドです。上限に達している間は、追加のスレッドがパケットを受け取れません。1
0を渡すとシステムのプロセッサ数が使われます。ドキュメントが全体としての最良の最大値として挙げているのもCPU数なので、これを出発点にします。21
待機中のスレッドを含めた総数の上限ではありません。 誰かが待ち状態に入った場合を、図5で見てください。
flowchart TB
P["キューにパケットが届く"]
Q{"実行可能なスレッド数は<br/>コンカレンシー値未満か"}
RUN["待機スレッドを起こして処理させる"]
HOLD["誰も起こさずキューに置いておく<br/>(実行中のスレッドが取りに来る)"]
BLK["実行中のスレッドが<br/>別の理由で待ち状態に入った"]
COMP["実行可能数が減った分だけ<br/>待機スレッドを起こして補充する"]
P --> Q
Q -->|未満| RUN
Q -->|上限| HOLD
BLK --> COMP
図5: コンカレンシー制御。上限は「実行可能な数」なので、誰かがブロックすれば自動で補充される
待ちに入ったワーカーは、別の待機スレッドで補う
実行中のワーカーが何かの待ち(ロック、ページフォールト、うっかり書いた同期I/O)に入ると、実行可能数が減るので、システムは待機中のスレッドを起こして穴を埋めます。1 だからワーカーを「CPU数ちょうど」しか作らないのではなく、コンカレンシー値より多めのスレッドを待機させておくのが定石です。処理に長い計算が混ざるならコンカレンシー値自体を上げる選択もあり、最終的にはプロファイリングで調整せよ、というのがドキュメントの立場です。1
一時的な上限超過もあるため、完了処理は短くする
ただし補充は万能ではありません。ブロックしたスレッドが後で目を覚ませば、その瞬間だけ実行可能数が上限を超えます(ドキュメントもこの超過に言及しています)。1 完了処理は短く保つのが大原則で、これは5.4節の.NETでも同じ形で効いてきます。
4. 道具箱 ── ポートを支えるAPIたち
4.1. 仕事の依頼や終了指示を、同じキューに流す
PostQueuedCompletionStatus ── I/Oを発行せずに、自前の完了パケットをキューに積めます。3 ワーカーへの仕事の依頼、シャットダウン指示(ワーカーの数だけ終了用パケット──俗に「毒饅頭」パケットと呼ばれます──を積む)、他スレッドからの通知──I/Oの完了と自前のメッセージを同じキュー・同じループで処理できるのは、設計を大きく単純化してくれます。
4.2. 高頻度I/Oの完了をまとめて受け取る
GetQueuedCompletionStatusEx ── 完了パケットを一度に複数取り出します。1パケット1呼び出しのオーバーヘッドが効いてくる高頻度I/Oで有効です。8
4.3. 同期完了時の通知を省略する
SetFileCompletionNotificationModes ── 第2回5章で見た「非同期発行したのに同期完了した」ケースで、ポートにパケットを積まないモードを選べます(FILE_SKIP_COMPLETION_PORT_ON_SUCCESS)。同期完了の結果はその場で分かっているのだから、キューを経由し直すだけ無駄──という高速化です。9
4.4. スレッドの生成・管理を任せる
WindowsスレッドプールAPI ── CreateThreadpoolIo / StartThreadpoolIo は内部でIOCPを使いつつ、スレッドの生成・管理を肩代わりします。マイクロソフトは新規のサーバーアプリにはまずこちらを検討し、コンカレンシー値やスレッド管理を明示的に制御したいときだけ生のIOCPを使うことを勧めています。1 そして.NETのスレッドプールも、まさにこの「IOCP+スレッド管理の自動化」を.NETランタイムとして実装したものです。
4.5. APIを選んでも変わらない、3つの寿命管理
- ワーカーの中で長時間ブロックしない。 3.4節の補充は劣化を緩和するだけです。
- ハンドル単位と操作単位を分けて識別する。 CompletionKeyはハンドル単位、
OVERLAPPEDは操作単位です。第2回の「伝票」は、完了まで解放してはいけません。 - 未完了I/Oが残ったままハンドルを閉じない。 cleanupの挙動(第1回6章)とキャンセルの作法(第2回6章)が、そのまま当てはまります。
5. .NETスレッドプール ── IOCPの上に建つ2階建て
ここからは、Win32の完了通知と.NETのコードを対応させます。見る順番は、完了を受けるスレッド → awaitの一往復 → 待ち時間 → 継続の実行場所です。
.NETスレッドプールは、次の2種類の役割を持つスレッドを提供します。4
| 種類 | この記事で見る役割 |
|---|---|
| ワーカースレッド | Task.Runや継続を実行する |
| I/O完了スレッド | 非同期I/Oの完了を受ける |
ThreadPool.GetAvailableThreads(out workerThreads, out completionPortThreads)も、この2つを別々の数として返します。以下では、この「完了を受ける側」と「仕事を実行する側」を区別して追います。4
Windowsでは、スレッドプールは自前のI/O完了ポートを持っています。OSのハンドルをこのポートに関連付ける低レベルAPIがThreadPoolBoundHandle.BindHandleです。束縛したハンドルへの非同期I/Oは、NativeOverlappedと組み合わせて扱います。これは第2回のOVERLAPPEDに対応する.NET側の仕組みです。5
古くからのThreadPool.BindHandleも同じ役割で残っていますが、新しく書くならThreadPoolBoundHandle.BindHandleです。FileStreamやSocketが非同期モードのハンドルを開くと、内部でこの種の結び付けが行われます。5
Win32との対応を、発行と完了に分けると次のようになります。
- 第2回の「非同期モードのハンドル+OVERLAPPED」が発行の仕組み
- 本記事のIOCPが完了を受ける仕組み
- .NETスレッドプールのI/O完了スレッドが、
GetQueuedCompletionStatusループを回すワーカーチーム
という対応で、Win32の絵がそのまま.NETの絵になります。
5.1. await ReadAsyncの一往復・完全版
第2回の図7で「本物の非同期I/O」と書いた箱の中身を、今回は完了後の継続まで追います。図6では、発行時のスレッドと、完了通知を受けるスレッド、続きのコードを実行する場所を分けて見てください。
sequenceDiagram
participant U as 呼び出しスレッド<br/>(UIスレッドなど)
participant K as カーネル<br/>(IRP発行〜完了)
participant Q as スレッドプールのIOCP
participant IO as I/O完了スレッド
participant C as 継続の実行場所
U->>K: ReadAsyncが非同期読み取りを発行<br/>(OVERLAPPED相当を添えて)
K-->>U: ERROR_IO_PENDING(すぐ戻る)
Note over U: awaitが未完了Taskに継続を登録し<br/>スレッドを手放す(UIなら次のメッセージ処理へ)
Note over K: デバイスが仕事中<br/>この間、待っているスレッドはどこにもいない
K->>Q: 完了パケットを積む
Q->>IO: LIFOで1本起こして手渡し
Note over IO: 結果(バイト数・状態)を確定し<br/>Taskを完了させ、継続をスケジュール
IO->>C: 捕捉したコンテキストへ投げる<br/>(UIスレッドへ / なければスレッドプールで実行)
Note over C: awaitの続きのコードが走る
図6: await一往復の全体。スレッドが働くのは「発行」と「完了後」だけで、待ち時間はスレッドゼロ
5.2. 「I/O待ちはスレッドを消費しない」の正確な意味
待ち時間のためだけに、専用スレッドを置かない
この図で強調したいのは、発行から完了までの間、その完了を待つためだけのスレッドはユーザーモードにもカーネルにも存在しないことです。マイクロソフトのasync解説(async in depth)も、I/OバウンドのTaskについて、デバイスドライバーと割り込みまで降りて説明しています。6
一方、カーネル内でドライバーが処理の一部をシステムのワーカースレッドに委ねる場面はあります。それは要求を前へ進めるための仕事であり、完了をブロックして待ち続けるためのスレッドとは別です。「スレッドを消費しない」は、この待ち時間についての説明です。
第1回からの積み上げで言い換えると──IRPはスレッドではなくデータ構造としてデバイススタックに滞在し(第1回)、発行は ERROR_IO_PENDING ですぐ戻り(第2回)、完了は割り込み→完了パケットというイベントの連鎖で届く(本記事)。「待つ」という状態を維持するのに、スレッドという高価な資源が要らない作りになっているわけです。
CPU処理や、見せかけの非同期とは区別する
だから、async/await を正しく使ったアプリは「同時に10,000件のI/Oが飛んでいる」状態を、スレッド十数本で維持できます。逆に言えば、この性質はI/OバウンドのTaskだけのものです。Task.Run で包んだCPU処理は当然ワーカースレッドを1本占有しますし、第2回7章の「見せかけの非同期」も裏でスレッドを眠らせています。
5.3. 継続はどこで走るか
図6の最後の矢印は、「完了したことを受け取った後、継続をどこで実行するか」です。コンテキストへ戻すかどうかと、CPUを使う処理をどこへ出すかを分けて考えます。6
flowchart TB
A["Taskが完了し、継続を実行したい"]
Q1{"awaitした時点で<br/>SynchronizationContextか<br/>非既定のTaskSchedulerを捕捉していたか"}
Q2{"ConfigureAwait(false)を<br/>付けていたか"}
UI["捕捉した先へ投げ返す<br/>例: UIスレッドのメッセージループや<br/>そのTaskSchedulerの上で実行"]
TP["特定の場所へ戻す義務なし<br/>完了させたスレッドで同期的に続くか<br/>スレッドプールのスレッドで実行"]
A --> Q2
Q2 -->|"はい"| TP
Q2 -->|"いいえ"| Q1
Q1 -->|"はい (WPF/WinFormsのUIスレッドなど)"| UI
Q1 -->|"いいえ (コンソール/ASP.NET Coreなど)"| TP
図7: 継続の行き先。「awaitの後でそのままUIを触れる」のは、捕捉したコンテキストに投げ返しているから
捕捉したコンテキストと、同期的に続く場合
- WPF/WinFormsのUIスレッドで
awaitすると、SynchronizationContextが捕捉され、続きはUIスレッドに戻ります。だからawaitの直後にコントロールを触ってもスレッド違反になりません。この設計の実務側は「WPF/WinFormsのasyncとUIスレッドを一枚で整理」で扱いました。 - 捕捉されるのは
SynchronizationContextだけでなく、非既定のTaskSchedulerの上でawaitした場合はそのスケジューラーも対象です。どちらもない場所(コンソール、ASP.NET Core、スレッドプール上)では、特定の場所へ戻す義務がないので、継続はスレッドプールで走るか、Taskを完了させたスレッドの上でそのまま同期的に続きます。 ConfigureAwait(false)は「戻らなくてよい」の明示であって、「必ずスレッドプールに移る」保証ではありません。すでに完了しているTaskをawaitした場合(第2回で見た同期完了もこれに含まれます)は、待ちが発生せず現在のスレッドでそのまま続行します。ライブラリコードでの使い分けは「C# async/await実務判断表」を参照してください。
悪い例: ConfigureAwait(false)で移動したつもりになる
最後の点をコードで確かめます。次の例は、ConfigureAwait(false)を「スレッドプールへ移る指示」だと思って書いたものです。
// 悪い例: 「ConfigureAwait(false) を付けたので、この先はスレッドプールで走る」という誤解
private async void OnLoadClick(object sender, EventArgs e)
{
string csv = await File.ReadAllTextAsync(path).ConfigureAwait(false);
// 期待: ここはスレッドプールだからUIは固まらない
// 実際: awaitした時点でTaskが完了済みなら待ちは発生せず、
// UIスレッドのまま続行する → この重い処理でUIが固まる
var rows = ParseHeavy(csv);
// しかも、非同期に完了した場合の続きはUIスレッドではない
resultLabel.Text = $"{rows.Count} 件"; // → スレッド違反の例外になりうる
}
ConfigureAwait(false) が言っているのは「捕捉したコンテキストへ戻さなくてよい」だけです。「どこで走るか」は指定していないので、UIスレッドのまま続くことも、I/O完了スレッドやスレッドプールのスレッドで続くこともあります。両方あり得るのが、このコードが壊れている理由です。
良い例: UIへの復帰と、重い処理の実行先を別々に指定する
UIコードとライブラリコードを分けて、意図を書き分けます。UIに戻すためのawaitと、CPU処理をスレッドプールへ出すTask.Runは別の役割です。
// 良い例: 「UIに戻す/戻さない」と「重い処理をどこでやるか」を別々に指示する
private async void OnLoadClick(object sender, EventArgs e)
{
// UIコードでは捕捉させたままにする(続きはUIスレッドへ戻る)
string csv = await File.ReadAllTextAsync(path);
// CPUを使う処理をスレッドプールへ出したいなら、Task.Run で明示する
var rows = await Task.Run(() => ParseHeavy(csv));
// ここは確実にUIスレッド。安全にコントロールを触れる
resultLabel.Text = $"{rows.Count} 件";
}
// ライブラリ側(UIを持たないコード)では逆に、「戻る必要がない」ことを表明する
public async Task<string> ReadConfigAsync(string path)
{
string text = await File.ReadAllTextAsync(path).ConfigureAwait(false);
return text.Trim(); // 呼び出し元のコンテキストに依存しない
}
判断は、次の3つに分けられます。
| 目的 | このコードでの選択 |
|---|---|
| await後にUIを触る | UIコードではコンテキストを捕捉させたままにする |
| 重いCPU処理をスレッドプールへ出す | Task.Runで明示する |
| ライブラリ内で、呼び出し元のコンテキストへ戻る必要がない | ConfigureAwait(false)で捕捉をやめる |
ConfigureAwait(false)は実行スレッドを指定するものではありません。呼び出し元のコンテキストに依存せず動くライブラリ側の道具として、UIの処理やCPU処理の配置とは切り分けてください。
5.4. 詰まりの正体 ── スレッドプール飢餓
同期的な待ちが、継続を実行するスレッドを塞ぐ
継続やワーカーの中で同期的に待つと、そのスレッドは塞がったままになります。同期I/O、Task.Result/Wait()、長いロック待ちが、このパターンです。
IOCP自身の補充(3.4節)は、待機中の予備スレッドがいる限り即座に効きます。しかし予備が尽きた先は、スレッドプールが新しいスレッドをゆっくりとしか注入しない領域です。
負荷がかかった瞬間に「継続を実行したいのに、実行するスレッドがない」という飢餓(starvation)が起きると、アプリ全体がもたつきます。
空き数と、実際のブロック箇所を合わせて調べる
調査の入口は2つです。
ThreadPool.GetAvailableThreadsで、ワーカーとI/O完了スレッドの空きを見る。4- イベントトレースで、スレッドプールとブロックの実態を掴む。
トレースの手順は「PerfViewとdotnet-traceで「遅い」を特定する」にまとめています。
予防の原則は、asyncの道をasyncのまま最後まで通し、sync-over-asyncを混ぜないことです。I/O待ちの間にスレッドを手放せても、継続の中で同期的に待てば、そこで再びスレッドを塞いでしまいます。
6. まとめ
- IOCPは完了キュー(FIFO)とスレッド数制御を一体化した仕組みです。多数のハンドルの完了を1つのポートに集約し、
GetQueuedCompletionStatusのループで処理します。17 - スレッドはLIFOで解放されるため、忙しいときほど同じスレッドが回り続け、コンテキストスイッチとキャッシュミスが最小化されます。1
- コンカレンシー値は「実行可能スレッド数」の上限で、出発点はCPU数(0指定)。実行中のスレッドがブロックすると待機スレッドで補充されますが、完了処理は短く保つのが大原則です。12
PostQueuedCompletionStatusで自前のパケットも流せます。新規実装ではWindowsスレッドプールAPI(内部はIOCP)が第一候補です。31- .NETスレッドプールはワーカースレッド+I/O完了スレッドの2階建てで、非同期ハンドルはプールのIOCPに結び付きます。
awaitのI/O待ちにスレッドは存在せず、完了後の継続だけがスレッドに乗ります。456 - 継続の行き先は捕捉したコンテキスト次第(UIスレッドに戻る/スレッドプールで続行)。
ConfigureAwait(false)はその捕捉をやめる指示です。6 - 詰まりの正体はほぼ同期待ちの混入によるスレッドプール飢餓です。asyncの道はasyncのまま通し切ってください。
続きは第4回「キャッシュマネージャー ── あなたのWriteFileはいつディスクに届くのか」です。第2回で「キャッシュに載っていれば同期完了する」と書き、今回も何度かキャッシュの影がちらつきました。次はそのキャッシュ本体──遅延書き込み、先読み、FILE_FLAG_NO_BUFFERING、そして「書いたはずのデータが電源断で消える」条件──に正面から向き合います。
関連記事
- Windows I/Oの深層(第1回) ── すべての読み書きはIRPになる:I/Oシステムの全体像
- Windows I/Oの深層(第2回) ── 同期I/Oと非同期I/O:OVERLAPPEDの本当の意味
- C# async/await実務判断表 - Task.RunとConfigureAwait
- WPF/WinFormsのasyncとUIスレッドを一枚で整理
- TCPでSendした単位ごとにReceiveできるという誤解 ── バイトストリームとして扱うための受信設計
- PerfViewとdotnet-traceで「遅い」を特定する ── .NETパフォーマンス調査の実務入門
- 普通のWindowsでソフトリアルタイムをできるだけ実現するための実践ガイド
関連する相談領域
合同会社小村ソフトでは、多数の同時接続・同時I/Oを扱うWindowsアプリ/サーバーの設計と、「スレッドプールが詰まる」「非同期化したのに速くならない」といった性能問題の原因調査を扱っています。
参考リンク
-
Microsoft Learn, I/O Completion Ports. I/O完了ポートがマルチプロセッサシステム上で多数の非同期I/O要求を処理するための効率的なスレッディングモデルを提供すること、非同期I/Oの完了時に完了パケットがFIFO順でポートのキューに積まれること、対象がディスク上のファイルに限らずソケット・名前付きパイプ・メールスロットなどオーバーラップI/Oをサポートする任意のハンドルであること、ポートで待機するスレッドがLIFO順で解放され、コンカレンシー値1でキューが埋まっている場合にはスレッド切り替えが発生しないこと、スレッドが最初にGetQueuedCompletionStatusを呼ぶとそのポートに関連付けられ同時に1つのポートにしか関連付けられないこと、コンカレンシー値が実行可能スレッド数を制限し、全体として最良の最大値はCPU数であること、実行中のスレッドが他の理由で待ち状態に入った場合に待機中のスレッドが完了パケットを処理できること(およびブロックしたスレッドが目覚めた際に一時的に上限を超えうること)、新規のサーバーアプリケーションにはまずWindowsスレッドプールAPI(CreateThreadpoolIo等。内部でIOCPを使用)を検討すべきことについて。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20
-
Microsoft Learn, CreateIoCompletionPort function. CreateIoCompletionPortがI/O完了ポートの新規作成と、既存ポートへのハンドルの関連付けの両方を行うこと、関連付け時にCompletionKey(ユーザー定義の値)を指定でき完了パケットに含まれること、NumberOfConcurrentThreadsが完了パケットを並行処理できるスレッド数の上限で、0を指定するとシステムのプロセッサ数が使われることについて。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, PostQueuedCompletionStatus function. PostQueuedCompletionStatusが非同期I/Oを開始することなく、アプリケーション独自の完了パケットをI/O完了ポートのキューに積めること、これによりポートがI/O完了の受信に加えてプロセス内の他スレッドからの通信の受け口としても使えることについて。 ↩ ↩2 ↩3
-
Microsoft Learn, The managed thread pool. .NETのスレッドプールがワーカースレッドと非同期I/O完了のためのスレッドを提供すること、ThreadPool.GetAvailableThreadsでワーカースレッドとI/O完了スレッドの利用可能数を別々に取得できること、スレッドプールのスレッドで長時間のブロックを行うべきでないことについて。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, ThreadPoolBoundHandle.BindHandle method. ThreadPoolBoundHandle.BindHandleがオペレーティングシステムのハンドルをシステムスレッドプール(のI/O完了ポート)に結び付けたThreadPoolBoundHandleを返すこと、束縛したハンドルに対する低レベルの非同期I/OをNativeOverlappedと組み合わせて行うこと、非同期I/Oの完了処理がスレッドプールによって行われるようになることについて。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Async in depth (.NET). I/OバウンドのTaskでは、呼び出しがOSに渡った後にその完了を待つための専用スレッドが存在しないこと(いわゆる「There is no thread」)、デバイスドライバーと割り込みを経て完了が通知され、登録されていた継続が実行されること、awaitが既定で現在のコンテキスト(SynchronizationContext等)を捕捉して継続をそこで実行し、捕捉すべきコンテキストがない場合はスレッドプールで実行されること、ConfigureAwait(false)でこの捕捉を無効化できることについて。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, GetQueuedCompletionStatus function. GetQueuedCompletionStatusが完了ポートのキューから完了パケットを1つ取り出す(なければ待つ)こと、取り出した結果として転送バイト数・CompletionKey・OVERLAPPEDポインターが得られること、そして戻り値がFALSEでもOVERLAPPEDポインターが非NULLの場合は「失敗したI/O操作の完了パケットを取り出した」ことを意味し、OVERLAPPEDがNULLの場合のみ(タイムアウトなどで)パケットを取り出せなかったことを意味することについて。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, GetQueuedCompletionStatusEx function. GetQueuedCompletionStatusExが複数の完了パケットを一度に取り出せること、取り出したエントリ数が返ることについて。 ↩ ↩2
-
Microsoft Learn, SetFileCompletionNotificationModes function. FILE_SKIP_COMPLETION_PORT_ON_SUCCESSにより、I/Oが即時に成功して結果がその場で確定した場合に完了ポートへパケットを積まない動作を選べることについて。 ↩
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
Windows I/Oの深層(第2回) ── 同期I/Oと非同期I/O:OVERLAPPEDの本当の意味
Windowsの同期I/Oと非同期I/O(オーバーラップI/O)を図解で解説する連載の第2回です。FILE_FLAG_OVERLAPPEDの意味、完了通知の4方式、非同期のはずが同期完了する条件、キャンセルの作法、.NETとの対応までを整理します。
Windows I/Oの深層(第4回) ── キャッシュマネージャー:あなたのWriteFileはいつディスクに届くのか
Windowsのキャッシュマネージャーを図解で解説する連載の第4回です。ファイルマッピングとして実装されたキャッシュ、先読みと遅延書き込み、FlushFileBuffersやFILE_FLAG_NO_BUFFERINGの使い分け、電源断でデータが消える条件までを整理します。
Windows I/Oの深層(第1回) ── すべての読み書きはIRPになる:I/Oシステムの全体像
WindowsのI/Oシステムを底から解説する連載の第1回です。オブジェクトマネージャーの名前空間、ドライバー・デバイス・ファイルの3つのオブジェクト、IRPのライフサイクル、CloseHandleの裏側までを図解で整理します。
Windowsの偽装トークンを正しく扱う ── スレッド単位の権限借用と安全な戻し方
Windowsの偽装トークンについて、アクセストークン、プライマリトークン、スレッドトークン、偽装レベル、RevertToSelf、.NETのWindowsIdentity.RunImpersonatedまで、実務で安全に扱うための考え方を整理します。
Windowsのプリンタードライバー終了 ── 業務アプリの帳票・ラベル印刷はどう備えるか
Microsoftはv3/v4プリンタードライバーの提供終了を段階的に進めており、2026年7月からはIPPクラスドライバーが優先されます。Windows protected print modeで何が消えるか、業務アプリの帳票・ラベル印刷の依存箇所の棚卸しと備え方を判断表...
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
Windowsアプリ開発
業務アプリ、装置連携、通信ツールなどの Windows ソフト開発を支援します。
よくある質問
この記事のテーマについて、相談時によくある質問をまとめています。
- I/O完了ポート(IOCP)とは何ですか?
- 多数の非同期I/Oの完了通知を1つのキューに集約し、それを処理するスレッドの同時実行数までまとめて制御する、Windowsカーネルの仕組みです。CreateIoCompletionPortでポートを作り、ファイルやソケットなどのハンドルを関連付けると、非同期I/Oが完了するたびに完了パケットがポートのFIFOキューに積まれます。ワーカースレッドはGetQueuedCompletionStatusでキューからパケットを取り出して処理します。ポイントは、これが単なる通知キューではなく「実行可能なスレッド数をコンカレンシー値以下に保つ」スケジューリング機構を兼ねていることです。多数の同時I/Oを少数のスレッドで効率よく処理でき、Windowsのサーバー実装や.NETスレッドプールの土台になっています。
- IOCPのコンカレンシー値(同時実行数)はいくつにすべきですか?
- マイクロソフトのドキュメントは、全体としての最良の最大値はコンピューターのCPU数だと述べています。CreateIoCompletionPortのNumberOfConcurrentThreadsに0を渡すとシステムのプロセッサ数が使われるので、迷ったら0が出発点です。この値は「実行可能状態のスレッド数」の上限であって、待機中のスレッド数の上限ではありません。実行中のスレッドが何かの理由で待ち状態に入ると、システムは待機中の別のスレッドを起こして穴を埋めるので、処理に長い計算やブロックが混ざるなら、コンカレンシー値を大きめにして同時に処理されるパケット数を増やす選択もあります。最終的にはプロファイリングと組み合わせて調整することが推奨されています。
- なぜasync/awaitのI/O待ちはスレッドを消費しないと言えるのですか?
- I/Oの発行から完了までの間、その操作の面倒を見るための専用スレッドがどこにも存在しないからです。連載第1回・第2回で見たとおり、発行された要求はIRPとしてデバイススタックに流れ、呼び出しはERROR_IO_PENDINGですぐ戻ります。awaitはそこで未完了のTaskに継続を登録してスレッドを手放すだけです。デバイスがハードウェアとして仕事をしている間、ユーザーモードにもカーネルにも「待つだけのスレッド」はいません。完了すると完了パケットがスレッドプールのIOCPに積まれ、そこで初めてI/O完了スレッドが短時間動いて、登録されていた継続をスケジュールします。つまりスレッドが使われるのは発行の瞬間と完了後の後処理だけで、待っている時間そのものはスレッドゼロで進みます。
- awaitの続き(継続)はどのスレッドで実行されますか?
- 既定では、awaitした時点のSynchronizationContext(またはTaskScheduler)が捕捉され、継続はそこに投げ返されます。WPFやWinFormsのUIスレッドでawaitしていれば続きはUIスレッドで走り、だからawaitの後でそのままコントロールを触れます。捕捉すべきコンテキストがない場合(コンソールアプリ、ASP.NET Core、スレッドプール上のコードなど)は、継続はスレッドプールのスレッドで実行されるか、Taskを完了させたスレッドの上でそのまま続きます。ConfigureAwait(false)を付けると捕捉をやめますが、これは「必ずスレッドプールへ移る」保証ではなく「特定の場所へ戻さなくてよい」という指示です。すでに完了しているTaskをawaitした場合は待ちが発生せず、現在のスレッドで同期的に続行します。ライブラリコードでConfigureAwait(false)が推奨されるのは、UIスレッドへの不要な往復を避け、特定コンテキストへの依存やデッドロックの芽を断つためです。
- IOCPのワーカースレッドや.NETのI/O完了スレッドで長時間ブロックするとどうなりますか?
- 即座に壊れはしませんが、仕組みの想定から外れて性能が劣化します。IOCPは実行中のスレッドが待ち状態に入ると待機中のスレッドを起こして補充しますが、補充されたスレッドの分だけ同時実行数が膨らみ、コンテキストスイッチが増えます。ブロックが常態化すると、キューにパケットが溜まって完了処理全体が遅延します。.NETでも同じで、I/O完了スレッドや継続の中で同期I/OやTask.Resultのような待ちをすると、スレッドプールの飢餓(starvation)を招きます。完了処理・継続は短く保ち、重い仕事は切り出すのが原則です。ThreadPool.GetAvailableThreadsでワーカースレッドとI/O完了スレッドの空きを観測できるので、詰まりの調査に使えます。