I/O完了ポート(IOCP)
多数の非同期I/Oの完了通知を1つのFIFOキューに集約しつつ、実行可能なスレッド数をコンカレンシー値以下に保つスケジューリング機構を兼ねる、Windowsカーネルの仕組み。
.NETスレッドプール
.NETランタイムが提供する、ワーカースレッドとI/O完了スレッドの2階建てのスレッドプール。非同期I/Oのハンドルは内部で自前のI/O完了ポートに結び付けられる。
CompletionKey
ハンドルをI/O完了ポートへ関連付ける際に指定する、ユーザー定義の値。完了パケットに含まれて返り、「どのハンドルからの完了か」をワーカーへ伝える。接続オブジェクトのポインターを入れるのが定石。
CreateIoCompletionPort
I/O完了ポートの新規作成と、既存ポートへのハンドルの関連付けの両方を行うWin32 API。NumberOfConcurrentThreadsでコンカレンシー値を指定する。
GetQueuedCompletionStatus
I/O完了ポートのキューから完了パケットを1つ取り出すWin32 API。戻り値がFALSEでもOVERLAPPEDポインターが非NULLなら、失敗したI/Oの完了パケットを取り出せたことを意味する。
OVERLAPPED構造体
発行中の非同期I/O操作1つを識別し、その状態(読み書き位置・完了イベント・転送バイト数)を運ぶデータ構造。同時に発行する操作の数だけ必要で、使い回すとデータ破損につながる。
GetQueuedCompletionStatusEx
複数の完了パケットを一度にまとめて取り出せるGetQueuedCompletionStatusの拡張版。高頻度I/Oで呼び出し回数を減らせる。
PostQueuedCompletionStatus
非同期I/Oを開始せずに、アプリケーション独自の完了パケットをI/O完了ポートのキューへ積めるWin32 API。ワーカーへの仕事依頼やシャットダウン指示に使う。
コンカレンシー値
I/O完了ポートに関連付いた「実行可能なスレッド数」の上限。CreateIoCompletionPortのNumberOfConcurrentThreadsで指定し、0ならシステムのプロセッサ数が使われる。全体としての最良の最大値はCPU数とされる。
Windowsスレッドプールの I/O API
内部でIOCPを使いながらスレッドの生成・管理を肩代わりする、新規のサーバーアプリケーション向けに推奨されるAPI群。
Overlapped I/O
FILE_FLAG_OVERLAPPEDを付けて開いたハンドルに対し、操作ごとのOVERLAPPED構造体を渡して行うWin32の非同期I/Oの具体的な実装形態。モードはハンドル単位でCreateFileの時点に決まる。
オーバーラップドI/O
非同期I/O(Win32)
ワーカースレッド(.NET)
.NETスレッドプールが提供する、Task.Runや継続の実行を担うスレッド群。I/O完了スレッドとは別に管理される。
I/O完了スレッド(.NET)
.NETスレッドプールが提供する、非同期I/Oの完了を受け取って継続をスケジュールするスレッド群。ワーカースレッドとは別に管理される。
ThreadPoolBoundHandle.BindHandle
OSのハンドルをシステムスレッドプール(のI/O完了ポート)に結び付ける.NETの低レベルAPI。束縛したハンドルへの非同期I/OはNativeOverlappedと組み合わせて行う。
NativeOverlapped
Win32のOVERLAPPED構造体を.NET側で表現した構造体。ThreadPoolBoundHandleに束縛したハンドルへの低レベルの非同期I/Oで使う。
FileOptions.Asynchronous
.NETのFileStreamコンストラクターやFile.OpenHandleで、OSレベルの非同期I/O(FILE_FLAG_OVERLAPPED)を有効にする指定。ハンドルのモードを直接決める。
非同期I/O
要求を発行した呼び出しがI/Oの完了を待たずにすぐ戻り、完了を別途の通知(イベント・APC・I/O完了ポートなど)で受け取るI/Oの形態。
オーバーラップI/O
SynchronizationContext
awaitした時点の実行コンテキストを表す.NETのクラス。捕捉されると、非同期処理完了後の継続がそのコンテキストへ投げ返される(例: WPF/WinFormsのUIスレッド)。
同期待ちの混入(sync-over-async)
非同期処理の継続やワーカーの中で、同期I/OやTask.Result/Wait()のような同期的な待ちを行ってしまうアンチパターン。スレッドプール飢餓の主因になる。
スレッドプール飢餓(starvation)
同期待ちの混入などでスレッドプールのスレッドが塞がり続け、負荷がかかった瞬間に「継続を実行したいのに実行するスレッドがない」状態に陥ること。
Task.Run
CPUを使う処理を.NETスレッドプールのワーカースレッドへ明示的にオフロードするためのAPI。I/OバウンドのTaskに使う必要はない。
LIFOスレッド解放
I/O完了ポートで待機しているスレッドを、直前まで働いていたスレッドから優先して起こす設計。FIFOで積まれるキューと対照的で、コンテキストスイッチとキャッシュミスを減らす。
ThreadPool.GetAvailableThreads
ワーカースレッドとI/O完了スレッドの利用可能数を別々に取得できる.NETのAPI。スレッドプールの詰まりを調査する際の入口になる。
ERROR_OPERATION_ABORTED
CancelIoEx等で取り消された操作が、完了として返ってくる際のエラーコード。この通知を受け取るまでOVERLAPPEDとバッファを解放してはならない。
見せかけの非同期
同期モードのハンドルに対してReadAsync/WriteAsyncを呼んだ結果生じる状態。呼び出し元はブロックされないが、裏でスレッドプールのスレッドが同期読み書きを肩代わりして待つ。
ハンドルをポートへ関連付けるときに指定するCompletionKeyが、完了パケットに含まれて「どのハンドルからの完了か」をワーカーへ伝える
CreateIoCompletionPortはポートの新規作成と、既存ポートへのハンドルの関連付けの両方を行う
ワーカースレッドはGetQueuedCompletionStatusを呼んでポートのキューから完了パケットを1つ取り出す
取り出した結果として、転送バイト数・CompletionKey・OVERLAPPEDポインターが得られる
戻り値がFALSEでもOVERLAPPEDポインターが非NULLなら、失敗したI/Oの完了パケットを取り出せたことを意味し、後始末が必要になる
複数の完了パケットを一度に取り出すGetQueuedCompletionStatusExは、I/O完了ポートを対象にした拡張版で、高頻度I/Oでの呼び出し回数を減らせる
PostQueuedCompletionStatusは、非同期I/Oを発行することなく自前の完了パケットをI/O完了ポートのキューに積める
自前のパケットにも任意のCompletionKeyを乗せられるので、ワーカーへの仕事依頼やシャットダウン指示を同じキュー・同じループで流せる
コンカレンシー値はCreateIoCompletionPortのNumberOfConcurrentThreadsで指定し、0を渡すとシステムのプロセッサ数が使われる
コンカレンシー値は「実行可能なスレッド数」の上限として働き、完了通知のキューとスレッド数制御を一体化する仕組みの核になる
CreateThreadpoolIo/StartThreadpoolIoは、内部でIOCPを使いながらスレッドの生成・管理を肩代わりする
新規のサーバーアプリケーションが非同期I/Oを扱うなら、生のIOCPよりまずWindowsスレッドプールAPIを検討すべきというのがドキュメントの立場
.NETスレッドプールは自前のI/O完了ポートを持ち、非同期I/Oのハンドルをそこに関連付ける
.NETスレッドプールは、Task.Runや継続を実行するワーカースレッドを提供する
.NETスレッドプールは、非同期I/Oの完了を受け取るためのI/O完了スレッドをワーカースレッドとは別に提供する
ThreadPoolBoundHandle.BindHandleは、OSのハンドルをシステムスレッドプール(のI/O完了ポート)に結び付ける
束縛したハンドルへの低レベルの非同期I/Oは、NativeOverlappedと組み合わせて行う
.NETのNativeOverlappedは、Win32のOVERLAPPED構造体を.NET側で表現したもの
FileStreamやSocketが非同期モードのハンドルを開くと、内部でThreadPoolBoundHandle相当の結び付けが行われる
I/O完了スレッドは、GetQueuedCompletionStatusループに相当する仕組みでポートから完了パケットを取り出す
UIスレッドでawaitした非同期I/Oでは、継続の実行先としてその時点のSynchronizationContextが捕捉される
継続やワーカーの中で同期I/OやTask.Result/Wait()のような同期待ちをすると、そのスレッドが塞がったままになり、負荷がかかった瞬間にスレッドプールの飢餓を招く
asyncの道はasyncのまま最後まで通すのが原則で、I/O完了スレッドや継続の中に同期待ちを混ぜるべきではない
Task.Runで包んだCPUバウンドの処理は、ワーカースレッドを1本占有して実行される
完了パケットはFIFOで積まれるが、待っているスレッドはLIFOで起こされるため、直前まで働いていた「温まった」スレッドが次のパケットも拾い、コンテキストスイッチがほぼ起きない
ThreadPool.GetAvailableThreadsで、ワーカースレッドとI/O完了スレッドの利用可能数を別々に観測できる
詰まりの調査の入口として、まずワーカー/I/O完了スレッドの空きをThreadPool.GetAvailableThreadsで見る
取り消された操作の完了パケットも通常どおりキューに積まれ、GetQueuedCompletionStatusはそれをERROR_OPERATION_ABORTEDを伴う失敗パケットとして返す
I/O完了スレッドは非同期I/Oの完了を受けて、結果を確定させ登録されていた継続をスケジュールする
見せかけの非同期では、呼び出し元はブロックされない代わりに、ワーカースレッドの1本が同期読み書きを肩代わりして待つ
ハンドルの関連付け時に指定するCompletionKeyはユーザー定義の値で、完了パケットに含まれて返る(接続オブジェクトへのポインターを入れるのが定石)
カーネルのI/O完了を受け取る使い方の場合、I/O完了ポートに関連付けられるのはオーバーラップI/Oを話せるファイル・ソケット・名前付きパイプなどのハンドルに限られる。ハンドルを1つも関連付けず、PostQueuedCompletionStatusで自前の完了パケットだけを流すワークキューとして使うなら、オーバーラップI/Oは介さない。