前回(第1回)は、WindowsのI/O要求がIRPというパケットになってデバイススタックを流れること、そして発行と完了はカーネルの底では最初から分離していることを見ました。
今回はその分離を、アプリケーション側から使う仕組み──非同期I/O(オーバーラップI/O)──を掘ります。FILE_FLAG_OVERLAPPED を付けたのに同期で返ってくる。OVERLAPPED を使い回したらデータが壊れた。CancelIoEx を呼んだのに止まらない。キャンセルしたらアクセス違反で落ちた──このあたりの「非同期I/Oの怖い話」は、すべて仕組みを1枚の絵として持っていないことから来ます。この記事で、その絵を作ります。
連載「Windows I/Oの深層」の第2回です。全体の構成は第1回の冒頭に置いています。
1. まず結論
- 同期I/Oと非同期I/Oの分かれ目は、カーネルではなく「待つかどうか」です。同期I/Oの保証は「完了する前には戻らない」こと。要求がペンディングになったときだけI/Oマネージャーが完了を待ち、スレッドはCPUを使わない待機状態で眠ります(2章)。1
FILE_FLAG_OVERLAPPEDはハンドル(ファイルオブジェクト)のモードです。CreateFileの時点で決まり、呼び出しごとに切り替えられません。非同期ハンドルではシステムがファイルポインターを管理しないので、ディスク上のファイルでは毎回OVERLAPPEDのOffsetで位置を指定します(3章)。21OVERLAPPED構造体は「操作1つぶんの伝票」です。発行中の操作の数だけ必要で、共有・使い回しはデータ破損につながると公式ドキュメントが明記しています。完了までは構造体もバッファも触ってはいけません(3章)。34- 完了の受け取り方は実質4つ。ハンドルのシグナル(非推奨)、
OVERLAPPEDのイベント+GetOverlappedResult、APC(alertable wait)、そしてI/O完了ポート(次回)です(4章)。156 - 非同期発行しても同期完了することがあります。キャッシュに載っている、NTFS圧縮/暗号化、ファイルを伸ばす書き込み──が代表例です。「非同期=絶対にブロックしない」ではありません(5章)。3
- キャンセルは「依頼」です。
CancelIoExを呼んでも、操作はERROR_OPERATION_ABORTEDでの完了として返ってきます。その完了を見届けるまで、後始末をしてはいけません(6章)。78 - .NETの
FileOptions.Asynchronousはこのモードの直通スイッチです。ハンドルのモードと呼び出すAPIが食い違うと、スレッドプールで肩代わりする「見せかけの非同期」や無駄な待ち合わせが発生します(7章)。910
2. 同期I/O ── スレッドはどこで眠るのか
まず既定の姿から。FILE_FLAG_OVERLAPPED を付けずに開いたハンドルは同期モードです。ReadFile を呼ぶと、I/Oが完了するまで関数は戻りません。1
第1回で見たとおり、ドライバーは要求を保留(ペンディング)にしてハードウェアの応答を待ちます。では同期I/Oのとき、誰が待っているのか。I/Oマネージャーが、完了を待ってからアプリに制御を返しています。
sequenceDiagram
participant App as アプリのスレッド
participant IOM as I/Oマネージャー
participant DRV as ドライバー(スタック)
App->>IOM: ReadFile(同期ハンドル)
IOM->>DRV: IRPを発行
DRV-->>IOM: STATUS_PENDING(応答待ち)
Note over App,IOM: スレッドはカーネル内で待機状態になり<br/>CPUを消費せずに眠る
DRV->>IOM: 完了(IoCompleteRequest)
IOM-->>App: 結果を返して起床<br/>ReadFileがTRUE/FALSEで戻る
図1: 同期I/O(要求がペンディングになった場合)。I/Oマネージャーが完了を待ち合わせてからアプリに戻る
なお、この図は要求がペンディングになった場合です。ドライバーがその場で完了できる要求(キャッシュヒットなど。第1回・図5の「即完了」の道)なら、待ちはどこにも発生せず、そのまま結果を持って戻ります。同期I/Oの保証は「完了する前には戻らない」ことであって、「必ず眠る」ことではありません。
押さえておきたいのは2点です。
- 「待つ」はCPUを食いません。待機状態のスレッドはスケジューラーの実行対象から外れます。ポーリングで自分の首を絞めるくらいなら待たせたほうがよい、という話は「WindowsでSleep(1)よりイベント待機を優先すべき理由」で書きました。
- 同期モードのハンドルでは、カーネルがファイルポインター(現在位置)を管理します。だから連続した
ReadFileが「続きから」読めます。この状態はハンドルではなくファイルオブジェクトにあるので、DuplicateHandleで複製したハンドルとは位置を共有します(第1回3.3節)。
同期I/Oの弱点は、待っている間そのスレッドが他の仕事をできないことに尽きます。UIスレッドで同期I/Oをすれば画面が固まり、サーバーで接続ごとにスレッドを立てれば数百接続でスレッドだらけになります。なお、同期I/Oで固まっている別スレッドを外から救出するための CancelSynchronousIo というAPIも存在します(6章)。11
3. 非同期I/O ── ハンドルのモードと操作の伝票
3.1. モードはハンドル単位で決まる
CreateFile に FILE_FLAG_OVERLAPPED を渡すと、そのハンドルの裏のファイルオブジェクトが非同期モードで開かれます。1 ここで大事なのは、これがハンドル単位の性質だということです。「この呼び出しだけ非同期で」はできません。同じファイルを同期用・非同期用の2つのハンドルで開くことはできます(ファイルオブジェクトが2つできるだけです)。
非同期モードのハンドルには、もうひとつ重大な違いがあります。システムがファイルポインターを管理しません。2 複数の操作が同時に飛んでいる状態で「現在位置」に意味がないからです。ディスク上のファイルのように位置を持つデバイスでは、読み書きの位置を毎回 OVERLAPPED 構造体の Offset/OffsetHigh で明示します。一方、シリアルポートや名前付きパイプのようにシーク位置という概念がないデバイスでは、Offset は位置指定として使われません(0にしておきます)。その場合でも、次節で見るとおり OVERLAPPED 構造体そのものは操作1つごとに必要です。
3.2. OVERLAPPEDは「操作1つぶんの伝票」
OVERLAPPED 構造体の役割は、発行中の操作1つを識別し、その状態を運ぶことです。4
| メンバー | 役割 |
|---|---|
Offset / OffsetHigh |
この操作が読み書きするファイル上の位置(発行時に指定。位置を持たないデバイスでは未使用) |
hEvent |
完了時にシグナルされるイベント(任意。手動リセット推奨) |
Internal |
操作の状態。完了前は STATUS_PENDING 相当が入る(システム用) |
InternalHigh |
完了時の転送バイト数(システム用) |
flowchart LR
subgraph H["ハンドル(ファイルオブジェクト) = モード"]
M1["同期モード<br/>カーネルが現在位置を管理<br/>完了までReadFileが戻らない"]
M2["非同期モード(FILE_FLAG_OVERLAPPED)<br/>現在位置は管理されない<br/>発行と完了が分離する"]
end
subgraph O["OVERLAPPED構造体 = 操作1つぶんの伝票"]
F1["Offset: どこを読むか"]
F2["hEvent: 完了をどう知るか"]
F3["Internal/InternalHigh:<br/>状態と結果(システムが書く)"]
end
C["CreateFile時に一度だけ決まる"] --> H
R["ReadFile/WriteFileの発行のたびに1つ用意"] --> O
図2: モードはハンドルに、状態は操作(伝票)に。この分担を混同すると事故になる
ここから、公式ドキュメントが明記している2つの禁止事項が自然に出てきます。3
- 同時に飛んでいる操作の数だけ
OVERLAPPEDが必要です。3つ発行するなら3つ。使い回すと「予測不能な結果やデータ破損」につながります。 - 完了までは、
OVERLAPPEDもデータバッファも生かしておき、触ってはいけません。カーネルがその領域に書き込みに来るからです。ローカル変数のOVERLAPPEDで発行して関数を抜ける──は、スタックをカーネルに踏ませる典型的な事故です。
3.3. 発行の結果は3通りある
非同期ハンドルへの ReadFile は、3通りの返り方をします。2
flowchart TB
A["ReadFile(非同期ハンドル, OVERLAPPED付き)"]
Q{"戻り値は?"}
T["TRUE<br/>その場で完了した(同期完了)<br/>既定では完了通知も別途届く"]
P["FALSE + ERROR_IO_PENDING<br/>受理された。完了はあとから通知される"]
E["FALSE + その他のエラー<br/>発行自体が失敗した"]
W["完了通知を待つ<br/>(4章の4方式)"]
A --> Q
Q --> T
Q --> P
Q --> E
P --> W
図3: 非同期発行の3分岐。TRUE(即完了)とERROR_IO_PENDINGの両方を正しく扱って初めて非同期I/Oは動く
ERROR_IO_PENDING はエラーではなく「受理」です。ここを普通のエラーとして処理してしまう、逆に TRUE(同期完了)のケースを想定せずコードを書く──のが二大典型バグです。同期完了がなぜ起きるのかは5章で扱います。
そして、ここに大事な注意がひとつあります。既定では、同期完了(TRUE)した操作についても完了通知は別途届きます。I/O完了ポートに関連付けたハンドルなら完了パケットはキューに積まれ、イベント方式ならイベントもシグナルされます。したがって「TRUEならその場で結果を処理し、通知が来たらまた処理する」と書くと、同じ操作を二重処理して伝票を二重解放する事故になります。安全な基本形は、「TRUE(同期完了)」と「ERROR_IO_PENDING」の2つの経路で、結果の処理を通知側に一本化することです。ただし3つ目の経路──発行自体が失敗した場合(FALSE+その他のエラー)には完了通知は届きません。これを通知待ちに回すと永久に待つことになるので、発行元でその場でエラー処理と伝票の後始末を行います。「同期完了時は通知をスキップしてその場で処理」に切り替えたいときだけ、SetFileCompletionNotificationModes(FILE_SKIP_COMPLETION_PORT_ON_SUCCESS)を明示的に有効にします──ただしこれが抑止するのはI/O完了ポートへのパケットだけで、OVERLAPPED.hEvent のシグナルは抑止されません。イベント方式には使えない、IOCP経路専用の最適化です(5章)。12
なお、同期モードのハンドルに OVERLAPPED を渡した場合は、Offset の位置から読まれますが、完了までブロックする動きは変わりません。2 「OVERLAPPEDを渡したから非同期」ではないのです。モードはあくまでハンドルが持っています。
4. 完了をどう知るか ── 4つの通知経路
発行と完了が分離した以上、「完了した」をどう受け取るかが設計の中心になります。経路は実質4つです。1
flowchart TB
DONE["カーネルでI/Oが完了<br/>(IoCompleteRequest→APCで結果確定)"]
N1["(1) ファイルハンドルがシグナル状態になる<br/>受け取り: WaitForSingleObject(ハンドル)"]
N2["(2) OVERLAPPEDのhEventがシグナル状態になる<br/>受け取り: WaitForSingleObject + GetOverlappedResult"]
N3["(3) 完了ルーチンが発行スレッドのAPCキューに積まれる<br/>受け取り: SleepEx等のalertable wait中に実行"]
N4["(4) I/O完了ポートに完了パケットが入る<br/>受け取り: GetQueuedCompletionStatus(第3回)"]
DONE --> N1
DONE --> N2
DONE --> N3
DONE --> N4
図4: 完了通知の4経路。どれで受け取るかは発行方法(hEventの有無、ReadFileEx、ポート関連付け)で決まる
4.1. ハンドルのシグナル ── 使わない
hEvent を入れずに発行すると、完了時にファイルハンドル自体がシグナル状態になります。一見手軽ですが、同じハンドルで複数の操作が飛んでいると、どれが完了したのか区別できません。1 「非同期I/Oは1本ずつしか発行しない」という特殊な場合を除き、使わないのが安全です。
4.2. イベント + GetOverlappedResult ── 基本形
OVERLAPPED.hEvent に手動リセットイベントを入れて発行し、WaitForSingleObject(または WaitForMultipleObjects で複数同時)で待って、GetOverlappedResult で結果(成否と転送バイト数)を取り出します。13 GetOverlappedResult の bWait にTRUEを渡せば「完了まで待って取り出す」もできます。イベントを自動リセットにすると、別の待機がシグナルを消費した場合に GetOverlappedResult が固まる罠があるため、手動リセットが推奨です。134
数本の同時I/Oを堅実に扱う、いちばん見通しのよい方法です。シリアルポートのように「読みながら書く」が必須のデバイスでは、この形が今も現役です(「シリアル通信アプリの落とし穴」)。
4.3. APC ── 発行したスレッドに配達される
ReadFileEx/WriteFileEx は、イベントの代わりに完了ルーチン(コールバック)を受け取ります。完了すると、そのルーチンが発行したスレッドのAPCキューに積まれ、スレッドが SleepEx や WaitForSingleObjectEx などのalertable waitに入ったときに実行されます。14515
この方式の特徴は「完了処理が必ず発行スレッドで走る」ことです。ロックが要らなくなる半面、発行スレッドがalertable waitに入らない限り完了ルーチンは永遠に走りません。UIスレッドのメッセージループと組み合わせるには MsgWaitForMultipleObjectsEx が要るなど、待ち方の設計が難しく、汎用にはイベントかIOCPを選ぶことが多いでしょう。
4.4. I/O完了ポート ── スケールする本命(次回)
多数の同時I/Oを少数のスレッドで受けるための仕組みがI/O完了ポート(IOCP)です。ハンドルをポートに関連付けておくと、完了がポートのキューに入り、ワーカースレッドが GetQueuedCompletionStatus で取り出します。6 .NETのasync/awaitのI/Oが最終的に行き着く場所でもあります。次回まるごと1本使って掘ります。
| 方式 | 待つ場所 | 向いている場面 |
|---|---|---|
| ハンドルのシグナル | 任意のスレッド | ほぼなし(区別不能問題) |
| イベント + GetOverlappedResult | 任意のスレッド | 数本までの同時I/O。デバイス通信 |
| APC(ReadFileEx) | 発行スレッド(alertable) | 単一スレッドで完結させたい通信処理 |
| I/O完了ポート | ワーカースレッド群 | 多数の同時I/O。サーバー、スレッドプール |
5. 「非同期のはずが同期完了する」問題
非同期I/Oの設計で最初につまずくのがここです。非同期モードで正しく発行しても、I/Oが同期的に完了することは普通にあります。マイクロソフトはトラブルシューティング文書で、代表的な理由を明記しています。3
flowchart TB
A["非同期ハンドルにReadFile/WriteFileを発行"]
Q{"同期完了の条件に当たるか"}
C1["すぐ満たせる要求<br/>(データがキャッシュに載っている等)"]
C2["NTFS圧縮されたファイル<br/>(圧縮ファイルは非同期にならない)"]
C3["NTFS暗号化(EFS)されたファイル"]
C4["ファイルの長さを伸ばす書き込み"]
T["TRUEで即返る<br/>= 呼び出しの中で完了まで実行された"]
P["ERROR_IO_PENDINGで返る<br/>= 本当に非同期で進行中"]
A --> Q
Q --> C1
Q --> C2
Q --> C3
Q --> C4
C1 --> T
C2 --> T
C3 --> T
C4 --> T
Q -->|"どれでもない"| P
図5: 同期完了する主な条件。キャッシュ・圧縮・暗号化・拡張書き込みは「非同期にならない」
それぞれ理由があります。3
- キャッシュヒット。多くのドライバーは「すぐ完了できる要求はその場で完了させる」特別扱いを持っています。ディスクなら、データがメモリ上のキャッシュにあるときです。速いのだから文句はないはずですが、「必ずERROR_IO_PENDINGが返る」前提のコードはここで壊れます。
- 逆に、キャッシュに無いときも罠があります。Windowsのキャッシュはファイルマッピングで実装されており、ページが無いときのページフォールト処理に非同期の仕組みがないため、キャッシュ有効の非同期読み取りは同期的に処理されることがあります。キャッシュの仕組みそのものは第4回で扱います。
- NTFS圧縮・EFS暗号化。ファイルシステムドライバーが圧縮・暗号化ファイルへのアクセスを同期に変換します。
- ファイルを伸ばす書き込み。長さを変える書き込みは同期になります。
実務への含意はシンプルです。
- 「TRUEで即返る」経路を必ず書く。図3の3分岐はすべて正常系です。ただし既定では同期完了でも完了通知は別途届くので、結果の処理自体は通知経路に一本化しておくのが安全です(3.3節)。
- 応答性の保証には使えない。「非同期だからUIは固まらない」は成立しません。固まってはいけないスレッドからは、そもそもI/Oを発行しない設計(専用スレッドやスレッドプールへの分離)が必要です。このあたりの実務は「普通のWindowsでソフトリアルタイムをできるだけ実現するための実践ガイド」でも扱いました。
- 高頻度I/Oでは同期完了は最適化のチャンスでもあります。同期完了時にI/O完了ポートへのパケットを省く
SetFileCompletionNotificationModesというAPIがあり、IOCPと組み合わせて効きます(第3回)。12
6. キャンセルと後始末 ── 「止めてくれ」は依頼である
長時間かかるI/O(応答しないネットワーク先、来ないシリアルデータ)を止めたいときの正攻法が CancelIoEx です。7
sequenceDiagram
participant App as アプリ
participant IOM as I/Oマネージャー
participant DRV as ドライバー
App->>IOM: CancelIoEx(ハンドル, OVERLAPPED)
Note over IOM: 該当する未完了IRPに<br/>キャンセルを要求(マークする)
IOM->>DRV: キャンセルルーチン呼び出し
Note over DRV: 取り消せる状態なら中断<br/>完了間際なら正常完了することもある
DRV->>IOM: IoCompleteRequest<br/>(STATUS_CANCELLED)
IOM-->>App: 完了通知が届く<br/>GetOverlappedResultはERROR_OPERATION_ABORTED
Note over App: この通知を見届けてから<br/>OVERLAPPEDとバッファを解放する
図6: キャンセルの実際。取り消された操作も「完了」として返ってくる
仕組みを知っていれば当然の帰結が3つあります。
- キャンセルは非同期の「依頼」です。
CancelIoExが成功しても、それは「マークした」だけです。完了間際だった操作は正常完了することもあります。8 - 取り消された操作も、完了通知で
ERROR_OPERATION_ABORTEDとして返ってきます。その通知を受け取るまで、OVERLAPPEDとバッファはカーネルが使用中です。先に解放するとメモリ破壊になります。「キャンセルしたら落ちるようになった」の原因は、ほぼこれです。78 - ハンドルを閉じる前に、未完了I/Oを始末します。第1回で見たとおり、最後のハンドルが閉じるとcleanup処理で未完了IRPの取り消しが走りますが、「発行済みI/Oが残ったままハンドルだけ閉じる」コードは、完了通知とバッファ寿命の管理が破綻しがちです。キャンセル→完了を見届ける→閉じるの順が原則です。
補足として2つ。古い CancelIo は呼び出したスレッド自身が発行した操作しか取り消せません(Vistaで CancelIoEx が入るまでの制約で、いまあえて使う理由はありません)。16 また、同期I/Oで固まっている別スレッドには CancelSynchronousIo を使います。11 「タイムアウトはOSが面倒を見てくれない。キャンセルを自分で設計する」──これが非同期I/Oの実務の核心です。
7. .NETから見る ── モードの不一致が「見せかけの非同期」を生む
ここまでの話は、.NETのコードに直結します。FileStream のコンストラクターの useAsync(または FileOptions.Asynchronous)は、まさに FILE_FLAG_OVERLAPPED の直通スイッチです(第1回の対応表参照)。
flowchart TB
A["await fs.ReadAsync(...)"]
Q{"ハンドルは非同期モード<br/>(FileOptions.Asynchronous)か?"}
Y["本物の非同期I/O<br/>OVERLAPPED相当を発行し<br/>完了はIOCP経由でスレッドプールへ(第3回)"]
N["見せかけの非同期<br/>スレッドプールのスレッドが<br/>同期Readを肩代わりして待つ"]
A --> Q
Q -->|はい| Y
Q -->|いいえ| N
図7: 同じReadAsyncでも、ハンドルのモード次第で地下はまったく別物になる
- 同期モードのハンドル +
ReadAsyncは、スレッドプールのスレッドで同期読み取りを行う「見せかけの非同期」です。呼び出し元は待ちませんが、裏で1スレッドが眠っています。少数なら実害は小さいものの、サーバーや高頻度処理ではスレッドプール枯渇の原因になります。9 - 非同期モードのハンドル + 同期
Readも逆向きの不整合で、内部の完了待ちのぶん無駄が出ます。モードと呼び出すAPIを揃えるのが原則です。10 - .NET 6以降は
FileStreamの内部が全面的に書き直され、さらにFile.OpenHandle+RandomAccessという「SafeFileHandleとオフセットを明示して読み書きする」APIが入りました。9 オフセットを毎回渡すこの形は、本記事で見た非同期ハンドル+OVERLAPPED.OffsetというWin32の素の姿そのものです。 - 非同期モードのハンドルであれば、
CancellationTokenによるファイルI/Oのキャンセルは内部的にCancelIoExに行き着きます。トークンを渡したReadAsyncがOperationCanceledExceptionで終わる裏では、6章の図がそのまま動いています。キャンセルが「依頼」であり、即時性が保証されないことも同じです。一方、同期モードのハンドルの「見せかけの非同期」では、取り消し対象になるオーバーラップ操作が存在しないため、この経路は使えません。近年の.NETランタイムには、こうした同期実行中の呼び出しに対してCancelSynchronousIoで取り消しを試みる仕組みも入っていますが、効くかどうかはランタイムのバージョンと操作の種類に依存し、確実な中断は保証されません。キャンセルを設計の前提にするなら、モードを揃えて本物の非同期I/Oにしておくのが本筋です。
なお、async/await をどう書くべきかという上側の実務(ConfigureAwait、UIスレッドとの関係)は「C# async/await実務判断表」と「WPF/WinFormsのasyncとUIスレッドを一枚で整理」を参照してください。本記事はその地下1階、次回(IOCP)が地下2階です。
8. まとめ
- 同期I/Oと非同期I/Oは別の配管ではなく、I/Oマネージャーが完了を待つか、待たずに戻るかの違いです。同期I/Oのスレッドは待機状態で眠り、CPUは消費しません。1
- モードはハンドル(ファイルオブジェクト)に、状態は操作(
OVERLAPPED)にあります。非同期ハンドルではファイルポインターが管理されないため、位置を持つファイルでは毎回Offsetで指定します。24 OVERLAPPEDとバッファは完了通知まで生かして触らない。同時発行の数だけ用意する。使い回しはデータ破損です。3- 完了通知はハンドル・イベント・APC・IOCPの4経路。複数同時I/Oでハンドルシグナルは使わない、イベントは手動リセット、APCはalertable waitが前提です。1135
- 非同期発行しても、キャッシュ・NTFS圧縮・暗号化・拡張書き込みでは同期完了します。「TRUEで即返る」経路は正常系として必ず書き、応答性の保証には使わないことです。3
- キャンセルは依頼。
CancelIoEx後も完了通知(ERROR_OPERATION_ABORTED)を見届けてから後始末します。キャンセル→完了確認→クローズの順です。78 - .NETの
FileOptions.AsynchronousはFILE_FLAG_OVERLAPPEDの直通で、モードとAPIの不一致が「見せかけの非同期」を生みます。CancellationTokenの地下ではCancelIoExが動いています。910
続きは第3回「I/O完了ポート(IOCP)と.NETスレッドプール ── async/awaitの地下室」です。今回4.4節で名前だけ出したIOCPが、なぜ「完了通知のキュー」と「実行スレッド数の制御」を一体化した設計なのか、そして await の続きがどのスレッドで走るのかまで降りていきます。
関連記事
- Windows I/Oの深層(第1回) ── すべての読み書きはIRPになる:I/Oシステムの全体像
- C# async/await実務判断表 - Task.RunとConfigureAwait
- WPF/WinFormsのasyncとUIスレッドを一枚で整理
- シリアル通信アプリの落とし穴 - 再接続とログ設計まで
- WindowsでSleep(1)よりイベント待機を優先すべき理由
- 普通のWindowsでソフトリアルタイムをできるだけ実現するための実践ガイド
- TCPでSendした単位ごとにReceiveできるという誤解 ── バイトストリームとして扱うための受信設計
関連する相談領域
合同会社小村ソフトでは、非同期I/Oを使うWindows業務アプリ・デバイス通信アプリの設計と、「固まる」「キャンセルで落ちる」「スレッドプールが枯れる」といった不具合の原因調査を扱っています。
参考リンク
-
Microsoft Learn, Synchronous and asynchronous I/O. 同期I/Oでは関数がI/O完了までブロックし、非同期I/Oでは要求を発行した関数がすぐ戻ってスレッドが他の仕事を続けられること、非同期I/OにはFILE_FLAG_OVERLAPPEDを指定してハンドルを開く必要があること、完了の通知方法として、ファイルハンドルのシグナル、OVERLAPPED構造体に指定したイベントのシグナル、alertable waitで実行される完了ルーチン(APC)、I/O完了ポートがあること、複数の操作が同時に発行されている場合にファイルハンドルのシグナルではどの操作の完了か区別できないことについて。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9
-
Microsoft Learn, ReadFile function. FILE_FLAG_OVERLAPPED付きで開いたハンドルではlpOverlappedが必須で、読み取り開始位置をOVERLAPPED構造体のOffset/OffsetHighで指定すること、非同期に処理される場合はFALSEとERROR_IO_PENDINGが返ること、システムが非同期ハンドルのファイルポインターを維持しないこと、FILE_FLAG_OVERLAPPEDなしで開いたハンドルにOVERLAPPEDを渡した場合は指定オフセットから読むが読み取りが完了するまでReadFileが戻らないことについて。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Asynchronous disk I/O appears as synchronous on Windows. 非同期用にコーディングしてもI/Oが同期的に完了する理由として、NTFS圧縮されたファイル(ファイルシステムドライバーが圧縮ファイルに非同期でアクセスせず、すべての操作が同期になる)、NTFS暗号化されたファイル、ファイルの長さを伸ばす書き込み、要求を即時に満たせる場合(データがメモリ上のキャッシュにある場合など)にドライバーが操作をその場で完了させTRUEを返すこと、Windowsのキャッシュがファイルマッピングで実装されておりページが無い場合に非同期のページフォールト機構がないこと、加えて、3つのI/Oを発行するなら3つのOVERLAPPED構造体が必要で、使い回すと予測不能な結果やデータ破損につながること、操作が完了するまで対応するデータバッファを読み書きしてはならないことについて。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, OVERLAPPED structure. OVERLAPPED構造体が非同期入出力のための情報を保持すること、Offset/OffsetHighがファイル位置を、hEventが完了時にシグナルされるイベントを、Internal/InternalHighが操作の状態コードと転送バイト数を保持すること、操作の実行中は構造体を変更してはならず、有効なまま保持する必要があること、イベントを使う場合の注意点について。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Alertable I/O. alertable I/Oでは完了ルーチンへのエントリがスレッドのAPCキューに積まれること、スレッドがSleepEx、WaitForSingleObjectEx、WaitForMultipleObjectsExなどでalertable状態に入ったときにAPCが実行されること、APCが必ず発行したスレッドのコンテキストで実行されることについて。 ↩ ↩2 ↩3
-
Microsoft Learn, I/O Completion Ports. I/O完了ポートが、マルチプロセッサシステム上で多数の非同期I/O要求を処理するための効率的なスレッディングモデルを提供すること、ファイルハンドルをポートに関連付けると完了パケットがキューに入り、ワーカースレッドがGetQueuedCompletionStatusで取り出すこと、並行実行するスレッド数をポートが制御することについて。 ↩ ↩2
-
Microsoft Learn, CancelIoEx function. CancelIoExが、どのスレッドが発行したかにかかわらず、指定ハンドルに対する未完了I/Oにキャンセルのマークを付けること、lpOverlappedを指定すればその操作だけが、NULLならすべての未完了I/Oが対象になること、取り消された操作がERROR_OPERATION_ABORTEDで完了すること、すべての操作の取り消しが保証されるわけではなく完了処理が済むまで待つ必要があることについて。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Canceling pending I/O operations. 未完了I/Oのキャンセルの仕組みと、キャンセルを要求しても操作がすでに完了に向かっている場合があること、取り消された操作の完了を確認してからリソースを解放すべきこと、同期操作にはCancelSynchronousIoを、非同期操作にはCancelIo/CancelIoExを使う整理について。 ↩ ↩2 ↩3 ↩4
-
Microsoft .NET Blog, File IO improvements in .NET 6. .NET 6でFileStreamの内部実装が全面的に書き直されたこと、ハンドルが非同期モードで開かれているかどうかで戦略が分かれること、File.OpenHandleでSafeFileHandleを直接取得し、RandomAccessでオフセットを明示した読み書き(スレッドセーフ)ができるようになったこと、非同期モードでないハンドルへの非同期呼び出しがスレッドプールにオフロードされることについて。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Asynchronous file I/O (.NET). .NETにおける非同期ファイルI/Oの考え方と、FileStreamで非同期I/Oを使う場合にコンストラクターでuseAsync(FileOptions.Asynchronous)を指定してOSレベルの非同期I/Oを有効にすること、同期メソッドと非同期メソッドの使い分けについて。 ↩ ↩2 ↩3
-
Microsoft Learn, CancelSynchronousIo function. CancelSynchronousIoが、指定したスレッドが実行中の同期I/O操作にキャンセルのマークを付けること、取り消された操作がERROR_OPERATION_ABORTEDで失敗として返ることについて。 ↩ ↩2
-
Microsoft Learn, SetFileCompletionNotificationModes function. FILE_SKIP_COMPLETION_PORT_ON_SUCCESSにより、I/Oが即時に成功した場合にI/O完了ポートへ完了パケットを積まない動作を選べること、FILE_SKIP_SET_EVENT_ON_HANDLEによりファイルハンドルのイベントのセットを省けることについて。 ↩ ↩2
-
Microsoft Learn, GetOverlappedResult function. GetOverlappedResultが非同期操作の結果(成否と転送バイト数)を取得すること、bWaitにTRUEを渡すと操作の完了まで待つこと、OVERLAPPEDのhEventに自動リセットイベントを指定していて他の待機がシグナルを消費した場合、bWait=TRUEの呼び出しが完了を検知できず待ち続ける可能性があるため手動リセットイベントを使うべきことについて。 ↩ ↩2 ↩3
-
Microsoft Learn, ReadFileEx function. ReadFileExが読み取りの完了時に呼ばれる完了ルーチン(FileIOCompletionRoutine)を受け取ること、完了ルーチンは呼び出したスレッドがalertable wait状態にあるときに実行されること、FILE_FLAG_OVERLAPPEDで開いたハンドルが必要なことについて。 ↩
-
Microsoft Learn, Asynchronous Procedure Calls. APCが特定スレッドのコンテキストで非同期に実行される関数であり、各スレッドが自分のAPCキューを持つこと、ユーザーモードAPCはスレッドがalertable状態のときにのみ実行されることについて。 ↩
-
Microsoft Learn, CancelIo function. CancelIoが取り消せるのは呼び出したスレッド自身が発行したI/O操作だけであること、他のスレッドが発行した操作も含めて取り消すにはCancelIoExを使うことについて。 ↩
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
Windows I/Oの深層(第4回) ── キャッシュマネージャー:あなたのWriteFileはいつディスクに届くのか
Windowsのキャッシュマネージャーを図解で解説する連載の第4回です。ファイルマッピングとして実装されたキャッシュ、先読みと遅延書き込み、FlushFileBuffersやFILE_FLAG_NO_BUFFERINGの使い分け、電源断でデータが消える条件までを整理します。
Windows I/Oの深層(第3回) ── I/O完了ポート(IOCP)と.NETスレッドプール:async/awaitの地下室
I/O完了ポート(IOCP)を図解で解説する連載の第3回です。完了キューとスレッド数制御を一体化した設計、コンカレンシー値とLIFO解放、.NETスレッドプールとasync/await継続の実行スレッドまでを整理します。
Windows I/Oの深層(第1回) ── すべての読み書きはIRPになる:I/Oシステムの全体像
WindowsのI/Oシステムを底から解説する連載の第1回です。オブジェクトマネージャーの名前空間、ドライバー・デバイス・ファイルの3つのオブジェクト、IRPのライフサイクル、CloseHandleの裏側までを図解で整理します。
Windows I/Oの深層(第6回・最終回) ── フィルタードライバーとミニフィルター:ProcmonとウイルススキャンがI/Oに割り込める理由
Windowsのフィルタードライバーとミニフィルターを図解で解説する連載の最終回です。フィルターマネージャーとアルティチュード、pre/postコールバック、Procmonやウイルス対策が全I/Oを検査できる仕組み、「あの環境だけ遅い」の調査手順まで整理します。
Windows I/Oの深層(第5回) ── NTFSの内部構造:MFTから理解するファイルシステム
NTFSの内部構造を図解で解説する連載の第5回です。MFTとファイルレコード、複数データストリーム(Zone.Identifier)、ハードリンクと8.3名、リパースポイント、2つのジャーナル、スパースと圧縮までを開発者視点で整理します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
Windowsアプリ開発
業務アプリ、装置連携、通信ツールなどの Windows ソフト開発を支援します。
よくある質問
この記事のテーマについて、相談時によくある質問をまとめています。
- FILE_FLAG_OVERLAPPEDを付けると何が変わりますか?
- ハンドルの裏にあるファイルオブジェクトが「非同期モード」で開かれます。これはCreateFileの時点で決まるハンドル単位の性質で、呼び出しごとに同期・非同期を切り替えることはできません。非同期モードのハンドルでは、ReadFile/WriteFileにOVERLAPPED構造体を必ず渡します。システムはこのハンドルについてファイルポインター(現在位置)を管理しないため、ディスク上のファイルのように位置を持つデバイスでは、読み書きの位置もOVERLAPPEDのOffsetで毎回指定します(シリアルポートなど位置を持たないデバイスではOffsetは使いません)。発行した操作は完了を待たずに制御が戻ることがあり、その場合ReadFileはFALSEを返し、GetLastErrorがERROR_IO_PENDINGになります。完了はイベント、APC、I/O完了ポートなどの通知で受け取ります。
- 非同期I/Oを発行したのに、すぐ完了して返ってくるのはなぜですか?
- 非同期モードは「完了を待たなくてよい」であって「必ず待たされない」ではないからです。マイクロソフトのドキュメントは、非同期で発行してもI/Oが同期的に完了する代表的な理由として、要求がすぐ満たせる場合(データがキャッシュに載っている場合など)、NTFS圧縮されたファイル、NTFS暗号化(EFS)されたファイル、ファイルの長さを伸ばす書き込み、を挙げています。この場合ReadFile/WriteFileはTRUEを返し、結果はその場で確定しています。したがって非同期I/Oを使うコードは「ERROR_IO_PENDINGで返る場合」と「その場で完了する場合」の両方を必ず想定する必要があり、応答性が絶対に保証されるわけでもありません。なお既定では、同期完了した操作についても完了通知(イベントのシグナルやI/O完了ポートへのパケット)は別途届くため、結果の処理は通知側に一本化しておくのが安全です。
- OVERLAPPED構造体は使い回してもよいですか?
- 同時に複数の操作で共有してはいけません。OVERLAPPED構造体は「発行中の操作1つぶんの状態」を表すもので、マイクロソフトのドキュメントも、3つのI/Oを発行するなら3つのOVERLAPPED構造体が必要で、使い回すと予測不能な結果やデータ破損につながると明記しています。操作が完了するまでは構造体も読み書きバッファも有効なまま保持し、内容を触ってはいけません。完了した後に再利用する場合は、前回の残りデータが影響しないよう毎回初期化し直します。イベントを入れるhEventには手動リセットイベントを使うのが安全です。
- 実行中のI/Oを途中でキャンセルするにはどうすればよいですか?
- CancelIoExを使うと、どのスレッドが発行したかに関係なく、指定ハンドルの未完了I/Oにキャンセルを要求できます。第2引数にOVERLAPPEDを渡せば特定の1操作だけ、NULLならそのハンドルの全操作が対象です。古いCancelIoは「呼び出したスレッド自身が発行した操作」しか取り消せません。重要なのは、キャンセルは「依頼」であって即時の「保証」ではないことです。すでに完了間際の操作は正常完了することがあり、取り消された操作はERROR_OPERATION_ABORTEDで完了として通知されます。どちらの場合も完了通知を受け取るまでは、OVERLAPPED構造体とバッファを解放してはいけません。別スレッドで同期I/Oに入って固まっているスレッドには、CancelSynchronousIoという専用APIがあります。
- .NETのFileStreamでFileOptions.Asynchronous(useAsync)を指定しないとどうなりますか?
- ハンドルが同期モードで開かれるため、ReadAsync/WriteAsyncを呼んでも本物の非同期I/Oにはならず、スレッドプールのスレッドが同期読み書きを肩代わりする「見せかけの非同期」になります。呼び出し元のスレッドはブロックされませんが、その裏で別のスレッドが待たされているので、スレッドプールの枯渇やスケーラビリティ低下の原因になります。逆に非同期モードで開いて同期のRead/Writeを呼ぶと、内部で完了待ちのオーバーヘッドがかかります。「ハンドルのモード」と「呼び出すAPI」を揃えるのが原則で、.NET 6以降ならFile.OpenHandleとRandomAccessで、モードとオフセットを明示した素直な書き方ができます。
著者プロフィール
記事の著者プロフィールページです。
小村 豪
合同会社小村ソフト 代表
Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。
公開リンク