CreateIoCompletionPortでI/O完了ポートを実装する手順と要点
Windowsで高負荷なネットワークサーバーやファイルI/Oを扱うプログラムを書くとき、スレッドの生成とコンテキスト切り替えがボトルネックになる場面があります。I/O完了ポート(IOCP)は、カーネル側でスレッドプールを管理し、完了した入出力だけを効率よくワーカースレッドへ配信する仕組みです。
CreateIoCompletionPortは、このIOCPを作成したり、既存のIOCPにデバイスハンドルやソケットを関連付けたりするWin32 APIです。サーバーアプリケーションでは事実上必須とも言える機能ですが、二つの異なる呼び出し方をするため、初めて触れると混乱しやすい関数でもあります。
I/O完了ポートの役割と他の非同期方式との違い
I/O完了ポートは、Windows NT系カーネルが提供する同期プリミティブの一つです。名前の通り、ポートに「入出力が終わった通知」が溜まり、それを専用の待機関数が取り出します。待機中のスレッドはカーネルが適切に休止させるため、多数の接続を少数のスレッドで処理できます。
これに対し、イベントオブジェクトやWSAAsyncSelectでは、ソケット単位やハンドル単位で待機オブジェクトを作るため、接続数が増えるとカーネルオブジェクトとメモリの消費が比例して増えます。下表に主な非同期I/O方式の特徴を整理します。
| 方式 | スケーラビリティ | 通知の粒度 | スレッド管理の負担 |
|---|---|---|---|
| 同期I/O(ブロッキング) | 低い | スレッド単位 | 高い |
| WSAAsyncSelect | 中程度 | ソケット単位 | 中程度 |
| WSAEventSelect | 中程度 | ソケット単位 | 中程度 |
| I/O完了ポート | 高い | I/O要求単位 | 低い |
Windowsサーバー向けの高性能な実装は、ほぼ例外なくIOCPを採用しています。
CreateIoCompletionPort関数の二つの使い方
CreateIoCompletionPortは呼び出し方によってまったく異なる動作をします。最初の引数にINVALID_HANDLE_VALUEを指定すると、新しいIOCPを作成し、そのハンドルを返します。NULL以外を指定すると、既存のIOCPにデバイスやソケットを関連付ける動作に切り替わります。
この二つのモードを使い分けるため、引数の意味も呼び出しごとに変わります。
- 第一引数:関連付けるハンドル(IOCP作成時はINVALID_HANDLE_VALUE)
- 第二引数:既存のIOCPハンドル(作成時は無視される)
- 第三引数:I/O完了時に取得したい任意の32ビット値
- 第四引数:同時にアクティブにできるワーカースレッド数の上限
ソケットやファイルをIOCPで利用する場合は、まずIOCPを作成した直後に関連付け、その後ReadFileやWSARecvなどのオーバーラップドI/Oを発行します。
ソケットとファイルの関連付け手順
ネットワークプログラミングでは、acceptで受け取ったソケットをすぐにCreateIoCompletionPortに渡してIOCPに紐付けます。ここで第三引数に渡す値は、I/Oが完了したときにGetQueuedCompletionStatusで取得できる任意の識別子です。ソケットごと、あるいはセッション構造体へのポインタを格納しておくと、完了通知の処理が大幅に楽になります。
ファイルI/Oでも手順は同じです。CreateFileで開いたハンドルをCreateIoCompletionPortに関連付け、ReadFileやWriteFileにOVERLAPPED構造体を渡して非同期読み書きを開始します。OVERLAPPED構造体のOffsetやOffsetHighフィールドは、ファイル位置を追跡するために必要です。
関連付けの時点で第三引数を渡せるのは、同時に関連付けたオーバーラップドI/O要求を追跡しやすい設計になっているからです。こうしたWin32 APIの全体像はhozugawa.netで俯瞰できます。
GetQueuedCompletionStatusでの完了待機
ワーカースレッド側は、GetQueuedCompletionStatusを呼び出してIOCPからの完了通知を待ちます。この関数は、内部的にカーネルオブジェクトとして登録されているIOCPから次の完了パケットを取り出すまでスレッドをスリープさせます。
返り値がTRUEでCompletionKeyとlpNumberOfBytesTransferredおよびlpOverlappedが埋まっていれば正常なI/O完了です。FALSEが返った場合はエラーですが、ERROR_OPERATION_ABORTEDやERROR_NETNAME_DELETEDのような特定のエラーコードは、クライアント切断による正常系として扱うことが多いです。
ループ構造は一般に次のような骨格になります。スレッド一つがIOCPを監視し続け、終了フラグが立ったらスレッドを解放する形です。
PostQueuedCompletionStatusによる通知の挿入
PostQueuedCompletionStatusを使うと、IOCPに任意の完了パケットを「偽装して」挿入できます。GetQueuedCompletionStatusで受け取った側でlpOverlappedがNULLかどうかを確認し、内部メッセージと外部メッセージを見分けます。
この仕組みは、シャットダウンシグナルの伝達や、内部キューへの作業投入に大変便利です。サーバーの停止時には、すべてのワーカースレッドに対して終了パケットを一つずつポストすることで、ループから抜けさせてきれいに終了できます。
同時実行スレッド数とパフォーマンス調整
CreateIoCompletionPortの第四引数で指定する同時実行スレッド数は、システム全体の挙動に大きく影響します。一般にはCPUコア数と同じか、やや少ない値が良いとされています。あまり大きくするとコンテキストスイッチが頻発し、小さすぎるとI/Oの完了が遅れます。
ワーカースレッド自体の数は、コア数より多めに起動しておくのが定石です。GetQueuedCompletionStatusがカーネル内でブロックしているスレッドの数が、指定した同時実行スレッド数を超えないようにカーネルが自動的に調整してくれます。これにより、I/O待ちでCPUが遊ばない設計になります。
実装時には以下のポイントに留意してください。
- 関連付け時に渡すCompletionKeyに、セッション構造体のポインタを直接格納する
- OVERLAPPED構造体は事前にプール化し、動的確保を避ける
- ソケットクローズ時はCancelIoまたはclosesocketで、ERROR_OPERATION_ABORTEDを発生させる
- シャットダウン時にはPostQueuedCompletionStatusですべてのワーカーを起こす
- ログ出力は非同期キューを介し、I/Oスレッドを長時間止めない
メモリプールやセッション管理も併せて設計すると、数千コネクション規模でも安定して動作します。関連APIの設計パターンを理解するうえで、TextOutとDrawTextの使い分けも参考になります。実装後の最初の確認作業としては、ローカル環境で1万程度の同時接続を模した負荷試験ツールを作成し、GetQueuedCompletionStatusの呼び出し頻度とワーカースレッドのCPU時間を観察することです。