更新履歴(5件・最終更新 2026年08月02日)
この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。
- この記事の知識マップに含まれる関係を見直しました。本文より広い主張になっていたもの(条件付きでしか成り立たない関係、「防ぐ」ではなく「軽減する」にあたる関係、前提ではなく推奨にあたる関係)を、条件付きに改めるか、より正確な述語に置き換えています。本文の主張は変えていません。
- 記事の冒頭に「この記事の知識マップ」を追加しました。本文で扱う概念どうしの関係を一枚の図で見渡せます。関係の全一覧(根拠のURL・確度・確認日つき)と主要概念の定義は知識マップ詳細ページにまとめ、機械可読データをJSON-LDとTurtleで公開しています。
- APCの例で、`ReadFileEx`の戻り値を見ずにalertable待機のループへ入っていたのを直しました。機器の取り外し直後や無効なハンドルでは発行そのものが失敗し、完了ルーチンは1つも積まれません。この状態でループに入ると`completed`は永久に立たず、存在しないI/Oに対して`SleepEx`と`CancelIoEx`を延々と繰り返します。戻り値が0なら`GetLastError()`をその場で取り、待機に入らず抜けるようにしました。
- `SleepEx`を1回呼ぶだけの例を直しました。タイムアウトで戻るとその時点でalertable waitを抜けるため、I/Oが出たままバッファの寿命が尽きます。完了を記録して立つまで待ち続けるか、`CancelIoEx`で取り消してから完了の配達を待つ形にし、`WAIT_IO_COMPLETION`は自分のI/Oの完了とは限らないことも明記しました。
- 戻り値と`GetLastError()`の組み合わせを3行の表にまとめ、それをそのまま実装したC++の関数を追加しました。発行に失敗したときだけ完了通知が来ないことを表とコードの両方で示しています。C#の例を`useAsync`の有無と`RandomAccess`の3パターンで対比し、比較表を章の前半へ移し、APCの悪い例と良い例を追加しました。
- 初版公開
この記事を引用する(DOI(登録済みアーカイブ): 10.5281/zenodo.21739468)
以下のDOIは過去に登録されたアーカイブを指しており、現在の本文とは一致しない場合があります。現在の本文を参照するときは、このページのURLを使用してください。
小村 豪(2026)「Windows I/Oの深層(第2回) ── 同期I/Oと非同期I/O:OVERLAPPEDの本当の意味」合同会社小村ソフト. https://comcomponent.com/blog/windows-io-sync-async-overlapped/
- DOI(登録済みアーカイブ)
- 10.5281/zenodo.21739468
- DOI(前回登録した版)
- 10.5281/zenodo.21739469
前回(第1回)は、WindowsのI/O要求がIRPというパケットになってデバイススタックを流れ、要求の発行と完了は、カーネルの中では分かれていることを見ました。今回は、その分離をアプリケーションから使う非同期I/O(オーバーラップI/O)を扱います。
FILE_FLAG_OVERLAPPED を付けたのに呼び出しが待たされる。OVERLAPPED を使い回したらデータが壊れる。キャンセルした直後にバッファを解放すると落ちる。これらを理解する鍵は、「モードはハンドルに、状態は操作ごとに、後始末は完了を確認してから」という分担です。
この記事では、ファイルを開いてからI/Oを発行し、結果を受け取り、後始末するまでを順に追います。Win32の仕組みを押さえた後で、.NETの FileStream、ReadAsync、CancellationToken がどこにつながるかを確認します。
連載「Windows I/Oの深層」の第2回です。全体の構成は第1回の冒頭に置いています。
1. まず結論:混同しやすい3つの区別
非同期I/Oは、API名だけで判断すると分かりにくくなります。まず、設定する対象と、完了を判断する時点を分けます。
| 混同しやすいもの | 区別するポイント |
|---|---|
| ハンドルのモードと、操作の状態 | 同期・非同期のモードは CreateFile の時点で決まる。OVERLAPPED は、そのハンドルに発行する操作1つぶんの状態を持つ |
| 発行結果と、完了の受け取り | ERROR_IO_PENDING は失敗ではなく受理。TRUE は同期完了だが、既定では通知も届く。結果を両方で処理しない |
| キャンセル要求と、後始末できる時点 | CancelIoEx は取り消しの依頼。構造体・バッファを解放するのは、その操作の完了を確認した後 |
同期I/Oは、完了する前には呼び出し元へ戻りません。非同期I/Oでは、完了前に戻る経路を使えます。ただし、非同期モードでも呼び出しの中で完了することがあり、「絶対に待たされない」という保証ではありません。12
実装では、モードを決める → 操作専用の構造体とバッファを用意する → 発行結果を判定する → 完了を受け取る → 後始末する、の順で考えます。キャンセルを要求したときも、完了を受け取る段階は省略しません。34
目的が決まっている場合は、次の案内から進んでください。
| 知りたいこと・困っていること | 最初に読むところ |
|---|---|
| 同期I/Oと非同期I/Oは、どこが違うのか | 2章: 待つ仕組み、3.1節: ハンドルのモード |
| OVERLAPPEDを使うとデータが壊れる、関数を抜けた後に落ちる | 3.2節: 操作ごとの状態と寿命 |
| ReadFileがFALSEで返る、同期完了で二重処理してしまう | 3.3節: 発行結果の3分岐 |
| 完了の受け取り方を選びたい、コールバックが来ない | 4章: 通知方式の比較、4.3節: APCの待ち方 |
| 非同期にしたのに呼び出しが待たされる | 5章: 同期完了する条件と応答性 |
| キャンセルが効かない、キャンセル後に落ちる | 6章: 完了を確認してから後始末する |
| ReadAsyncを使っているのにスレッドが増える | 7章: .NETのハンドルとAPIの組み合わせ |
図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全36件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle
2. 同期I/O:完了を待つスレッドは、CPUを使わずに眠る
2.1. 待ち合わせるのはI/Oマネージャー
FILE_FLAG_OVERLAPPED を付けずに開いたハンドルは同期モードです。ReadFile は、I/Oが完了するまで戻りません。1
ドライバーがハードウェアの応答待ちで要求を保留(ペンディング)にすると、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。完了を待ち合わせてからReadFileが戻る
ただし、同期I/Oだから必ず眠るわけではありません。キャッシュヒットなど、その場で完了できる要求なら、待機せず結果を返します(第1回・図5の「即完了」の経路)。保証されるのは「完了する前には戻らない」ことです。
2.2. CPUを使わないことと、他の仕事ができることは別
待機状態のスレッドは、スケジューラーの実行対象から外れるためCPUを消費しません。自分でポーリングを続けるより、待機に任せるほうがよい理由は「WindowsでSleep(1)よりイベント待機を優先すべき理由」でも説明しています。
一方、待機中のスレッドは他の仕事をできません。UIスレッドなら画面が固まり、サーバーで接続ごとにスレッドを用意すれば、数百接続でスレッドが増えます。同期I/Oの弱点は、CPU使用率ではなく、完了までそのスレッドを使えないことです。
同期モードでは、カーネルがファイルポインター(現在位置)も管理します。そのため連続した ReadFile が「続きから」読みます。位置を持つのはハンドルの裏のファイルオブジェクトなので、DuplicateHandle で複製したハンドルどうしは位置を共有します(第1回3.3節)。
なお、別スレッドで実行中の同期I/Oにキャンセルを要求する CancelSynchronousIo もあります。非同期I/O向けのAPIとの使い分けは6章でまとめます。5
3. 非同期I/Oの準備と発行:モード・状態・戻り値を分ける
3.1. 非同期モードは、ファイルを開くときに決める
CreateFile に FILE_FLAG_OVERLAPPED を渡すと、ハンドルの裏のファイルオブジェクトが非同期モードになります。モードは呼び出しごとに切り替えるものではありません。同じファイルを同期用・非同期用の2つのハンドルで開くことはでき、その場合はファイルオブジェクトも2つになります。1
非同期モードでは、システムはファイルポインターを管理しません。複数の操作を同時に発行できるため、ディスク上のファイルでは読み書きする位置を毎回 OVERLAPPED.Offset / OffsetHigh で指定します。シリアルポートや名前付きパイプのようにシーク位置を持たないデバイスでは、この位置指定は使われず、0にしておきます。位置を指定しない場合でも、操作専用の OVERLAPPED は必要です。6
反対に、同期モードのハンドルに OVERLAPPED を渡しても、非同期にはなりません。Offset の位置から読みますが、完了までブロックする動きは変わりません。構造体を渡したかどうかではなく、ハンドルをどのモードで開いたかを確認します。6
3.2. OVERLAPPEDとバッファを、操作1つに対応させる
OVERLAPPED は、発行中の操作を識別し、その位置・状態・結果を運ぶ構造体です。「操作1つぶんの伝票」と考えると、ハンドルとの分担が分かります。3
| メンバー | 役割 |
|---|---|
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: ハンドルはモードを、OVERLAPPEDは操作ごとの位置・状態を持つ
ここで守ることは、個数と寿命の2つです。3つのI/Oを同時に発行するなら、OVERLAPPED も3つ用意します。同じ構造体を未完了の複数操作で共有すると、予測不能な結果やデータ破損につながります。2
また、完了するまでは構造体とデータバッファを有効なまま保持し、内容を変更・再利用・解放しません。カーネルがまだその領域を使っているためです。ローカル変数の OVERLAPPED で発行し、未完了のまま関数を抜けると、寿命の終わったスタック領域を使わせることになります。32
完了を確認してから再利用するときも、前の操作の状態が残らないよう初期化し直します。イベント方式を選ぶ場合は、hEvent に手動リセットイベントを使います。待ち方との関係は4.2節で説明します。3
3.3. ReadFileの戻り値を3つに分け、処理する場所を決める
非同期ハンドルに ReadFile を発行した結果は、戻り値と GetLastError() の組み合わせで判定します。FALSE をすべて失敗として扱わないことが重要です。6
ReadFile の戻り値 |
GetLastError() |
意味 | 呼び出し元がすること |
|---|---|---|---|
TRUE |
(見ない) | その場で完了した(同期完了) | 既定では完了通知も別途届く。結果の処理は通知側に任せる |
FALSE |
ERROR_IO_PENDING(997) |
受理された。進行中 | 何もしない。OVERLAPPED もバッファも触らずに完了通知を待つ |
FALSE |
それ以外 | 発行自体が失敗した | 完了通知は来ない。その場でエラー処理し、OVERLAPPED とバッファを後始末する |
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分岐を扱う
次の関数は、この判定だけを行って呼び出し元へ返します。ハンドル、操作専用の構造体・バッファ・イベントの準備と、完了を受け取る処理は別に用意する前提です。
// C++ / Win32
// hFile : FILE_FLAG_OVERLAPPED で開いたハンドル
// ov : この操作専用に確保した OVERLAPPED(Offset と hEvent は設定済み)
// buf/len: この操作専用のバッファ。完了通知を受け取るまで解放しない
DWORD IssueRead(HANDLE hFile, OVERLAPPED* ov, BYTE* buf, DWORD len)
{
// 非同期発行では lpNumberOfBytesRead に NULL を渡し、
// 転送バイト数は完了後の GetOverlappedResult で受け取る
if (ReadFile(hFile, buf, len, nullptr, ov))
{
// (1) 同期完了。既定では完了通知も届くので、ここでは結果を処理しない
return ERROR_SUCCESS;
}
DWORD err = GetLastError();
if (err == ERROR_IO_PENDING)
{
// (2) 受理された。ov と buf には触らずに完了通知を待つ
return ERROR_IO_PENDING;
}
// (3) 発行自体の失敗。完了通知は来ないので、呼び出し元がここで後始末する
return err;
}
ERROR_IO_PENDING は「受理され、まだ完了していない」という結果です。通常のエラーとして後始末してはいけません。一方、TRUE で戻る同期完了も正常な経路なので、必ず扱います。同期完了する理由は5章で説明します。
結果の処理は、一度だけ行うようにします。既定では、同期完了した操作でも、IOCPに関連付けたハンドルなら完了パケットが積まれ、イベント方式ならイベントがシグナルされます。TRUE の直後と通知を受けたときの両方で処理すると、同じ操作を二重処理し、構造体を二重解放するおそれがあります。安全な基本形は、TRUE と ERROR_IO_PENDING の両経路を、通知側での結果処理に一本化することです。1
これに対し、発行自体に失敗した経路は、発行元でエラー処理と後始末をします。通知が来ないのに待機へ回すと、永久に待つことになります。
同期完了時のIOCP通知を省く最適化もありますが、既定の動きとは別の設計です。FILE_SKIP_COMPLETION_PORT_ON_SUCCESS を使う場合の適用範囲は、5.3節で分けて説明します。7
4. 完了通知を選ぶ:I/Oの本数と、処理するスレッドで決める
発行と完了が分かれる以上、「完了をどう受け取るか」が必要です。まず、同時に扱うI/Oの数と完了処理を実行するスレッドで4方式を比較します。1
| 方式 | 完了処理が走るスレッド | 同時に飛ばせるI/Oの数 | 向いている場面 |
|---|---|---|---|
| (1) ハンドルのシグナル | 待機した任意のスレッド | 実質1本。複数飛ばすとどれが完了したか区別できない | ほぼなし(4.1) |
(2) イベント + GetOverlappedResult |
待機した任意のスレッド | 操作ごとにイベントが1つ必要。WaitForMultipleObjects でまとめて待つ場合は64個が上限 |
数本までの同時I/O。デバイス通信(4.2) |
(3) APC(ReadFileEx) |
発行したスレッド。しかも alertable wait に入っている間だけ | 本数の制約はないが、完了処理は全部その1スレッドで直列に走る | 単一スレッドで完結させたい通信処理(4.3) |
| (4) I/O完了ポート | ポートに紐づいたワーカースレッド群 | 多数を少数のスレッドで受けられる | サーバー、スレッドプール(4.4) |
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経路。発行方法に応じて受け取り方が変わる
4.1. ハンドルのシグナル:複数の操作を区別できない
hEvent を指定せずに発行すると、完了時にファイルハンドル自体がシグナル状態になります。ただし、同じハンドルで複数の操作が進行していると、どれが完了したのか区別できません。1
「非同期I/Oを1本ずつしか発行しない」という特殊な場合を除き、使わないのが安全です。手軽に見えても、操作ごとに結果を管理する仕組みにはなりません。
4.2. イベントとGetOverlappedResult:数本の同時I/Oの基本形
操作ごとの OVERLAPPED.hEvent に手動リセットイベントを設定して発行します。WaitForSingleObject で待った後、GetOverlappedResult で成否と転送バイト数を取得します。複数イベントをまとめて待つなら WaitForMultipleObjects を使いますが、同時に待てるのは64個までです。18
GetOverlappedResult の bWait を TRUE にすると、完了まで待ってから結果を取り出すこともできます。ここで自動リセットイベントを使うと、別の待機がシグナルを消費した後に GetOverlappedResult が待ち続ける場合があります。手動リセットを使うのは、この待ち合わせの問題を避けるためです。83
数本の同時I/Oを堅実に扱うには、見通しのよい方式です。シリアルポートの「読みながら書く」処理でも使われます。実務例は「シリアル通信アプリの落とし穴」を参照してください。
4.3. APC:発行スレッドで、完了までalertable waitを続ける
ReadFileEx / WriteFileEx は、完了ルーチン(コールバック)を指定する方式です。I/Oが完了すると、ルーチンが発行したスレッドのAPCキューに積まれます。そのスレッドが SleepEx や WaitForSingleObjectEx などで alertable wait に入ったときに実行されます。91011
完了処理が同じスレッドで直列に動くため、単一スレッドで完結する処理ではロックを避けられます。一方、発行スレッドがalertable waitに入らなければ、完了ルーチンは走りません。UIのメッセージループとの併用には MsgWaitForMultipleObjectsEx が必要になるなど、待ち方の設計が難しくなります。汎用的にはイベントかIOCPを選ぶことが多い方式です。
APCで見落としやすいのは、発行できたか、待ち方が正しいか、自分の操作が完了したかの3点です。次のコードは、悪い待ち方と良い待ち方を対比する抜粋であり、2つを続けて実行する例ではありません。ハンドルとバッファの準備、操作ごとの完了フラグを更新する OnReadCompleted は別途用意する前提です。
// C++ / Win32。hFile は FILE_FLAG_OVERLAPPED で開いたハンドル、
// ov と buf は完了まで生かしておく前提(3.2節)
// 悪い例: 完了ルーチンは永遠に呼ばれない
ReadFileEx(hFile, buf, len, ov, OnReadCompleted);
Sleep(1000); // alertable ではない待機。APCは配達されない
// 良い例: この I/O が終わるまで alertable な待機を続ける
//
// 完了ルーチン側でこのフラグを立てる(ov の Hosted な構造体などに持たせる)
volatile bool completed = false;
// 発行できたかを必ず確かめる。0 が返ったときは完了ルーチンが積まれていない
if (!ReadFileEx(hFile, buf, len, ov, OnReadCompleted))
{
const DWORD err = GetLastError(); // 直後に取る。以降のAPIで上書きされる
ReportError(err); // 取り外し・無効なハンドルなど
return; // ★ 下の待機ループに入ってはいけない
}
while (!completed)
{
DWORD r = SleepEx(1000, TRUE); // 第2引数の TRUE が alertable
if (r == WAIT_IO_COMPLETION)
{
// 何らかの APC が実行された。ただし自分の I/O とは限らないので、
// completed を見て判断し、違えば待ち直す
continue;
}
// タイムアウトで戻ってきた。まだ I/O は出たままなので、
// 打ち切るなら CancelIoEx で取り消し、完了が配達されるまで待つ
CancelIoEx(hFile, ov);
}
発行に失敗したら、待機へ入らない。機器の取り外しや無効なハンドルなどで ReadFileEx が0を返した場合、完了ルーチンは積まれていません。GetLastError() を直後に取得し、エラー処理して抜けます。ここを見落とすと、completed が立たないまま、存在しないI/Oに対して SleepEx と CancelIoEx を繰り返してしまいます。9
待機のタイムアウトを、I/Oの終了とみなさない。SleepEx がタイムアウトするとalertable waitを抜けますが、発行済みのI/Oは残り得ます。そこでスコープを抜けて ov や buf を失効させないでください。完了まで待ち続けるか、打ち切るならキャンセルを要求し、その完了が配達されるまで待ちます。3.2節の寿命の規則は、タイムアウト後も同じです。
WAIT_IO_COMPLETION だけで、自分のI/Oが終わったと決めない。この戻り値は、APCが1つ以上実行されたという意味です。同じスレッドに別のI/Oや QueueUserAPC のAPCが積まれていれば、それでも戻ります。自分の完了ルーチンが更新するフラグで判定し、まだなら待ち直します。10
「APCが来ない」ときは、発行の成否に加え、待機関数を確認します。Sleep ではなく SleepEx(..., TRUE)、WaitForSingleObject ではなく WaitForSingleObjectEx(..., TRUE) になっているか。末尾の Ex と、alertable引数の TRUEが確認点です。10
4.4. IOCP:多数のI/Oを、少数のワーカーで受ける
I/O完了ポート(IOCP)では、ハンドルをポートに関連付けます。完了パケットがポートのキューへ入り、ワーカースレッドが GetQueuedCompletionStatus で取り出します。多数の同時I/Oを少数のスレッドで処理する仕組みです。12
.NETの非同期I/Oを支える経路でもあります。完了通知のキューと並行実行するスレッド数の制御をどう組み合わせるかは、次回の第3回で詳しく扱います。
5. 同期完了の例外:「非同期」と「待たされない」は同じではない
5.1. 呼び出しの中で完了する代表的な条件
非同期モードで正しく発行しても、I/Oが呼び出しの中で完了することがあります。同期完了とは、関数が戻るまでにI/Oが完了したという意味で、短時間で戻る保証ではありません。キャッシュヒットで速く終わる場合と、呼び出しの中で待たされる場合を分けて考えます。2
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: 非同期で発行しても同期完了する主な条件。呼び出しが戻るまでの時間とは区別する
Microsoftのトラブルシューティング文書は、次の理由を挙げています。2
| 条件 | 同期的に処理される理由と、実装上の注意 |
|---|---|
| すぐ満たせる要求・キャッシュヒット | データがメモリにあれば、ドライバーはその場で完了できる。速く終わっても、必ず ERROR_IO_PENDING になる前提のコードは壊れる |
| キャッシュ有効の読み取りで、必要なページがない | Windowsのキャッシュはファイルマッピングで実装される。非同期のページフォールト機構がないため、同期的に処理される場合がある |
| NTFS圧縮・EFS暗号化されたファイル | ファイルシステムドライバーがアクセスを同期に変換する |
| ファイルを伸ばす書き込み | 長さを変える書き込みは同期になる |
キャッシュヒットだけでなく、キャッシュにないときにも同期的な処理が起こり得る点が重要です。キャッシュ自体の仕組みは、連載第4回で扱います。
5.2. 発行結果の分岐と、UIの応答性を別々に設計する
まず必要なのは、3.3節の3分岐をすべて扱うことです。TRUE も正常な結果として想定し、既定では通知側へ結果処理を一本化します。
ただし、分岐を正しく書いても応答性が保証されるわけではありません。「非同期I/OだからUIは固まらない」とは言えないため、止められないスレッドからはI/Oの発行自体を分離し、専用スレッドやスレッドプールへ任せる設計が必要です。関連する実務は「普通のWindowsでソフトリアルタイムをできるだけ実現するための実践ガイド」でも説明しています。
5.3. 同期完了時の通知省略は、IOCPだけの最適化
高頻度I/Oでは、同期完了時の通知を省く最適化ができます。SetFileCompletionNotificationModes で FILE_SKIP_COMPLETION_PORT_ON_SUCCESS を有効にすると、即時に成功したI/Oの完了パケットをIOCPへ積まない動作になります。通知側ではなく、その場で結果を処理する設計に切り替える場合の指定です。7
省かれるのはIOCPへのパケットだけで、OVERLAPPED.hEvent のシグナルは抑止されません。イベント方式に同じ最適化を当てはめないでください。既定の通知経路と最適化後の経路を混在させると、3.3節の二重処理や通知待ちの誤りにつながります。IOCPとの組み合わせは第3回で扱います。
6. キャンセルと終了処理:要求する、完了を確認する、閉じる
6.1. 取り消す対象に合わせてAPIを選ぶ
キャンセル用のAPIは、操作と発行スレッドの違いで使い分けます。4135
| API | 対象と指定方法 |
|---|---|
CancelIoEx |
どのスレッドが発行したかに関係なく、指定ハンドルの未完了I/Oへ要求する。第2引数が OVERLAPPED ならその操作、NULL ならそのハンドルの全操作 |
CancelIo |
呼び出したスレッド自身が発行した操作だけが対象 |
CancelSynchronousIo |
指定した別スレッドで実行中の同期I/Oが対象 |
CancelIoEx はVistaで導入されました。非同期I/Oでは、発行スレッドに制限のある古い CancelIo をいまあえて使う理由はなく、CancelIoEx を基本にします。
6.2. CancelIoExの成功は、I/Oの終了を意味しない
CancelIoEx は未完了IRPにキャンセルを要求するAPIであり、操作の完了を待つAPIではありません。成功しても、取り消しを要求した段階です。すでに完了間際だった操作は、キャンセルが間に合わず正常完了することもあります。14
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: 取り消された操作も完了として通知される。後始末はその確認後に行う
実際に取り消された操作は、完了通知で ERROR_OPERATION_ABORTED として返ります。正常完了した場合も取り消された場合も、通知を受け取るまでは、構造体とバッファを解放しません。先に解放すると、カーネルが使用中の領域を失い、メモリ破壊につながります。キャンセル後のアクセス違反では、まずこの寿命を確認します。414
6.3. ハンドルを閉じる前に、発行済みの操作を回収する
終了処理の基本は、キャンセルを要求する → 完了を見届ける → ハンドルを閉じるです。
第1回で見たとおり、最後のハンドルを閉じるとcleanup処理で未完了IRPの取り消しが走ります。しかし、発行済みI/Oを残したままハンドルだけ閉じると、完了通知とバッファ寿命の管理が破綻しがちです。閉じる操作に後始末を丸投げせず、未完了の操作を先に始末します。
タイムアウトで処理を打ち切りたい場合も同じです。OSがアプリの打ち切り条件まで決めてくれるわけではないので、タイムアウト後のキャンセルと、完了を受け取る手順をセットで設計します。APC方式なら、4.3節のように完了の配達までalertable waitを続けます。
7. .NETとの対応:ReadAsyncだけでなく、ファイルを開く箇所を見る
7.1. ハンドルのモードと、呼び出すAPIを揃える
FileStream の useAsync、または FileOptions.Asynchronous は、Win32の FILE_FLAG_OVERLAPPED に対応します。第1回の対応表と同じく、.NETでもファイルを開くときのモードが重要です。1516
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でも、ハンドルのモードによってOS側の処理経路が変わる
| ハンドルとAPIの組み合わせ | 内部で起きること |
|---|---|
非同期モード + ReadAsync / WriteAsync |
OSの非同期I/Oを使う組み合わせ |
同期モード + ReadAsync / WriteAsync |
スレッドプールのスレッドが同期読み書きを肩代わりする「見せかけの非同期」 |
非同期モード + 同期 Read / Write |
内部で完了を待ち合わせるぶんのオーバーヘッドが生じる |
「見せかけの非同期」でも呼び出し元のスレッドは待たされませんが、その裏では別のスレッドが待ちます。少数なら実害が小さくても、サーバーや高頻度処理ではスレッドプール枯渇やスケーラビリティ低下の原因になります。モードとAPIを揃えるのが原則です。1615
7.2. FileStreamの作成方法を、3つの例で比べる
次の(A)と(B)では、ReadAsync の呼び方は同じです。違うのは、ファイルを開くときの useAsync だけです。(C)は.NET 6以降で、ハンドルと位置を明示する例です。
using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Win32.SafeHandles;
string path = @"C:\temp\data.bin";
byte[] buffer = new byte[4096];
// (A) 見せかけの非同期。useAsync を省略/false にすると、ハンドルは同期モードで開かれる
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
bufferSize: 4096, useAsync: false))
{
// 呼び出し元はブロックされないが、裏でスレッドプールの1本が同期Readを肩代わりして待つ
await fs.ReadAsync(buffer, 0, buffer.Length);
}
// (B) 本物の非同期。useAsync: true が FILE_FLAG_OVERLAPPED に直結する
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
bufferSize: 4096, useAsync: true))
{
// 完了はIOCP経由でスレッドプールへ(第3回)
await fs.ReadAsync(buffer, 0, buffer.Length);
}
// (C) .NET 6以降。モードとオフセットを明示する素直な書き方
using (SafeFileHandle handle = File.OpenHandle(path, FileMode.Open, FileAccess.Read,
options: FileOptions.Asynchronous))
{
int read = await RandomAccess.ReadAsync(handle, buffer, fileOffset: 0);
}
既存コードを調べるときは、ReadAsync / WriteAsync の呼び出し側だけでなく、FileStream を作っている場所を探します。File.OpenRead や new FileStream(path, FileMode.Open) の短いオーバーロードは同期モードで開きます。SafeFileHandle から FileStream を作る場合も、isAsync 引数をハンドルの実際のモードに合わせます。
7.3. RandomAccessでは、ハンドルとオフセットを明示する
.NET 6では FileStream の内部実装が全面的に書き直され、File.OpenHandle と RandomAccess が追加されました。SafeFileHandle を直接扱い、読み書きする位置を毎回渡すAPIです。16
(C)のようにモードと fileOffset を明示する形は、本記事で説明した非同期ハンドルと操作ごとの OVERLAPPED.Offsetという分担に対応します。
7.4. CancellationTokenでも、キャンセルは依頼にとどまる
非同期モードのハンドルでは、CancellationToken によるファイルI/Oのキャンセルは内部的に CancelIoEx へつながります。トークンを渡した ReadAsync が OperationCanceledException で終わる裏でも、6章の仕組みが働きます。即時の中断が保証されない点も同じです。
同期モードでの「見せかけの非同期」には、取り消し対象のオーバーラップ操作がないため、この経路は使えません。近年の.NETランタイムには、同期実行中の呼び出しに CancelSynchronousIo で取り消しを試みる仕組みもありますが、動作はランタイムの版と操作の種類に依存し、確実な中断は保証されません。キャンセルを前提に設計するなら、ハンドルのモードを揃えてOSの非同期I/Oを使うのが本筋です。
async/await の上側の実務、たとえば ConfigureAwait やUIスレッドとの関係は「C# async/await実務判断表」と「WPF/WinFormsのasyncとUIスレッドを一枚で整理」を参照してください。本記事は、その下でOSがどう読み書きを進めるかを説明しています。
8. まとめ:発行から後始末までを、ひと続きで確認する
非同期I/Oの点検では、次の順にコードを追います。
- 開く箇所で、同期・非同期のモードを確認する。ディスク上のファイルなら、非同期操作ごとに位置を指定する。
- 発行する箇所で、操作専用の
OVERLAPPEDとバッファがあり、3.3節の3分岐を扱っていることを確認する。 - 完了を受け取る箇所で、イベント・APC・IOCPなどの方式に合った待ち方をし、同じ結果を二重処理していないことを確認する。
- 終了する箇所で、タイムアウトやキャンセル要求だけを根拠に解放していないことを確認する。
同期I/Oと非同期I/Oは、別々の配管ではありません。完了を待ってから戻るか、完了前に戻る経路を使うかの違いです。ただし非同期モードでも同期完了は起こるため、発行結果の分岐と応答性の設計は分けます。12
モードはハンドルに、状態は操作ごとに、後始末は完了を確認してから。この分担は、Win32の OVERLAPPED を直接扱う場合も、.NETの FileOptions.Asynchronous を使う場合も共通です。キャンセルした操作も、完了を受け取るまでは管理を続けます。3415
続きは第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
-
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
-
Microsoft Learn, OVERLAPPED structure. OVERLAPPED構造体が非同期入出力のための情報を保持すること、Offset/OffsetHighがファイル位置を、hEventが完了時にシグナルされるイベントを、Internal/InternalHighが操作の状態コードと転送バイト数を保持すること、操作の実行中は構造体を変更してはならず、有効なまま保持する必要があること、イベントを使う場合の注意点について。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, CancelIoEx function. CancelIoExが、どのスレッドが発行したかにかかわらず、指定ハンドルに対する未完了I/Oにキャンセルのマークを付けること、lpOverlappedを指定すればその操作だけが、NULLならすべての未完了I/Oが対象になること、取り消された操作がERROR_OPERATION_ABORTEDで完了すること、すべての操作の取り消しが保証されるわけではなく完了処理が済むまで待つ必要があることについて。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, CancelSynchronousIo function. CancelSynchronousIoが、指定したスレッドが実行中の同期I/O操作にキャンセルのマークを付けること、取り消された操作がERROR_OPERATION_ABORTEDで失敗として返ることについて。 ↩ ↩2
-
Microsoft Learn, ReadFile function. FILE_FLAG_OVERLAPPED付きで開いたハンドルではlpOverlappedが必須で、読み取り開始位置をOVERLAPPED構造体のOffset/OffsetHighで指定すること、非同期に処理される場合はFALSEとERROR_IO_PENDINGが返ること、システムが非同期ハンドルのファイルポインターを維持しないこと、FILE_FLAG_OVERLAPPEDなしで開いたハンドルにOVERLAPPEDを渡した場合は指定オフセットから読むが読み取りが完了するまでReadFileが戻らないことについて。 ↩ ↩2 ↩3
-
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
-
Microsoft Learn, ReadFileEx function. ReadFileExが読み取りの完了時に呼ばれる完了ルーチン(FileIOCompletionRoutine)を受け取ること、完了ルーチンは呼び出したスレッドがalertable wait状態にあるときに実行されること、FILE_FLAG_OVERLAPPEDで開いたハンドルが必要なことについて。 ↩ ↩2
-
Microsoft Learn, Alertable I/O. alertable I/Oでは完了ルーチンへのエントリがスレッドのAPCキューに積まれること、スレッドがSleepEx、WaitForSingleObjectEx、WaitForMultipleObjectsExなどでalertable状態に入ったときにAPCが実行されること、APCが必ず発行したスレッドのコンテキストで実行されることについて。 ↩ ↩2 ↩3
-
Microsoft Learn, Asynchronous Procedure Calls. APCが特定スレッドのコンテキストで非同期に実行される関数であり、各スレッドが自分のAPCキューを持つこと、ユーザーモードAPCはスレッドがalertable状態のときにのみ実行されることについて。 ↩
-
Microsoft Learn, I/O Completion Ports. I/O完了ポートが、マルチプロセッサシステム上で多数の非同期I/O要求を処理するための効率的なスレッディングモデルを提供すること、ファイルハンドルをポートに関連付けると完了パケットがキューに入り、ワーカースレッドがGetQueuedCompletionStatusで取り出すこと、並行実行するスレッド数をポートが制御することについて。 ↩
-
Microsoft Learn, CancelIo function. CancelIoが取り消せるのは呼び出したスレッド自身が発行したI/O操作だけであること、他のスレッドが発行した操作も含めて取り消すにはCancelIoExを使うことについて。 ↩
-
Microsoft Learn, Canceling pending I/O operations. 未完了I/Oのキャンセルの仕組みと、キャンセルを要求しても操作がすでに完了に向かっている場合があること、取り消された操作の完了を確認してからリソースを解放すべきこと、同期操作にはCancelSynchronousIoを、非同期操作にはCancelIo/CancelIoExを使う整理について。 ↩ ↩2
-
Microsoft Learn, Asynchronous file I/O (.NET). .NETにおける非同期ファイルI/Oの考え方と、FileStreamで非同期I/Oを使う場合にコンストラクターでuseAsync(FileOptions.Asynchronous)を指定してOSレベルの非同期I/Oを有効にすること、同期メソッドと非同期メソッドの使い分けについて。 ↩ ↩2 ↩3
-
Microsoft .NET Blog, File IO improvements in .NET 6. .NET 6でFileStreamの内部実装が全面的に書き直されたこと、ハンドルが非同期モードで開かれているかどうかで戦略が分かれること、File.OpenHandleでSafeFileHandleを直接取得し、RandomAccessでオフセットを明示した読み書き(スレッドセーフ)ができるようになったこと、非同期モードでないハンドルへの非同期呼び出しがスレッドプールにオフロードされることについて。 ↩ ↩2 ↩3
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
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を監視・制御する仕組みを解説します。FltMgr、アルティチュード、pre/postコールバック、fltmcの読み方を整理し、Procmonで遅い操作を特定する手順と、除外設定・Dev Driveの注意点をまとめます。
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で、モードとオフセットを明示した素直な書き方ができます。