hozugawa.net · A neutral informational page

ReadFileとWriteFileによる非同期I/Oプログラミング

Windows APIによるファイルアクセスでは、I/O処理を同期的に行うか非同期に行うかによって、プログラムの構造が大きく変わります。ReadFileやWriteFileを単純に同期呼び出しする方法は理解しやすい反面、大容量ファイルの転送や多数のパイプ通信ではスレッドが長時間ブロックされてしまいます。UIを持つプロセスでは応答不能に陥り、バッチ処理ではスループットが頭打ちになる典型的な症状です。

こうした課題を解決するのが、Windowsが提供する非同期I/O機構です。OVERLAPPED構造体をReadFileとWriteFileに引き渡すと、関数自体はI/O要求を登録した直後に制御を返し、実際の読み書きはバックグラウンドのカーネル側で進行します。本記事では、その基本となる仕組みから運用上の注意点までを一通り整理します。

同期I/Oと非同期I/Oの動作モデルの違い

同期I/Oの代表格であるReadFile関数は、デバイスドライバからの応答を受け取るまで呼び出しスレッドを待機させます。少量のファイル読み出しや小さなログ追記であれば、この単純さが実装上の利点になります。一方で、ネットワーク経由のファイル共有や、大容量のストリームを別スレッドで受け取るケースでは、待機中のスレッドがCPUサイクルを浪費し、同時実行数にも上限が生じます。

非同期I/Oでは、同じReadFile関数をOVERLAPPED構造体付きで呼び出すことで、カーネル内部にI/Oリクエストをキューイングしただけで関数が返ります。呼び出し側のスレッドは次の処理に進めるため、ロジックのレイテンシを保ったまま複数のファイルを並行して扱えます。代償として、ユーザーが用意したバッファがI/O完了まで生存しなければならないという責任が発生し、メモリ管理とエラー処理を疎かにすると未定義の動作につながります。

OVERLAPPED構造体の準備とメモリ確保

非同期I/Oを実装する最初のステップは、I/O要求ごとに一つずつOVERLAPPED構造体を確保し、内部メンバの初期化を行う作業です。構造体の先頭メンバであるInternalとInternalHighはOSが書き込み、OffsetとOffsetHighは読み書きの開始位置を指定するために使います。hEventフィールドには、CreateEventで作成したイベントハンドルを入れるか、ハンドル無効化を意味する特殊な定数を指定します。

メモリ管理には特に気を配る必要があります。OVERLAPPED構造体はI/Oが完了するまで生存していなければならず、スタック上に配置して関数のスコープを抜けた瞬間に不正アクセスになる事故は、誰もが一度は踏みやすい落とし穴です。ヒープに置くか、またはI/Oごとにバッファと一緒に構造体も確保する設計が安全とされています。確保方針の選定についてはHeapAllocとLocalAllocのweiと選択基準で詳しく扱われているので、併せて確認すると判断材料が増えます。

ハンドルと構造体の対応関係を配列やリストで一元管理しておくと、後段のクリーンアップやCancelIoの呼び出しで迷うことが少なくなります。CreateFileで取得したハンドルが閉じられた後も内部でOVERLAPPEDを参照し続けるとクラッシュするため、ライフサイクルを最初から明確にしておくことが大切です。

ReadFileとWriteFileの非同期呼び出し

実際にReadFileを非同期モードで呼び出すには、第七引数にOVERLAPPED構造体へのポインタを渡します。CreateFileでハンドルを取得した際のdwFlagsAndAttributesにFILE_FLAG_OVERLAPPEDを含めておかないと、構造体を渡しても内部的に同期動作として処理されてしまうので、最初に設定する段階で必ず指定してください。

関数がTRUEを返しても、それはドライバが要求を受け付けたことを意味するだけです。FALSEを返したとしてもエラーではなくERROR_IO_PENDINGが立っていれば「非同期処理中」を示しており、正常な進行です。WriteFileについても同じ動作モデルが成り立ち、パイプやソケットのように大量データを流す場面で真価を発揮します。

両者の挙動を整理すると、次のようになります。

比較項目 同期モード 非同期モード
関数呼び出し後の状態 完了までスレッドを保持 制御を即座に呼び出し側へ
エラーコードの解釈 直前のGetLastErrorを参照 ERROR_IO_PENDINGは正常
バッファ管理 関数終了後に解放して安全 完了通知まで保持が必須
内部フラグ指定 不要 FILE_FLAG_OVERLAPPEDが必要
パフォーマンス特性 単純だがブロックが発生 高スループットを実現しやすい

シーケンシャルな並列書き込みなど、同じハンドルに対して複数のOVERLAPPED要求を同時に発行する場合は、独自に位置情報や順序情報を管理し、書き込みが重なっても破綻しないよう注意深く設計してください。

完了待機と同期オブジェクトの使い分け

非同期I/Oの完了を知る手段は、要件に応じて段階的に存在します。最も単純なのはOVERLAPPED構造体のhEventにCreateEventで生成したハンドルを割り当て、WaitForSingleObjectでブロックする方法です。ワーカースレッドを一本だけ立てて順番にリクエストを処理するモデルでは、この素朴な実装で十分なケースも多くあります。

複数I/Oを同時に待ちたい場合は、WaitForMultipleObjectsでnMaxHandlesの制限を意識しながら実装するか、もっと本気のスループットを求めるならI/O完了ポートに切り替えます。完了ポートを使うと、完了通知がワーカースレッドのキューに積まれ、GetQueuedCompletionStatusで取得した情報から転送済みバイト数や完了キーを辿って処理を分岐できます。

GetOverlappedResultはbWaitにTRUEを渡せば完了まで待機するWait系のラッパーとして使え、FALSEを渡しせば完了したかを問い合わせるPollとして使えます。ファイル読み込みの進行をUIに進捗バーで表示したい場合など、ブロックせずに状態を取りたい場面で重宝します。読み書きの完了キーをルール化しておけば、異なるハンドルからの応答が混じっても識別しやすくなります。

実装時のトラブルとデバッグの観点

非同期I/Oでは、エラーコードの意味が直感と食い違うケースが頻出します。ERROR_IO_PENDINGをそのまま異常扱いにして例外を投げてしまうプログラムや、CancelIoExで中断されたI/OをERROR_OPERATION_ABORTEDで一括に異常終了扱いしてしまう実装は、実際に動作する環境でも警告なく動いてしまうため気付きにくいものです。

ファイルの選択から読み込みまでを一連の流れで扱う場合、GetOpenFileNameでの実装例ではダイアログ初期化から非同期読み込みへの接続部分が参考になります。バッファがI/O中に解放されてしまった場合はアクセス違反でクラッシュするため、ベクタやスマートポインタでの保持期間管理をパターン化しておくのがおすすめです。

本番運用を見据えるなら、I/O失敗時の再試行ロジックや、シャットダウン時の安全なキャンセル処理、そしてタイムアウトの設計までを最初の段階で含めておくのが、後々の保守負荷を下げます。デバッグ時にはProcess Explorerで保留中のI/O件数を確認する、ETWでイベント順序を記録するといった方法を併用してください。

非同期I/Oへの移行を一気に進めるよりも、まずは同期版で動く最小コードを用意し、そこにOVERLAPPEDとイベントを追加して段階的に非同期化する手順が最も確実です。次のステップとしては、ローカルファイルだけでなく名前付きパイプで同じコードが動作するかを確認し、複数同時I/Oを扱う拡張へと進むのが安定します。