Windows I/Oの深層(第3回) ── I/O完了ポート(IOCP)と.NETスレッドプール:async/awaitの地下室

· · Windows, Win32, I/O, IOCP, 非同期, スレッドプール, .NET, CSharp

前回(第2回)は、非同期I/Oの発行と、完了を受け取る4つの経路を見ました。そのとき「多数の同時I/Oを少数のスレッドで受ける本命」として名前だけ出したのがI/O完了ポート(IOCP)です。

なぜWebサーバーは数千の同時接続を十数本のスレッドで捌けるのか。なぜasync/awaitのI/O待ちは「スレッドを消費しない」と言い切れるのか。なぜawaitの続きは、あるときはUIスレッドで、あるときはスレッドプールで走るのか──この3つの疑問の答えは、すべてIOCPという1つの設計に行き着きます。今回は連載でいちばん.NET開発者に直結する回です。

連載「Windows I/Oの深層」の第3回です。全体の構成は第1回の冒頭に置いています。

1. まず結論

  • IOCPは「完了通知のキュー」と「スレッド数の制御」を一体化した仕組みです。完了パケットはFIFOでキューに積まれ、ワーカースレッドが GetQueuedCompletionStatus で取り出します(3章)。1
  • スレッドはLIFOで起こされます。直前まで働いていた「温まった」スレッドが次のパケットも拾うので、キューが埋まっている限りコンテキストスイッチがほぼ起きません(3.3節)。1
  • コンカレンシー値は「実行可能なスレッド数」の上限です。推奨の出発点はCPU数(指定0でプロセッサ数)。実行中のスレッドがブロックすると、待機中のスレッドを起こして穴を埋めます(3.4節)。12
  • ポートは自前の通知にも使えます。PostQueuedCompletionStatus でI/Oとは無関係のパケットを積めるので、ワーカーへの仕事依頼や終了指示も同じキューで流せます(4章)。3
  • 新規のサーバー実装なら、生のIOCPよりWindowsスレッドプールAPI(CreateThreadpoolIo)が推奨です。内部はIOCPのまま、スレッド管理を肩代わりしてくれます(4章)。1
  • .NETスレッドプールはワーカースレッドとI/O完了スレッドの2階建てで、非同期I/Oのハンドルはスレッドプール(のIOCP)に結び付けられます。await のI/O待ちにスレッドは存在せず、完了後の継続だけがスレッドに乗ります(5章)。456
  • 継続の行き先は「捕捉されたコンテキスト」が決めます。UIスレッドで await すれば続きはUIスレッドへ、捕捉するものがなければスレッドプール(または完了させたスレッド上)で続きます。ConfigureAwait(false) は捕捉をやめる指示であって、スレッドプール行きの保証ではありません(5.3節)。6

2. スレッドで殴る設計はどこで破綻するか

まず、IOCPが解こうとした問題を確認します。素朴なサーバーは「接続1本にスレッド1本」で書けます。同期I/Oで読んで、処理して、書く。分かりやすい設計ですが、接続が増えると2つの壁に当たります。

IOCPモデル(非同期I/O)完了キュー(全接続の完了通知が集まる)ワーカー1ワーカー2ワーカーはCPU数程度の少数接続ごとにスレッド1本(同期I/O)スレッド1: 接続1のread待ちスレッド2: 接続2のread待ちスレッド3: 接続3のread待ち…接続の数だけスレッドが増える大半はI/O待ちで眠っているだけ

図1: 左は接続数に比例してスレッドが増える。右は「起きた出来事」だけを少数のスレッドで処理する

  1. スレッドはタダではない。1本ごとにスタック(既定で予約1MB)とカーネルオブジェクトを消費し、数が増えるほどスケジューラーとコンテキストスイッチの負担が積み上がります。数千接続=数千スレッドは、その大半が「読めるのを待って眠っているだけ」でも高くつきます。
  2. 「実行したい数」がCPU数を超えても意味がない。同時に走れるスレッドは物理的にCPUの数までです。それ以上のスレッドを実行可能にしても、切り替え損が増えるだけです。

第2回までで、I/Oの完了通知を「イベント」や「APC」で受ける方法は見ました。しかしイベント方式は WaitForMultipleObjects の64個制限や待ち合わせの設計が煩雑になり、APCは発行スレッドに縛られます。多数のI/O × 少数のスレッドという形に最初から合わせて設計されたのがIOCPです。1

3. IOCPの設計 ── キューとスレッド制御の一体化

3.1. CreateIoCompletionPortの2つの顔

CreateIoCompletionPort は名前に反して2つの仕事をします。ポートの新規作成と、既存ポートへのハンドルの関連付けです。2

ワーカースレッド群I/O完了ポート関連付けたハンドル(何本でも)制御GetQueuedCompletionStatusで待つGetQueuedCompletionStatusで待つ完了パケットのキュー(FIFO)パケット = 転送バイト数 +CompletionKey + OVERLAPPEDポインターコンカレンシー制御実行可能スレッド数 ≦ 上限ファイルソケット名前付きパイプ

図2: IOCPの構成。多数のハンドルの完了が1つのキューに集まり、取り出す側のスレッド数まで制御される

関連付けのときに渡す CompletionKey は、「このハンドルからの完了です」をワーカーに伝えるための自由な値です(接続オブジェクトのポインターを入れるのが定石)。完了パケットには、CompletionKeyと、その操作の OVERLAPPED ポインター、転送バイト数が入って届きます。どの接続の(CompletionKey)、どの操作が(OVERLAPPED)、どれだけ進んだか(バイト数)──第2回で見た「操作の伝票」が、ここで回収されるわけです。27

対象は「ファイル」に限りません。ソケット、名前付きパイプ、メールスロットなど、オーバーラップI/Oを話せるハンドルなら何でも関連付けられます。1 第1回で見た「すべてがファイルに見える」設計が、ここでも効いています。

3.2. 完了パケットの旅

ワーカースレッドポートのキュー(FIFO)カーネル(IRP完了)ワーカースレッドポートのキュー(FIFO)カーネル(IRP完了)GetQueuedCompletionStatusで待機パケットを見て完了処理(継続の実行・次のI/Oの発行など)キューにパケットが残っていれば待たずに次を受け取る完了パケットを積む(バイト数 / CompletionKey / OVERLAPPED)待機中のスレッドを1本起こして手渡す処理を終えて再びGetQueuedCompletionStatus

図3: 完了パケットはFIFOで積まれ、ワーカーは取り出しては処理するループを回す

非同期I/Oが完了すると、完了パケットがポートのキューにFIFO順で積まれます。ワーカーは GetQueuedCompletionStatus を呼んでパケットを1つ受け取り、処理が終わったらまた呼ぶ──このループがIOCPプログラミングの骨格です。17 一度に複数のパケットをまとめて取り出す GetQueuedCompletionStatusEx もあり、高頻度I/Oでは呼び出し回数を減らせます。8

ここで、ワーカーループの定番バグを先に潰しておきます。GetQueuedCompletionStatusFALSE を返しても、OVERLAPPED ポインターが非NULLで返ってきたなら、それは「失敗したI/Oの完了パケットを取り出せた」という意味です。7 失敗した操作にも後始末(エラー処理と、第2回で見た伝票・バッファの解放)は必要なので、このパケットは処理しなければなりません。「パケットそのものを取り出せなかった」(タイムアウト、ポートのクローズなど)と言えるのは、OVERLAPPED がNULLのときだけです。if (!GetQueuedCompletionStatus(...)) break; と横着に書くと、失敗したI/Oをすべて取りこぼしてリークさせます

なお、あるスレッドが GetQueuedCompletionStatus を最初に呼ぶと、そのスレッドはそのポートに関連付けられます(1スレッドが同時に関連付けられるポートは1つ)。1 「専属のワーカーチームがポートに付く」という絵で覚えておくと正確です。

3.3. スレッドはLIFOで起こされる

ここからがIOCPの設計の妙です。パケットはFIFOで積まれますが、待っているスレッドはLIFOで起こされます。つまり、いちばん最近まで働いていたスレッドが次のパケットも拾います1

待機スレッド(LIFOスタック)P1もP2もP3も、空いていればまずスレッドAへAが埋まったときだけめったに起きないスレッドA(直前まで実行、温かい)スレッドB(しばらく寝ている)スレッドC(ずっと寝ている)キュー: P1 → P2 → P3 (FIFO)

図4: LIFO解放。忙しいときほど同じスレッドが回り続け、暇なスレッドは寝たままでいられる

この設計の利益は2つあります。

  • コンテキストスイッチが起きない。キューにパケットが残っている限り、処理を終えたスレッドが GetQueuedCompletionStatus を呼ぶと待たずにそのまま次のパケットを受け取り、走り続けます。ドキュメントはコンカレンシー値1のシナリオで「スレッドの切り替えは発生しない」と明記しています。1
  • キャッシュが温かいまま使える。同じスレッドが回り続けるので、スタックやスケジューリング上の状態がCPUキャッシュに残っている可能性が高くなります。寝ているスレッドは、負荷のピークに備えた予備として安く維持されます。

3.4. コンカレンシー値 ── 「実行可能」を数える

ポート作成時の NumberOfConcurrentThreadsコンカレンシー値です。これは「そのポートに関連付いた実行可能(runnable)なスレッド数」の上限で、上限に達している間は、追加のスレッドがパケットを受け取れません。1 0を渡すとシステムのプロセッサ数が使われ、ドキュメントも全体としての最良の最大値はCPU数としています。21

賢いのは、この数が「起きているスレッド」ではなく「実行可能なスレッド」を数えていることです。

未満上限キューにパケットが届く実行可能なスレッド数はコンカレンシー値未満か待機スレッドを起こして処理させる誰も起こさずキューに置いておく(実行中のスレッドが取りに来る)実行中のスレッドが別の理由で待ち状態に入った実行可能数が減った分だけ待機スレッドを起こして補充する

図5: コンカレンシー制御。上限は「実行可能な数」なので、誰かがブロックすれば自動で補充される

実行中のワーカーが何かの待ち(ロック、ページフォールト、うっかり書いた同期I/O)に入ると、実行可能数が減るので、システムは待機中のスレッドを起こして穴を埋めます1 だからワーカーを「CPU数ちょうど」しか作らないのではなく、コンカレンシー値より多めのスレッドを待機させておくのが定石です。処理に長い計算が混ざるならコンカレンシー値自体を上げる選択もあり、最終的にはプロファイリングで調整せよ、というのがドキュメントの立場です。1

ただし補充は万能ではありません。ブロックしたスレッドが後で目を覚ませば、その瞬間だけ実行可能数が上限を超えます(ドキュメントもこの超過に言及しています)。1 完了処理は短く保つのが大原則で、これは6章の.NETでも同じ形で効いてきます。

4. 道具箱 ── ポートを支えるAPIたち

  • PostQueuedCompletionStatus ── I/Oを発行せずに、自前の完了パケットをキューに積めます。3 ワーカーへの仕事の依頼、シャットダウン指示(「毒饅頭」パケットをワーカー数だけ積む)、他スレッドからの通知──I/Oの完了と自前のメッセージを同じキュー・同じループで処理できるのは、設計を大きく単純化してくれます。
  • GetQueuedCompletionStatusEx ── 完了パケットを一度に複数取り出します。1パケット1呼び出しのオーバーヘッドが効いてくる高頻度I/Oで有効です。8
  • SetFileCompletionNotificationModes ── 第2回5章で見た「非同期発行したのに同期完了した」ケースで、ポートにパケットを積まないモードを選べます(FILE_SKIP_COMPLETION_PORT_ON_SUCCESS)。同期完了の結果はその場で分かっているのだから、キューを経由し直すだけ無駄──という高速化です。9
  • WindowsスレッドプールAPI ── CreateThreadpoolIo / StartThreadpoolIo は内部でIOCPを使いつつ、スレッドの生成・管理を肩代わりします。マイクロソフトは新規のサーバーアプリにはまずこちらを検討し、コンカレンシー値やスレッド管理を明示的に制御したいときだけ生のIOCPを使うことを勧めています。1 そして.NETのスレッドプールも、まさにこの「IOCP+スレッド管理の自動化」を.NETランタイムとして実装したものです。

落とし穴も3つだけ挙げておきます。(1) ワーカーの中で長時間ブロックしない(3.4節の補充は劣化を緩和するだけ)。(2) 完了パケットの識別はCompletionKey(ハンドル単位)と OVERLAPPED(操作単位)の2段で行う──第2回の「伝票」の寿命管理(完了まで解放しない)はここでも生命線です。(3) 未完了I/Oが残ったままハンドルを閉じない──cleanupの挙動(第1回6章)とキャンセルの作法(第2回6章)がそのまま当てはまります。

5. .NETスレッドプール ── IOCPの上に建つ2階建て

ここからが本題の「async/awaitの地下室」です。

.NETのスレッドプールには2種類のスレッドがいます。Task.Run や継続を実行するワーカースレッドと、非同期I/Oの完了を受けるI/O完了スレッドです。ThreadPool.GetAvailableThreads(out workerThreads, out completionPortThreads) が2つの数を別々に返すのは、内部が実際に2階建てだからです。4

そしてWindowsでは、スレッドプールは自前のI/O完了ポートを持っています。OSのハンドルをこのポートに関連付ける現行の低レベルAPIが ThreadPoolBoundHandle.BindHandle で、束縛したハンドルへの非同期I/Oは NativeOverlapped(まさに第2回の OVERLAPPED の.NET側の姿)と組みで扱います(古くからの ThreadPool.BindHandle も同じ役割で残っていますが、新しく書くならこちらです)。FileStreamSocket が非同期モードのハンドルを開くと、内部でこの種の結び付けが行われます。5 つまり:

  • 第2回の「非同期モードのハンドル+OVERLAPPED」が発行の仕組み
  • 本記事のIOCPが完了を受ける仕組み
  • .NETスレッドプールのI/O完了スレッドが、GetQueuedCompletionStatus ループを回すワーカーチーム

という対応で、Win32の絵がそのまま.NETの絵になります。

5.1. await ReadAsyncの一往復・完全版

第2回の図7で「本物の非同期I/O」とだけ書いた箱の中身を、今回は最後まで開けます。

継続の実行場所I/O完了スレッドスレッドプールのIOCPカーネル(IRP発行〜完了)呼び出しスレッド(UIスレッドなど)継続の実行場所I/O完了スレッドスレッドプールのIOCPカーネル(IRP発行〜完了)呼び出しスレッド(UIスレッドなど)awaitが未完了Taskに継続を登録しスレッドを手放す(UIなら次のメッセージ処理へ)デバイスが仕事中この間、待っているスレッドはどこにもいない結果(バイト数・状態)を確定しTaskを完了させ、継続をスケジュールawaitの続きのコードが走るReadAsyncが非同期読み取りを発行(OVERLAPPED相当を添えて)ERROR_IO_PENDING(すぐ戻る)完了パケットを積むLIFOで1本起こして手渡し捕捉したコンテキストへ投げる(UIスレッドへ / なければスレッドプールで実行)

図6: await一往復の全体。スレッドが働くのは「発行」と「完了後」だけで、待ち時間はスレッドゼロ

5.2. 「I/O待ちはスレッドを消費しない」の正確な意味

この図で強調したいのは、発行から完了までの間、その完了を待つためだけのスレッドはユーザーモードにもカーネルにも存在しないことです。マイクロソフトのasync解説(async in depth)も、I/OバウンドのTaskについて「その完了を待つためだけのスレッドはどこにもいない」ことを、デバイスドライバーと割り込みまで降りて説明しています。6 なお、カーネルの中でドライバーが処理の一部をシステムのワーカースレッドに委ねる場面はあります。ただしそれは要求を前へ進めるための短い仕事であって、完了をブロックして待ち続けるスレッドはどこにもいない──ここが保証されている範囲です。

第1回からの積み上げで言い換えると──IRPはスレッドではなくデータ構造としてデバイススタックに滞在し(第1回)、発行は ERROR_IO_PENDING ですぐ戻り(第2回)、完了は割り込み→完了パケットというイベントの連鎖で届く(本記事)。「待つ」という状態を維持するのに、スレッドという高価な資源が要らない作りになっているわけです。

だから、async/await を正しく使ったアプリは「同時に10,000件のI/Oが飛んでいる」状態を、スレッド十数本で維持できます。逆に言えば、この性質はI/OバウンドのTaskだけのものです。Task.Run で包んだCPU処理は当然ワーカースレッドを1本占有しますし、第2回7章の「見せかけの非同期」も裏でスレッドを眠らせています。

5.3. 継続はどこで走るか

図6の最後の矢印──「継続をどこに投げるか」には明確なルールがあります。6

はいいいえはい (WPF/WinFormsのUIスレッドなど)いいえ (コンソール/ASP.NET Coreなど)Taskが完了し、継続を実行したいawaitした時点でSynchronizationContextか非既定のTaskSchedulerを捕捉していたかConfigureAwait(false)を付けていたか捕捉した先へ投げ返す例: UIスレッドのメッセージループやそのTaskSchedulerの上で実行特定の場所へ戻す義務なし完了させたスレッドで同期的に続くかスレッドプールのスレッドで実行

図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実務判断表」を参照してください。

5.4. 詰まりの正体 ── スレッドプール飢餓

最後に、この地下室が詰まるパターンを1つだけ。継続やワーカーの中で同期的に待つ(同期I/O、Task.Result/Wait()、長いロック待ち)と、そのスレッドは塞がったままになります。IOCP自身の補充(3.4節)は、待機中の予備スレッドがいる限り即座に効きます。しかし予備が尽きた先は、スレッドプールが新しいスレッドをゆっくりとしか注入しない領域です。負荷がかかった瞬間に「継続を実行したいのに実行するスレッドがない」飢餓(starvation)が起き、アプリ全体がもたつきます。

調査の入口は2つです。ThreadPool.GetAvailableThreads でワーカー/I/O完了スレッドの空きを見ること。4 そしてイベントトレースでスレッドプールとブロックの実態を掴むこと──手順は「PerfViewとdotnet-traceで「遅い」を特定する」にまとめています。予防はシンプルで、async の道はasyncのまま最後まで(sync-over-asyncを混ぜない)、これに尽きます。

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、そして「書いたはずのデータが電源断で消える」条件──に正面から向き合います。

関連記事

関連する相談領域

合同会社小村ソフトでは、多数の同時接続・同時I/Oを扱うWindowsアプリ/サーバーの設計と、「スレッドプールが詰まる」「非同期化したのに速くならない」といった性能問題の原因調査を扱っています。

参考リンク

  1. 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

  2. Microsoft Learn, CreateIoCompletionPort function. CreateIoCompletionPortがI/O完了ポートの新規作成と、既存ポートへのハンドルの関連付けの両方を行うこと、関連付け時にCompletionKey(ユーザー定義の値)を指定でき完了パケットに含まれること、NumberOfConcurrentThreadsが完了パケットを並行処理できるスレッド数の上限で、0を指定するとシステムのプロセッサ数が使われることについて。  2 3 4 5

  3. Microsoft Learn, PostQueuedCompletionStatus function. PostQueuedCompletionStatusが非同期I/Oを開始することなく、アプリケーション独自の完了パケットをI/O完了ポートのキューに積めること、これによりポートがI/O完了の受信に加えてプロセス内の他スレッドからの通信の受け口としても使えることについて。  2 3

  4. 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

  6. Microsoft Learn, Async in depth (.NET). I/OバウンドのTaskでは、呼び出しがOSに渡った後にその完了を待つための専用スレッドが存在しないこと(いわゆる「There is no thread」)、デバイスドライバーと割り込みを経て完了が通知され、登録されていた継続が実行されること、awaitが既定で現在のコンテキスト(SynchronizationContext等)を捕捉して継続をそこで実行し、捕捉すべきコンテキストがない場合はスレッドプールで実行されること、ConfigureAwait(false)でこの捕捉を無効化できることについて。  2 3 4 5 6

  7. Microsoft Learn, GetQueuedCompletionStatus function. GetQueuedCompletionStatusが完了ポートのキューから完了パケットを1つ取り出す(なければ待つ)こと、取り出した結果として転送バイト数・CompletionKey・OVERLAPPEDポインターが得られること、そして戻り値がFALSEでもOVERLAPPEDポインターが非NULLの場合は「失敗したI/O操作の完了パケットを取り出した」ことを意味し、OVERLAPPEDがNULLの場合のみ(タイムアウトなどで)パケットを取り出せなかったことを意味することについて。  2 3 4

  8. Microsoft Learn, GetQueuedCompletionStatusEx function. GetQueuedCompletionStatusExが複数の完了パケットを一度に取り出せること、取り出したエントリ数が返ることについて。  2

  9. Microsoft Learn, SetFileCompletionNotificationModes function. FILE_SKIP_COMPLETION_PORT_ON_SUCCESSにより、I/Oが即時に成功して結果がその場で確定した場合に完了ポートへパケットを積まない動作を選べることについて。 

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

Windows I/Oの深層(第6回・最終回) ── フィルタードライバーとミニフィルター:ProcmonとウイルススキャンがI/Oに割り込める理由

Windowsのフィルタードライバーとミニフィルターを図解で解説する連載の最終回です。フィルターマネージャーとアルティチュード、pre/postコールバック、Procmonやウイルス対策が全I/Oを検査できる仕組み、「あの環境だけ遅い」の調査手順まで整理します。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

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完了スレッドの空きを観測できるので、詰まりの調査に使えます。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る