更新履歴(3件・最終更新 2026年08月01日)
この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。
- 記事の冒頭に「この記事の知識マップ」を追加しました。本文で扱う概念どうしの関係を一枚の図で見渡せます。関係の全一覧(根拠のURL・確度・確認日つき)と主要概念の定義は知識マップ詳細ページにまとめ、機械可読データをJSON-LDとTurtleで公開しています。
- 対訳表で`ReadFile`の呼び出し1回を「IRP 1つ」と言い切っていたのを直しました。キャッシュに乗っているファイルへの同期の読み書きはファストI/Oで済み、IRPは作られません(同じ記事の5.2で書いているとおりです)。この表でProcmonのトレースを読むと、存在しないIRPを探すことになります。
- Win32や.NETから見えているものとカーネル側の対応物を並べた対訳表を追加しました(HANDLEとファイルオブジェクトへの参照、`ReadFile`1回とIRP1つ、`GetLastError`とNTSTATUSなど)。観察の章に必要なものと入手先を明示し、目的別の読み飛ばしガイドと前提知識を冒頭に置きました。
- 初版公開
この記事を引用する(DOI(登録済みアーカイブ): 10.5281/zenodo.21739464)
以下のDOIは過去に登録されたアーカイブを指しており、現在の本文とは一致しない場合があります。現在の本文を参照するときは、このページのURLを使用してください。
小村 豪(2026)「Windows I/Oの深層(第1回) ── すべての読み書きはIRPになる:I/Oシステムの全体像」合同会社小村ソフト. https://comcomponent.com/blog/windows-io-internals-architecture-irp/
- DOI(登録済みアーカイブ)
- 10.5281/zenodo.21739464
- DOI(前回登録した版)
- 10.5281/zenodo.21739465
File.ReadAllText の1行、あるいは ReadFile の1呼び出し。この関数が戻ってくるまでの間に、Windowsの中では何が起きているのでしょうか。
この流れを知ると、Process Monitorに現れる IRP_MJ_READ や FASTIO_READ、CloseHandle を呼んだのに解放されないファイル、ネットワークドライブやウイルス対策ソフトのある環境でだけ変わる挙動を、同じ仕組みの中で考えられます。
連載「Windows I/Oの深層」では、『インサイドWindows』(Windows Internals)が扱うようなカーネルの設計まで視野に入れ、APIの使い方だけでなく「なぜそう動くのか」を説明します。1 第1回の本記事は、その土台となる名前の解決、登場人物、要求と完了の流れを図で押さえます。ドライバーを書くためではなく、アプリケーション開発者が自分の呼んだAPIの行き先を理解するための記事です。
前提知識: C#で FileStream を使ったことがある、またはC/C++で CreateFile / ReadFile を呼んだことがあれば読めます。ドライバー開発の経験は不要で、カーネル側のコードは概念図として示します。読了の目安は、図を見ながら通しで25分ほど、1章の結論だけなら2〜3分です。
連載の構成
| 回 | テーマ |
|---|---|
| 第1回(本記事) | I/Oシステムの全体像 ── すべての読み書きはIRPになる |
| 第2回 | 同期I/Oと非同期I/O ── OVERLAPPEDの本当の意味 |
| 第3回 | I/O完了ポート(IOCP)と.NETスレッドプール ── async/awaitの地下室 |
| 第4回 | キャッシュマネージャー ── あなたのWriteFileはいつディスクに届くのか |
| 第5回 | NTFSの内部構造 ── MFTから理解するファイルシステム |
| 第6回 | フィルタードライバーとミニフィルター ── ProcmonとウイルススキャンがI/Oに割り込める理由 |
1. まず結論
最初に押さえたいのは、次の3点です。
- WindowsのI/Oは「名前で宛先を決め、要求をパケットで渡す」仕組みです。
CreateFileが開くのはディスク上のファイルとは限らず、C:もNTデバイス名へのシンボリックリンクです。デバイスドライバーに送られる要求のほとんどは、IRP(I/O Request Packet)としてデバイススタックを流れます。ただし、IRPを作らないファストI/Oという近道もあります(2章・4章・5.2節)。2345 - 「処理するもの」「宛先」「開いた状態」を分けて考えます。 ドライバーオブジェクトは処理関数の表、デバイスオブジェクトは要求の宛先、ファイルオブジェクトは開いた1回ぶんの状態を持ちます。アプリの
HANDLEは、ファイルオブジェクトへの参照です(3章)。6789 - 要求の発行・完了と、ハンドルを閉じること・参照の解放は、それぞれ別の段階です。 ドライバーはIRPを完了する・下へ渡す・保留する処理を組み合わせます。同期I/Oは「完了する前には戻らない」という保証であり、別のI/O機構ではありません。また、最後のハンドルが閉じるcleanupと、参照がなくなるcloseも区別が必要です(4〜6章)。310111213
目的別の読み方
| 知りたいこと | 読む場所 |
|---|---|
| APIを呼んだ後の全体像 | 1章 → 2章(名前解決) → 5章(ReadFileの往復) |
| ドライバー・デバイス・ファイルオブジェクトとIRPの違い | 3章の対訳表 → 4章 |
| ハンドルリークや「閉じたのに使用中」の理由 | 3.3節 → 6章 |
| 自分のPCで確かめる方法 | 7章。WinObjで名前空間、Process MonitorでI/Oを観察 |
図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全40件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle
2. 「すべてがファイルに見える」の正体
2.1. CreateFileに渡した名前はどこへ行くのか
ファイルやデバイスを開く操作の入口は「名前」です。C:\project\report.csv というローカルパス、\\server\share\data.csv というUNCパス、\\.\COM3 というデバイス指定は、どれも同じ CreateFile に渡せます。14
この一貫性を支えるのが、カーネルのオブジェクトマネージャーが管理する名前空間です。名前付きのデバイス、イベント、共有メモリセクションなどが、ツリー状の名前空間で扱われます。SysinternalsのWinObjを使うと、その名前空間を直接確認できます。15
flowchart TB
ROOT["\ (名前空間のルート)"]
DEV["\Device<br/>(ドライバーが作るデバイスオブジェクト)"]
GLB["\GLOBAL??<br/>(Win32から見えるグローバルな名前の置き場)"]
BNO["\BaseNamedObjects<br/>(名前付きミューテックスなど)"]
ROOT --> DEV
ROOT --> GLB
ROOT --> BNO
DEV --> D1["HarddiskVolume3"]
DEV --> D2["Serial0"]
DEV --> D3["Mup (ネットワークリダイレクター)"]
GLB --> L1["C: → \Device\HarddiskVolume3"]
GLB --> L2["COM1 → \Device\Serial0"]
GLB --> L3["PhysicalDrive0 → \Device\Harddisk0\DR0"]
図1: オブジェクトマネージャーの名前空間(抜粋)。\GLOBAL?? 配下にあるのはシンボリックリンクで、実体は \Device 配下にある
図1では、次の2つを分けて見てください。
| 名前の側 | 例 | 役割 |
|---|---|---|
| Win32アプリが使う名前 | ドライブレター、COMポート名 | アプリからの入口 |
| カーネルが使う名前 | \Device\HarddiskVolume3 などのNTデバイス名 |
デバイスオブジェクトを指す名前 |
両者をつなぐのがシンボリックリンクです。アプリから見える名前と、カーネル側の実体の名前は同じではありません。
2.2. ドライブレターはシンボリックリンクである
ドライバーは、デバイスをWin32アプリから見えるようにするため、IoCreateSymbolicLink で \DosDevices\COM1 のようなMS-DOSデバイス名からNTデバイス名へのリンクを作ります。4 C: も同じ仕組みで、\Device\HarddiskVolume3 のようなボリュームデバイスへのリンクです。
CreateFile("C:\project\report.csv") の名前解決は、次のように進みます。
flowchart TB
A["アプリが渡した名前<br/>C:\project\report.csv"]
B["Win32層がNT形式に変換<br/>\??\C:\project\report.csv"]
C["オブジェクトマネージャーが名前空間を検索<br/>\??\C: はシンボリックリンクだと分かる"]
D["リンクをたどって置き換え<br/>\Device\HarddiskVolume3\project\report.csv"]
E["\Device\HarddiskVolume3 で<br/>ボリュームのデバイスオブジェクトに到達"]
F["残りの \project\report.csv の解決は<br/>I/OマネージャーがIRP_MJ_CREATEを発行して<br/>ファイルシステムドライバー(NTFS)に任せる"]
A --> B
B --> C
C --> D
D --> E
E --> F
図2: CreateFileの名前解決。前半はオブジェクトマネージャー、デバイスに到達した後半はファイルシステムの仕事
デバイスまでの名前と、ファイルシステム内の名前を分ける
図2の前半では、Win32のパスをNT形式に変換し、リンクをたどってボリュームのデバイスオブジェクトへ到達します。その後に残る \project\report.csv の解決は、I/Oマネージャーが IRP_MJ_CREATE を発行し、ファイルシステムドライバー(NTFS)に任せます。
この区切りを知ると、次の指定も同じ流れで読めます。
\\.\はWin32デバイス名前空間への指定です。\\.\PhysicalDrive0や\\.\COM10は、ドライブレターを経由せず、デバイス名のリンクを直接指定します。514CONやNULは予約済みのMS-DOSデバイス名です。 パスに書いてもデバイス側へ解決されうるため、通常のファイル名として使えません。5 実務上の注意は「MAX_PATHとWindowsのパス・ファイル名の落とし穴」で扱っています。- UNCパスでは解決先のデバイスが変わります。
\\server\shareはネットワークリダイレクターのデバイス(\Device\Mup)へ解決され、その先はSMBクライアントがネットワーク越しに要求を運びます。ローカルとUNCで挙動が変わる理由は、この解決先の違いから考えられます(「ネットワークドライブとUNCパスの落とし穴」参照)。
\?? と \GLOBAL?? は同じものではない
\?? は、実在するディレクトリの単なる別名ではありません。まずログオンセッションごとのローカルなDOSデバイスマップを探し、なければ \GLOBAL?? を探すという検索順の入口です。図1に描いているのは、グローバル側の \GLOBAL?? だけです。
net use や subst で作ったドライブレターはローカル側に入ります。そのため、同じPCでもログオンセッションごとに見えるドライブが違い、サービスからはユーザーのネットワークドライブが見えないことがあります。
ここまでをまとめると、ファイルもデバイスも同じAPIで扱えるのは、名前からデバイスオブジェクトへ到達し、その先の要求を共通の形式で渡せるからです。次に、その宛先と開いた状態を表すオブジェクトを整理します。
3. 登場人物は3つのオブジェクト
まず、普段のWin32 / .NETコードで見えるものと、カーネル側の対応物を並べます。以降の用語で迷ったときは、この表に戻ってください。
| Win32 / .NET 側で見えているもの | カーネル側の対応物 | ひとことで言うと |
|---|---|---|
HANDLE / SafeFileHandle |
ハンドルテーブルのエントリ(ファイルオブジェクトへの参照) | 「開いた1回」を指す番号札(3.3) |
FileStream が握っている開いた状態 |
ファイルオブジェクト | 共有モード・フラグ・現在位置の入れ物(3.3) |
ドライブレター C: や \\.\COM3 |
デバイスオブジェクト(への名前解決の結果) | 要求の宛先(2章・3.2) |
| 「NTFSドライバー」「ディスクドライバー」 | ドライバーオブジェクト | 要求の種類ごとの処理関数の表(3.1) |
ReadFile / stream.Read の呼び出し1回 |
通常は IRP 1つ | 宛先へ運ばれる要求パケット(4章)。キャッシュに乗っているファイルへの同期の読み書きはファストI/Oという近道でIRPを作らずに済ませます(5.2)。Procmon で FASTIO_ で始まる行がそれです |
FileOptions / CreateFile のフラグ |
ファイルオブジェクトに記録される属性 | 先読みや非同期の振る舞いを決める(7章の対応表) |
GetLastError の値 / .NETの例外 |
NTSTATUS(STATUS_PENDING など) |
完了状態。Win32のエラーコードに翻訳されて上がってくる |
この表は対応を理解するためのものです。ReadFileからディスクまで常に同じIRPが1つだけ流れる、という意味ではありません。IRPを作らない経路は5.2節、別の下位IRPを発行する境界は4.2節で説明します。
3.1. ドライバーオブジェクト ── 処理関数の表
ドライバーのロード時に、I/Oマネージャーは、そのドライバーを表すドライバーオブジェクト(DRIVER_OBJECT)を作ります。6
アプリ開発者が押さえたいのは MajorFunction 配列です。これは「要求の種類 → 処理関数」の対応表で、要求の種類をメジャーファンクションコードと呼びます。代表例は IRP_MJ_CREATE(開く)、IRP_MJ_READ(読む)、IRP_MJ_WRITE(書く)、IRP_MJ_CLEANUP、IRP_MJ_CLOSE です。16
C#で関係だけを表すと、次のようになります。実装例ではなく、カーネル内のC構造体を理解するための概念図です。
// 概念図。実際はカーネル内のC構造体
class DriverObject
{
// IRP_MJ_XXX がインデックス。全28種類
public DispatchRoutine[] MajorFunction = new DispatchRoutine[28];
}
// ntfs.sys なら MajorFunction[IRP_MJ_READ] に「NTFSの読み取り処理」が入っている
3.2. デバイスオブジェクト ── 要求の宛先
ドライバーは、自分が扱うデバイスごとにデバイスオブジェクト(DEVICE_OBJECT)を作ります。これがI/O要求の宛先です。7
ただし、物理デバイスと1対1とは限りません。ボリューム(HarddiskVolume3)のような論理的な存在も、フィルタードライバーが介在するために作るデバイスも、このオブジェクトで表されます。
デバイスオブジェクトは、自分を作ったドライバーオブジェクトを指します。したがって、宛先が決まると、要求を処理する関数の表も決まるという関係です。
3.3. ファイルオブジェクト ── 「開いた1回」の状態
CreateFile が成功するたびに、カーネルはファイルオブジェクトを1つ作ります。表しているのはディスク上のファイルそのものではなく、ファイルやデバイスを開いた1回ぶんの状態です。8
同じファイルを2回開けば、ファイルオブジェクトも2つできます。同期ハンドルの現在のファイルポインター、開いたときの共有モードやフラグは、それぞれの開いた状態に属します。アプリが受け取る HANDLE は、プロセスごとのハンドルテーブルを経由した、そのファイルオブジェクトへの参照です。9
flowchart LR
subgraph P["プロセス(ユーザーモード)"]
H1["HANDLE 0x1A4"]
H2["HANDLE 0x1B8"]
end
subgraph K["カーネル空間"]
FO1["ファイルオブジェクト 1<br/>report.csv を読み取りで開いた<br/>現在オフセット: 4096"]
FO2["ファイルオブジェクト 2<br/>report.csv を追記で開いた<br/>現在オフセット: 65536"]
DO["デバイスオブジェクト<br/>HarddiskVolume3 相当"]
DR["ドライバーオブジェクト NTFS<br/>MajorFunction = 処理関数の表"]
end
H1 --> FO1
H2 --> FO2
FO1 --> DO
FO2 --> DO
DO --> DR
図3: 3つのオブジェクトの関係。ハンドルはハンドルテーブル経由でファイルオブジェクトを指し、ファイルオブジェクトはデバイスへ、デバイスはドライバーへつながる
「同じファイルを2回開く」と「ハンドルを複製する」は違う
| 操作 | ファイルオブジェクト | ファイルポインター |
|---|---|---|
| 同じファイルを別々に開く | 開いた回数ぶん作られる | それぞれ独立する |
DuplicateHandle でハンドルを複製する |
同じオブジェクトを参照する | 共有する |
同じファイルを指すハンドルでも、開き直したのか複製したのかで、共有している状態が変わります。
ハンドルリークと共有違反も、開いた状態から考える
ファイルハンドルがリークすると、ファイルオブジェクトと、その先のリソースが参照され続けます。調査で数えるのはハンドルテーブルのエントリです。Process Explorerや handle.exe が見せる情報はこれに対応します(「Process Explorer / Handle / VMMap実践」、調査記「産業用カメラ長期稼働クラッシュ調査 - ハンドルリーク編」)。
共有違反(sharing violation)は、既存の開いた状態の共有モードと、新しい CreateFile の要求を突き合わせて判定されます。排他制御の実務は「ファイル連携の排他制御の基礎知識」で扱っています。
4. IRP ── I/O要求は小包になる
4.1. なぜパケットにするのか
I/Oマネージャーは、開く・読む・書くといった要求をIRP(I/O Request Packet)に詰めてドライバーへ渡します。デバイスドライバーへの要求のほとんどが、この形式です。3
パケットにする理由は、要求の発行と完了を切り離せるようにするためです。ディスクなどのデバイスはCPUと同じ速度では動きません。要求を独立したパケットとして保持できれば、発行した後、別のタイミングで完了させられます。2
IRPの中も、全体の情報と各ドライバーへの指示に分かれています。17
| 領域 | 持っているもの |
|---|---|
| ヘッダー | 要求全体の情報 |
| I/Oスタックロケーション | 経由するドライバーごとの要求の種類とパラメーター |
I/Oスタックロケーションは、経由予定のドライバーの数に応じて並びます。各ドライバーは自分用の領域を読み、どの処理を求められているかを判断します。
4.2. デバイススタックを降りていく
デバイスオブジェクトが積み重なったものをデバイススタックと呼びます。18 ローカルディスク上のファイルを読む場合、要求はおおよそ次の経路をたどります。
flowchart TB
IOM["I/OマネージャーがIRPを組み立てる<br/>(IRP_MJ_READ+スタックロケーション)"]
subgraph FSSTACK["ファイルシステム側のスタック"]
FLT["ファイルシステムフィルター<br/>(ウイルス対策・暗号化・Procmonなど)"]
NTFS["NTFS<br/>ファイル内オフセットをボリューム上の位置に変換"]
end
subgraph STSTACK["ストレージ側のスタック"]
VOL["ボリューム/パーティション管理<br/>(volmgr など)"]
DISK["ディスククラスドライバー<br/>(disk.sys)"]
PORT["ストレージポート/ミニポート<br/>(storport など)"]
end
HW[("ディスク装置")]
IOM --> FLT
FLT --> NTFS
NTFS --> VOL
VOL --> DISK
DISK --> PORT
PORT --> HW
図4: 読み取り要求が通る道。ファイルシステム側とストレージ側は別々のデバイススタックで、NTFSはストレージ側宛ての下位IRPを新たに発行して仕事を依頼する
フィルターはOSが用意した拡張点
ウイルス対策ソフトがファイルI/Oを検査できるのは、OSが公式に用意した、要求の途中へ介在する仕組みを使っているからです。19 Process Monitorもこの位置でI/Oを記録します。
「あの環境でだけファイルアクセスが遅い」という調査では、この経路に入っているフィルターが最初に確認すべき候補になります。詳しくは第6回で扱います。
1つのIRPがディスクまで通り抜けるわけではない
アプリは「このファイルのオフセット4096から8KB」と要求します。NTFSはそれをボリューム上のクラスターへ、ストレージスタックはディスクのセクターへと対応付けます。上の層は、下の層の詳細を知らずに要求できます。
ここで重要なのは、ファイルシステム側とストレージ側が別のスタックだという点です。NTFSは、ファイル宛てのIRPを処理するために、ボリューム宛ての下位IRPを新たに作って発行します。同じ1本のIRPを、そのままアプリからディスクまで渡すわけではありません。
断片化したファイルでは、1回の読み取りが複数の下位IRPに分かれることもあります。要求の粒度と寿命は、層ごとに分けて考える必要があります。
4.3. 各ドライバーの3つの選択肢
IRPを受け取ったドライバーの処理は、基本的に次の3つに整理できます。310
| 処理 | 主な動作 | 例 |
|---|---|---|
| 自分で完了する | IoCompleteRequest で完了させる |
手元のキャッシュで要求を満たす |
| 下のドライバーへ渡す | IoCallDriver で次のデバイスへ送る |
フィルターが検査後に転送する |
| 保留する | STATUS_PENDING を返し、後から完了させる |
ハードウェアの応答を待つ |
flowchart TB
RECV["ドライバーがIRPを受け取る"]
Q{"この要求をどう扱うか"}
DONE["(1) 自分で完了する<br/>IoCompleteRequest を呼ぶ<br/>例: キャッシュにあるデータで即答"]
PASS["(2) 下のデバイスへ渡す<br/>IoCallDriver を呼ぶ<br/>例: フィルターが検査して素通し"]
PEND["(3) 保留(ペンディング)にする<br/>STATUS_PENDING を返してIRPをキューへ<br/>例: ハードウェアの応答待ち"]
LATER["割り込みなどを契機に<br/>あとから IoCompleteRequest"]
UP["完了処理がスタックを逆順に上る<br/>(各層の完了ルーチンが呼ばれる)"]
RECV --> Q
Q --> DONE
Q --> PASS
Q --> PEND
PASS -->|"下の層がその場で完了した"| UP
PASS -->|"下の層が保留にした<br/>(保留は呼び出し元まで伝わる)"| LATER
PEND --> LATER
DONE --> UP
LATER --> UP
図5: IRPを受け取ったドライバーの3択。3つは排他ではなく「下へ渡した先で保留になる」のがいちばん普通の道で、どれも最後はIoCompleteRequestによる完了で終わる
「転送」と「保留」は同じ要求で重なる
この3つは、排他的な選択肢ではありません。典型的なのは、上のドライバーが下へ渡し、その先のどこかが保留にする流れです。この場合、STATUS_PENDING は間のドライバーを通って呼び出し元へ伝わります。
後から下の層が要求を完了させると、転送時に完了ルーチンを登録していた各層が逆順で呼び戻されます。要求を下へ渡す処理と、結果を上へ返す完了処理は、分けて読んでください。
この組み合わせが、連載で扱う仕組みの基礎です。キャッシュで即完了できれば速くなり(第4回)、途中にはフィルターを重ねられます(第6回)。保留と完了を分けられるため、非同期I/Oでは遅いデバイスを待つ間もスレッドが別の仕事をできます(第2回・第3回)。
5. ReadFile一往復を追う
ここでは、キャッシュを外れてディスクへ読みに行く場合の ReadFile を追います。図では、まず要求を発行する「行き」、次にデータを読み終えた後の「帰り」を見てください。
sequenceDiagram
participant App as アプリのスレッド
participant IOM as I/Oマネージャー
participant FS as フィルター+NTFS
participant ST as ストレージスタック
participant HW as ディスク装置
App->>IOM: ReadFile → NtReadFile(システムコール)
Note over IOM: ハンドルからファイルオブジェクトを解決し<br/>IRP(IRP_MJ_READ)を組み立てる
IOM->>FS: IoCallDriver(スタックの先頭へ)
FS->>ST: 位置をボリューム上に翻訳し<br/>ストレージスタック宛ての下位IRPを発行
ST->>HW: 読み取りコマンドを発行
ST-->>FS: STATUS_PENDING(下位IRPは保留)
FS-->>IOM: 元のIRPも保留のまま返る<br/>(行きはここで終わり)
Note over App: 同期I/O: ここで完了を待って眠る<br/>非同期I/O: 制御が戻り別の仕事ができる
HW-->>ST: 割り込み「データを読み終えた」
Note over ST: 割り込み処理(ISR)から<br/>DPCで完了処理を続行
ST->>FS: 下位IRPを完了(IoCompleteRequest)<br/>NTFS側の完了ルーチンが受け取る
Note over FS: 必要な下位IRPが<br/>すべて完了したら
FS->>IOM: 元のIRP(IRP_MJ_READ)を完了
Note over IOM: 各層の完了ルーチンを逆順に実行し<br/>要求元スレッドへのAPCで結果を確定
IOM->>App: 状態とバイト数が確定(イベント通知など)
図6: キャッシュを外れたReadFileの一往復。下位IRPの完了と元のIRPの完了は別のステップで、「行き」と「帰り」も別々のイベントとして進む
5.1. 行きと帰りは別の出来事
図6の区切りは、STATUS_PENDING です。ストレージドライバーはハードウェアにコマンドを発行した後、要求を保留にして戻ります。そこで「行き」がいったん終わり、後から割り込みなどを契機に「帰り」の完了処理が進みます。10
| 段階 | 起きること |
|---|---|
| 発行 | ハンドルから宛先を求め、元のIRPと必要な下位IRPを送る |
| 保留 | ハードウェアの処理が終わっていなければ、要求を未完了のまま保持する |
| 下位の完了 | 読み取り終了後、ストレージ側の下位IRPが完了する |
| 元の要求の完了 | 必要な下位IRPがすべて完了すると、元のIRPも完了し、状態とバイト数が確定する |
同期と非同期の違いは、呼び出し元がいつ戻るか
カーネルの中には、同期I/O専用の別の仕組みがあるわけではありません。同期I/Oは「完了する前には呼び出しが戻らない」という保証です。ここで説明している完了待ちは要求がペンディングになったときに発生し、その場で完了できる要求なら、後からの完了を待たずに結果を返せます。11
Win32では、ハンドルを開くときの FILE_FLAG_OVERLAPPED で同期・非同期の扱いを選びます。ただし、OVERLAPPED 構造体そのものは、発行中の操作1つごとに必要です。「ハンドルの設定」と「個々の操作の状態」は分けて考えます。
この違いが分かると、「非同期かどうかを開くときに決めるのはなぜか」「非同期のはずなのに、その場で完了するとはどういうことか」を第2回につなげられます。.NETの async/await でI/O待ちのためだけにスレッドを使い続けなくてよい理由も、発行と完了の分離にあります(「C# async/await実務判断表」参照)。
5.2. 例外もある ── IRPを作らない近道
すべてのI/OがIRPになるわけではありません。 キャッシュにあるファイルデータを同期的に読み書きするときには、ファストI/Oという、IRPを組み立てずにキャッシュとの間で直接コピーする近道があります。
Procmonで詳細出力を有効にしたとき、Operation列の FASTIO_ で始まる行が、その経路に対応します。近道を使えない条件とキャッシュマネージャーとの関係は第4回で扱います。
したがって、調査では「IRPを使う基本経路」と「IRPを作らない近道」の両方を考えます。ReadFileを呼んだという理由だけで、必ずIRPが作られたとは判断しません。
6. CloseHandleの裏側 ── cleanupとcloseは別物
開いたものを閉じるときも、段階を分ける必要があります。CloseHandle が行うのは、プロセスのハンドルテーブルからエントリを1つ消すことです。
ファイルオブジェクトには、ハンドルの数であるハンドルカウントと、カーネル内の参照を管理する参照カウントがあります。ハンドルがなくなる時点と、オブジェクトへの参照がすべてなくなる時点は、同じとは限りません。
sequenceDiagram
participant App as アプリ
participant OB as オブジェクトマネージャー
participant IOM as I/Oマネージャー
participant FS as ファイルシステム
App->>OB: CloseHandle(h)
Note over OB: ハンドルテーブルからエントリを削除<br/>ハンドルカウントを減らす
alt それが最後のハンドルだった
IOM->>FS: IRP_MJ_CLEANUP
Note over FS: そのファイルオブジェクトの<br/>未完了I/Oの取り消しやロック解放
end
Note over OB: ただし未完了I/Oやセクション(メモリマップ)など<br/>カーネル内の参照が残っていれば<br/>ファイルオブジェクトはまだ生きている
alt 参照カウントも0になった
IOM->>FS: IRP_MJ_CLOSE
Note over FS: ファイルオブジェクトの後始末が完了<br/>ここで初めて「閉じ終わった」
end
図7: cleanup(最後のハンドルが閉じた)とclose(参照もすべて消えた)の2段階
6.1. 最後のハンドルと、最後の参照を区別する
| 通知 | 何が起きたか | まだ残りうるもの |
|---|---|---|
IRP_MJ_CLEANUP |
そのファイルオブジェクトの最後のハンドルが閉じた | 未完了I/Oなどによる参照 |
IRP_MJ_CLOSE |
ファイルオブジェクトの参照カウントが0になった | ファイルオブジェクトは解放される段階 |
IRP_MJ_CLEANUP の説明には、未完了のI/O要求が残っていれば、ファイルオブジェクトの解放はまだの場合があると明記されています。12 IRP_MJ_CLOSE はその後に送られますが、cleanupの直後とは限りません。13
6.2. 「使用中」を調べるときは、ハンドルの先も見る
メモリマップは、ファイルハンドルとは別に寿命を持ちます。 マップされたセクションがファイルオブジェクトへの参照を保持している間は、元のハンドルを閉じても解放されません。ビューのアンマップとセクションのクローズまで確認します(「共有メモリの落とし穴と実務ベストプラクティス」参照)。
ハンドルが見つからなくても、調査は終わりではありません。 マップ済みイメージなど、カーネル内の参照がファイルを保持している場合があります。Process Explorerの検索が、ハンドルだけでなくDLL(マップ済みファイル)も対象にしているのは、このためです。
.NETでも、最終的に閉じられることと、必要な時点で閉じることは別です。 SafeFileHandle やファイナライザーがいつか閉じてくれることを当てにすると、閉じ忘れたハンドルがcleanupを遅らせ、共有違反やロック保持を長引かせます。
7. 自分の目で確かめる
名前空間とI/Oの流れは、管理者権限のあるWindows PCで観察できます。まず安全なローカルファイルで、開く・読む・書く・閉じる操作を追ってみてください。
7.1. 観察に必要なものを用意する
| 必要なもの | 準備と注意 |
|---|---|
| 管理者権限 | Process Monitorはカーネルドライバーを読み込むため、管理者として実行します。WinObjも、管理者でないと見えないオブジェクトがあります |
| Sysinternalsのツール | Microsoftが無償配布するWinObjとProcess Monitorを使います。Sysinternals Suiteでまとめて入手することもできます。展開してexeを実行でき、インストーラーは不要です |
| 観察する操作 | メモ帳でファイルを1つ保存する、小さなファイルを1つコピーする程度で十分です。業務用共有フォルダーや本番機は使わず、手元のローカルディスクで試します |
7.2. WinObjで名前から実体へのリンクを見る
WinObjで GLOBAL?? ディレクトリを開くと、C: が \Device\HarddiskVolumeN へのシンボリックリンクであることを確認できます。次に \Device 配下を見ると、ドライバーが作ったデバイスオブジェクトの実名が分かります。図1の名前とリンクを、実際のPCで照合する手順です。15
7.3. ProcmonでIRPとファストI/Oを読み分ける
Process Monitorの Filter > Enable Advanced Output を有効にすると、Operation列が ReadFile などの表示から、IRP_MJ_READ や FASTIO_READ というカーネル側の語彙に変わります。
ファイルコピーでは、IRP_MJ_CREATE → FASTIO_READ / IRP_MJ_READ → IRP_MJ_WRITE → IRP_MJ_CLEANUP → IRP_MJ_CLOSE という、本記事で説明した操作を探します。読み取りの経路と、cleanupからcloseまでの段階を意識して追うと、単なる操作名の一覧ではなくなります。
Procmonの実務的な使い方は「Process Monitor(ProcMon)実践ガイド」にまとめています。
7.4. .NETの指定をWin32のフラグに対応付ける
Windows上のC#の FileStream は、内部で CreateFileW を使って開いたハンドルを SafeFileHandle として保持します。コンストラクターの FileOptions は、次のようにWin32のフラグへ対応します。
.NET (FileOptions) |
Win32 (CreateFile フラグ) |
意味(関連する回) |
|---|---|---|
Asynchronous |
FILE_FLAG_OVERLAPPED |
非同期I/O用にハンドルを開く(第2回・第3回) |
WriteThrough |
FILE_FLAG_WRITE_THROUGH |
書き込みをキャッシュで止めない(第4回) |
SequentialScan |
FILE_FLAG_SEQUENTIAL_SCAN |
先読みのヒント(第4回) |
RandomAccess |
FILE_FLAG_RANDOM_ACCESS |
先読み抑制のヒント(第4回) |
DeleteOnClose |
FILE_FLAG_DELETE_ON_CLOSE |
最後のハンドルが閉じたら削除(6章の仕組みの応用) |
.NET 6以降なら、File.OpenHandle と RandomAccess クラスを使い、FileStream を挟まずに「ハンドル+オフセット指定I/O」という形でも扱えます。この表の右側を理解しておくと、左側のオプションを挙動から選べます。
8. まとめ
WindowsのI/Oは、名前解決 → 開いた状態 → 要求の発行 → 完了 → 参照の解放の順に追うと整理できます。
- 名前を解決して宛先を決める。
C:はシンボリックリンク、\\.\はWin32デバイス名前空間への指定、UNCはリダイレクターへの解決です。ファイルもデバイスも同じAPIで扱える土台は、この名前解決の一貫性にあります。45 - 3つのオブジェクトを分ける。 ドライバーは処理関数の表、デバイスは宛先、ファイルオブジェクトは開いた1回の状態です。
HANDLEは、そのファイルオブジェクトを参照します。6789 - IRPは要求を運び、完了は別のタイミングで進められる。 要求のほとんどはIRPとしてデバイススタックへ送られ、各ドライバーが完了・転送・保留を組み合わせます。下位IRPと元のIRPの寿命は別であり、ファストI/OではIRPを作らない経路もあります。231810
- 同期I/Oと非同期I/Oは、発行と完了の関係から考える。 同期I/Oは完了前に戻らない保証です。ペンディングになった要求では、発行と、割り込みなどを契機に進む完了が別の出来事になります。1110
- ハンドルを返すことと、オブジェクトの解放は同じではない。 cleanupは最後のハンドル、closeは最後の参照がなくなった段階です。「閉じたのに使用中」の調査では、この区別が役立ちます。1213
続きは第2回「同期I/Oと非同期I/O ── OVERLAPPEDの本当の意味」です。 発行と完了の分離をアプリ側から使うための FILE_FLAG_OVERLAPPED、完了通知の4方式、キャンセル、「非同期のはずが同期で返ってくる」条件を扱います。
関連記事
- Process Monitor(ProcMon)実践ガイド ── 「設定が読まれない」「ACCESS DENIED」を10分で特定する
- Process Explorer / Handle / VMMap実践 ── ハング・リーク・「ファイルが使用中」を今この瞬間の状態から追う
- 産業用カメラ長期稼働クラッシュ調査 - ハンドルリーク編
- ファイル連携の排他制御の基礎知識 - ファイルロックと原子的 claim のベストプラクティス
- 共有メモリの落とし穴と実務ベストプラクティス
- C# async/await実務判断表 - Task.RunとConfigureAwait
- ネットワークドライブとUNCパスの落とし穴 ── 業務アプリでファイルサーバー(共有フォルダ)を扱う実務
- MAX_PATHとWindowsのパス・ファイル名の落とし穴 ── 260文字制限、予約名、末尾ドット、大文字小文字
関連する相談領域
合同会社小村ソフトでは、Windows業務アプリのファイルI/Oまわりの設計・不具合調査(ハンドルリーク、「ファイルが使用中」、特定環境でのI/O遅延など)を扱っています。
参考リンク
-
Microsoft Learn, Windows Internals - Sysinternals. 書籍『Windows Internals』(邦訳『インサイドWindows』)の紹介ページ。WindowsのカーネルアーキテクチャーとI/Oシステムを含む内部構造を扱う定番書であり、本連載が扱う内容をさらに深く学ぶための出発点として。 ↩
-
Microsoft Learn, I/O manager. Windowsカーネルモード I/Oマネージャーがアプリケーションとデバイスドライバーの提供するインターフェイスの間の通信を管理すること、デバイスはOSと一致しない速度で動作するため、OSとドライバーの間の通信は主としてIRP(I/O要求パケット)を通じて行われること、IRPがネットワークパケットやWindowsメッセージに似た存在としてOSからドライバーへ、またドライバーからドライバーへ渡されることについて。 ↩ ↩2 ↩3
-
Microsoft Learn, I/O request packets. デバイスドライバーに送られる要求のほとんどがIRPに詰められること、OSのコンポーネントやドライバーがIoCallDriver(デバイスオブジェクトへのポインターとIRPへのポインターを取る)でドライバーへIRPを送ること、IRPが通常はデバイススタックとして積み重なった複数のドライバーによって処理され、まずスタック最上位のデバイスオブジェクトへ送られること、各ドライバーがIRPを処理して完了させるか、下のドライバーへ転送するかを選べることについて。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Introduction to MS-DOS device names. MS-DOSデバイス名がNTスタイルのデバイス名へのシンボリックリンクであること、ユーザーモードのWindowsアプリはMS-DOSデバイス名(ドライブレターやCOMポート名)でデバイスにアクセスするのに対し、ドライバーやカーネルはNTスタイルの名前を使うこと、ドライバーがIoCreateSymbolicLinkで \DosDevices\名前 からデバイスへのシンボリックリンクを作ることについて。 ↩ ↩2 ↩3
-
Microsoft Learn, Naming files, paths, and namespaces. CON、PRN、AUX、NUL、COM1〜COM9、LPT1〜LPT9がファイル名として予約されていること、Win32の名前空間に「ファイル名前空間」と「デバイス名前空間」があり、”\\.\” プレフィックスがWin32デバイス名前空間へのアクセスを意味すること(例: \\.\PhysicalDrive0)、これらの名前解決の規則について。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Introduction to driver objects. ドライバーのロード時にI/OマネージャーがDRIVER_OBJECT構造体を作ること、ドライバーオブジェクトがドライバーの標準ルーチン群への入口(ディスパッチテーブルであるMajorFunction配列を含む)を保持すること、I/Oマネージャーがこの表を使って要求に対応する処理関数を呼び出すことについて。 ↩ ↩2 ↩3
-
Microsoft Learn, Introduction to device objects. DEVICE_OBJECT構造体が論理・仮想・物理デバイスを表し、I/O要求の宛先(ターゲット)になること、ドライバーがIoCreateDeviceでデバイスオブジェクトを作ること、デバイスオブジェクトが自分を作成したドライバー(ドライバーオブジェクト)と結び付いていることについて。 ↩ ↩2 ↩3
-
Microsoft Learn, Using files in a driver. カーネルにおいてファイルオブジェクトが「開かれたファイル(またはデバイス)のインスタンス」を表すこと、ファイルのオープンのたびにファイルオブジェクトが作られ、開いた際のコンテキスト(現在のバイトオフセットなど)を保持することについて。 ↩ ↩2 ↩3
-
Microsoft Learn, File handles. CreateFileが返すファイルハンドルが、プロセスに固有であり、開かれたファイルオブジェクトと結び付いていること、同じファイルを複数回開けばそれぞれ別のハンドル(と開いた状態)になること、ハンドルが不要になったらCloseHandleで閉じるべきことについて。 ↩ ↩2 ↩3
-
Microsoft Learn, Completing IRPs. I/O操作を完了させるのはIoCompleteRequestの呼び出しであること、完了時にはスタック上位のドライバーが登録したIoCompletionルーチンが順に呼ばれること、要求の完了が発行とは別のタイミングで起こり、最終的に要求元へ状態が返されるまでの流れについて。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Synchronous and asynchronous I/O. 同期I/Oでは関数がI/O完了まで戻らずスレッドが待たされるのに対し、非同期I/O(オーバーラップI/O)では要求を発行した関数がすぐ戻り、スレッドが他の仕事を続けられること、非同期I/OにはFILE_FLAG_OVERLAPPEDを指定してハンドルを開く必要があること、完了の通知を受け取る複数の方法があることについて。 ↩ ↩2 ↩3
-
Microsoft Learn, IRP_MJ_CLEANUP. この要求の受信が「対象デバイスオブジェクトに関連付けられたファイルオブジェクトの最後のハンドルが閉じられた」ことを示すこと、ただし未処理のI/O要求のためにファイルオブジェクトの解放はまだの場合があること、このIRPがハンドルを閉じたプロセスのコンテキストで送られることについて。 ↩ ↩2 ↩3
-
Microsoft Learn, IRP_MJ_CLOSE. この要求の受信が「ファイルオブジェクトの参照カウントが0になり、ファイルオブジェクトが解放されようとしている」ことを示すこと、cleanup要求の後に送られるが、未処理I/Oの完了を待つため即座に続くとは限らないことについて。 ↩ ↩2 ↩3
-
Microsoft Learn, CreateFileW function. CreateFileがファイルだけでなく、物理ディスク・ボリューム・コンソール・通信ポート(COMポート)・パイプなどのデバイスを開いてハンドルを返せること、デバイスを開く際に “\\.\” 形式の名前を使うこと、FILE_FLAG_OVERLAPPEDをはじめとする各種フラグの意味について。 ↩ ↩2
-
Microsoft Learn, WinObj - Sysinternals. WinObjがNTオブジェクトマネージャーの名前空間を表示するツールであり、デバイスオブジェクトやシンボリックリンクを含む名前空間内のオブジェクトを閲覧できることについて。 ↩ ↩2
-
Microsoft Learn, IRP major function codes. IRPのメジャーファンクションコード(IRP_MJ_CREATE、IRP_MJ_READ、IRP_MJ_WRITE、IRP_MJ_CLEANUP、IRP_MJ_CLOSE、IRP_MJ_DEVICE_CONTROL、IRP_MJ_PNPなど)の一覧と、それぞれの要求が何を意味し、どのドライバーが対応すべきかについて。 ↩
-
Microsoft Learn, I/O stack locations. I/OマネージャーがレイヤードドライバーのチェーンにあるドライバーごとにIRP内へI/Oスタックロケーションを用意すること、各スタックロケーションにメジャー/マイナーファンクションコードとその要求のパラメーターが入ること、各ドライバーがIoGetCurrentIrpStackLocationで自分用のスタックロケーションを取得して要求内容を知ることについて。 ↩
-
Microsoft Learn, Device nodes and device stacks. デバイスオブジェクトが積み重なってデバイススタックを構成すること、IRPがまずスタック最上位のデバイスオブジェクトに送られ、各層で処理または下位への転送が行われること、フィルタードライバーのデバイスオブジェクトがスタックに挟まる形で存在することについて。 ↩ ↩2
-
Microsoft Learn, Filter Manager Concepts. フィルターマネージャーがWindowsに付属するカーネルモードドライバーであり、ミニフィルタードライバーがファイルシステムへのI/O要求に対して事前(pre)・事後(post)のコールバックで割り込めること、各ミニフィルターの割り込み位置(アルティチュード)によってI/Oスタック内の順序が決まることについて。 ↩
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
Windows I/Oの深層(第6回・最終回) ── ミニフィルターの仕組みとProcmonによる遅延調査
ミニフィルターがファイルI/Oを監視・制御する仕組みを解説します。FltMgr、アルティチュード、pre/postコールバック、fltmcの読み方を整理し、Procmonで遅い操作を特定する手順と、除外設定・Dev Driveの注意点をまとめます。
Windows I/Oの深層(第4回) ── キャッシュマネージャー:あなたのWriteFileはいつディスクに届くのか
Windowsのキャッシュマネージャーを図解で解説する連載の第4回です。ファイルマッピングとして実装されたキャッシュ、先読みと遅延書き込み、FlushFileBuffersやFILE_FLAG_NO_BUFFERINGの使い分け、電源断でデータが消える条件までを整理します。
Windows I/Oの深層(第2回) ── 同期I/Oと非同期I/O:OVERLAPPEDの本当の意味
Windowsの同期I/Oと非同期I/O(オーバーラップI/O)を図解で解説する連載の第2回です。FILE_FLAG_OVERLAPPEDの意味、完了通知の4方式、非同期のはずが同期完了する条件、キャンセルの作法、.NETとの対応までを整理します。
Windows I/Oの深層(第5回) ── NTFSの内部構造:MFTから理解するファイルシステム
NTFSの内部構造を図解で解説する連載の第5回です。MFTとファイルレコード、複数データストリーム(Zone.Identifier)、ハードリンクと8.3名、リパースポイント、2つのジャーナル、スパースと圧縮までを開発者視点で整理します。
Windows I/Oの深層(第3回) ── I/O完了ポート(IOCP)と.NETスレッドプール:async/awaitの地下室
I/O完了ポート(IOCP)を図解で解説する連載の第3回です。完了キューとスレッド数制御を一体化した設計、コンカレンシー値とLIFO解放、.NETスレッドプールとasync/await継続の実行スレッドまでを整理します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
不具合調査 / 長期稼働テーマ
再現しにくい不具合、通信停止、長期稼働障害、失敗パス検証を整理するトピックです。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
Windowsアプリ開発
業務アプリ、装置連携、通信ツールなどの Windows ソフト開発を支援します。
不具合調査・原因解析
再現しにくい障害、長期稼働後の不具合、通信停止などの調査を支援します。
よくある質問
この記事のテーマについて、相談時によくある質問をまとめています。
- IRPとは何ですか?
- IRP(I/O Request Packet)は、WindowsカーネルのI/Oマネージャーがアプリケーションの読み書き要求などを詰めて、デバイスドライバーに渡すためのパケットです。マイクロソフトのドライバー開発ドキュメントは、デバイスドライバーに送られる要求のほとんどがIRPに詰められると説明しています。IRPは要求の種類(作成・読み取り・書き込み・クリーンアップなど)を示すメジャーファンクションコードと、経由するドライバーごとのスタックロケーションを持ち、デバイススタックを上から下へ渡されながら処理されます。各ドライバーはIRPを自分で完了するか、下のドライバーへ渡すか、保留(ペンディング)にして後から完了させるかを選びます。アプリ開発者がIRPを直接触ることはありませんが、Process MonitorのOperation列に出るIRP_MJ_READなどの表記はこの仕組みそのものです。
- なぜWindowsではファイルもシリアルポートもプリンターも同じCreateFileで開けるのですか?
- CreateFileに渡した名前が、どれもオブジェクトマネージャーの名前空間で最終的にデバイスオブジェクトへ解決され、I/Oマネージャーがそのデバイスに結び付いたファイルオブジェクトを作ってハンドルを返す、という同じ道筋をたどるからです。C:のようなドライブレターの実体は、\Device\HarddiskVolume3のようなNTデバイス名へのシンボリックリンクで、\\.\COM1のような指定も同様にシリアルポートのデバイスオブジェクトへ解決されます。解決先がどのデバイスであっても、その先の要求はすべてIRPという同じ形式に詰められてドライバーへ届くため、ファイルもデバイスも同じAPIで開いて読み書きできます。UNCパスも同じ仕組みで、ネットワークリダイレクターのデバイスに解決されるだけです。この「名前空間+パケット」という設計が、WindowsのI/Oの一貫性の正体です。
- CloseHandleを呼んだのにファイルがすぐ解放されないことがあるのはなぜですか?
- CloseHandleが行うのは「ハンドルを1つ返す」ことであって、「ファイルを閉じる」ことではないからです。カーネル内のファイルオブジェクトには、ハンドルの数(ハンドルカウント)と、カーネルコンポーネントからの参照の数(参照カウント)という2つのカウントがあります。最後のハンドルが閉じられるとファイルシステムにはIRP_MJ_CLEANUPが送られますが、未完了のI/Oやメモリマップトファイルのセクションなど、カーネル内の参照が残っている間はファイルオブジェクト自体が生き続け、参照カウントが0になって初めてIRP_MJ_CLOSEが送られます。メモリマップトファイルを使った後にファイルが削除できない、アプリを閉じたのに「ファイルが使用中」と言われる、といった現象の多くはこの2段階の仕組みで説明できます。
- IRPやデバイススタックの知識は、アプリ開発者に何の役に立ちますか?
- 直接IRPを書くことはなくても、調査と設計の両方で効いてきます。まず、Process MonitorのOperation列(詳細出力を有効にした場合)はIRP_MJ_CREATEやIRP_MJ_READといったIRPの用語そのもので表示されるため、この層の言葉を知っているとログが読めるようになります。次に、ウイルス対策ソフトなどのフィルタードライバーがすべてのファイルI/Oの通り道に挟まっていることが分かるので、「特定の環境でだけファイルアクセスが遅い」という問題の調査で当たりを付けられます。さらに、WindowsのI/Oが発行と完了を分離できる作りで、同期I/Oは「完了する前には戻らない」という保証にすぎないと理解していれば、非同期I/OやI/O完了ポート、.NETのasync/awaitの挙動を仕組みから納得して使えます。