File.ReadAllText の1行、あるいは ReadFile の1呼び出し。この関数が戻ってくるまでの間に、Windowsの中では何が起きているのでしょうか。
不具合調査でProcess Monitorを開くと、IRP_MJ_READ や FASTIO_READ という見慣れない語が並びます。ハンドルリークを追いかけると、CloseHandle を呼んだはずのファイルがまだ生きています。「ネットワークドライブだと挙動が違う」「ウイルス対策ソフトを入れた環境でだけ遅い」──業務アプリの現場で繰り返し出会うこれらの現象は、すべてWindowsのI/Oシステムという同じ地面の上で起きています。
この記事から、その地面を底から掘る連載「Windows I/Oの深層」を始めます。名著『インサイドWindows』(Windows Internals)がカーネルの設計まで降りて解説するように、この連載も「APIの使い方」ではなく「なぜそう動くのか」を扱います。1 予定している構成は次のとおりです。
- I/Oシステムの全体像 ── すべての読み書きはIRPになる(本記事)
- 同期I/Oと非同期I/O ── OVERLAPPEDの本当の意味
- I/O完了ポート(IOCP)と.NETスレッドプール ── async/awaitの地下室
- キャッシュマネージャー ── あなたのWriteFileはいつディスクに届くのか
- NTFSの内部構造 ── MFTから理解するファイルシステム
- フィルタードライバーとミニフィルター ── ProcmonとウイルススキャンがI/Oに割り込める理由
第1回の本記事は、以降のすべての回の土台になる「登場人物と要求の流れ」を図で押さえます。ドライバーを書くための記事ではありません。アプリケーション開発者が、自分の呼んだAPIの行き先を最後まで見届けられるようになるための記事です。
1. まず結論
- WindowsのI/Oはパケット駆動です。デバイスドライバーに送られる要求のほとんどは、IRP(I/O Request Packet)というパケットに詰められ、ドライバーの積み重ね(デバイススタック)を上から下へ流れていきます。23
- CreateFileが開くのは「ファイル」とは限りません。名前の解決はオブジェクトマネージャーの名前空間で行われ、
C:の実体は\Device\HarddiskVolume3のようなNTデバイス名へのシンボリックリンクです(2章)。45 - 登場人物は3つのオブジェクトです。処理関数の表を持つ「ドライバーオブジェクト」、要求の宛先になる「デバイスオブジェクト」、開いた1回ぶんの状態を持つ「ファイルオブジェクト」。あなたの持つHANDLEは、ファイルオブジェクトへの参照です(3章)。6789
- 各ドライバーの選択肢は3つだけです。IRPを「自分で完了する」「下のドライバーへ渡す」「保留にして後から完了させる」。この3択の組み合わせが、フィルタードライバーもキャッシュも非同期I/Oも全部説明します(4章)。310
- カーネルの底に「同期I/O」という別の仕組みはありません。同期I/Oの正体は「完了する前には戻らない」という保証で、要求がペンディングになったときだけ待ちが発生します。ここが第2回・第3回の入口になります(5章)。11
- CloseHandleは「閉じる」ではなく「ハンドルを1つ返す」です。最後のハンドルが閉じるとcleanup、カーネル内の参照もすべて消えるとcloseという2段階で、「閉じたのに使用中」の正体はこの差にあります(6章)。1213
- この層は自分の目で観察できます。WinObjで名前空間を、Process MonitorでIRPの流れを見られます。Procmonの表示語彙はこの記事の用語そのものです(7章)。14
2. 「すべてがファイルに見える」の正体
2.1. CreateFileに渡した名前はどこへ行くのか
Win32のファイルAPIはすべて「名前」から始まります。C:\project\report.csv のようなパス、\\server\share\data.csv のようなUNCパス、\\.\COM3 のようなデバイス指定──これらはすべて同じ CreateFile に渡せます。15
この一貫性の裏には、カーネルのオブジェクトマネージャーが管理する単一の名前空間があります。カーネルの中では、デバイスもイベントも共有メモリセクションも、すべて「オブジェクト」としてこのツリー状の名前空間に登録されています。SysinternalsのWinObjを使うと、この名前空間をそのまま覗けます。14
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 配下にある
ポイントは、Win32アプリが使う名前(ドライブレターやCOMポート名)と、カーネルが使う名前(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の名前解決。前半はオブジェクトマネージャー、デバイスに到達した後半はファイルシステムの仕事
この図から、普段の疑問がいくつか解けます。
\\.\プレフィックスの意味。\\.\PhysicalDrive0や\\.\COM10の\\.\は、Win32デバイス名前空間(≒\??ディレクトリ)を直接指すための記法です。ドライブレターを経由せず、リンクの置き場を直接指名しています。515CONやNULがファイル名に使えない理由。これらはMS-DOSデバイス名として予約されており、パスのどこに書いてもデバイス側に解決されうるからです。5 このあたりの実務的な落とし穴は「MAX_PATHとWindowsのパス・ファイル名の落とし穴」にまとめています。- UNCパスも特別扱いではない。
\\server\shareはネットワークリダイレクターのデバイス(\Device\Mup)に解決され、その先はSMBクライアントがネットワーク越しに要求を運びます。ローカルとUNCで挙動が変わる問題の根っこは、解決先のデバイスが違うことにあります(「ネットワークドライブとUNCパスの落とし穴」参照)。
なお、正確を期すと \?? は実在するディレクトリの別名ではなく、「まずログオンセッションごとのローカルなDOSデバイスマップを見て、なければ \GLOBAL?? に落ちる」という検索順を表す仮想的な入口です。net use や subst で作ったドライブレターはこのローカル側に入るため、同じPCでもユーザー(ログオンセッション)ごとに見えるドライブが違う、サービスからはユーザーのネットワークドライブが見えない、といった現象が起きます。図1に描いたのはグローバル側(\GLOBAL??)だけです。
つまり「Windowsではすべてがファイルに見える」の正確な言い方は、「すべての名前が最終的にデバイスオブジェクトに解決され、その先の要求が同じ形式(IRP)に統一されている」です。ではそのデバイスオブジェクトとは何者なのか。登場人物を整理します。
3. 登場人物は3つのオブジェクト
WindowsのI/Oシステムの構造は、3種類のカーネルオブジェクトの関係として描けます。
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構造体
class DriverObject
{
// IRP_MJ_XXX がインデックス。全28種類
public DispatchRoutine[] MajorFunction = new DispatchRoutine[28];
}
// ntfs.sys なら MajorFunction[IRP_MJ_READ] に「NTFSの読み取り処理」が入っている
3.2. デバイスオブジェクト ── 要求の宛先
ドライバーは、自分が面倒を見るデバイスごとにデバイスオブジェクト(DEVICE_OBJECT)を作ります。7 これがI/O要求の宛先です。物理デバイスと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つのオブジェクトの関係。ハンドルはハンドルテーブル経由でファイルオブジェクトを指し、ファイルオブジェクトはデバイスへ、デバイスはドライバーへつながる
この図を頭に入れると、いくつかの実務知識が「暗記」から「当然」に変わります。
- ハンドルリークの正体は、参照され続けて解放できないファイルオブジェクト(とその先のリソース)の山です。数えるべきはハンドルテーブルのエントリで、Process Explorerや
handle.exeが見せてくれるのはまさにこれです(「Process Explorer / Handle / VMMap実践」、調査記としては「産業用カメラ長期稼働クラッシュ調査 - ハンドルリーク編」)。 - 同じファイルを開いた2つのハンドルでファイルポインターが独立しているのは、ポインターがファイルオブジェクト側にあるからです。逆に
DuplicateHandleで複製したハンドルは同じファイルオブジェクトを指すので、ポインターを共有します。 - 共有違反(sharing violation)は、既存のファイルオブジェクト群の共有モードと新しい
CreateFileの要求をカーネルが突き合わせて判定しています。排他制御の実務は「ファイル連携の排他制御の基礎知識」で扱いました。
4. IRP ── I/O要求は小包になる
4.1. なぜパケットにするのか
I/Oマネージャーは、アプリからの要求(開く・読む・書く…)を受け取ると、IRP(I/O Request Packet)というパケットに詰めてドライバーに渡します。デバイスドライバーへの要求のほとんどはIRPとして届きます。3 デバイスはOSと速度が合わない(ディスクはCPUより桁違いに遅い)ので、要求を「呼び出し」ではなく「荷物」にして、発行と完了を切り離せる形にしたわけです。2
IRPには、要求の全体情報を持つヘッダーに加えて、I/Oスタックロケーションという領域が経由予定のドライバーの数だけ並んでいます。各ドライバーは自分用のスタックロケーションから「自分への指示」(メジャーファンクションコード、パラメーター)を読み取ります。17
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を新たに発行して仕事を依頼する
この図で覚えてほしいことは2つです。
- フィルターは正規の住人である。ウイルス対策ソフトがすべてのファイルI/Oを検査できるのは、ハックではなく、この「間に挟まる」仕組みがOSの公式な拡張点だからです。19 Process Monitorも同じ場所に立って全I/Oを記録しています。「あの環境でだけファイルアクセスが遅い」の調査で最初に疑うべき場所でもあります(詳細は第6回)。
- 層ごとに要求の意味が翻訳される。アプリは「このファイルのオフセット4096から8KB」と言い、NTFSがそれを「このボリュームのこのクラスター」に、ストレージスタックが「このディスクのこのセクター」に翻訳します。上の層は下の層の事情を知りません。ここで大事な補足がひとつあります。1つのIRPがアプリからディスクまで素通しで届くわけではないことです。ファイルシステム側とストレージ側は別々のスタックで、NTFSはファイル宛てのIRPを処理した結果として、ボリューム(ストレージスタック)宛ての下位IRPを新たに作って発行します。断片化したファイルなら1つの読み取りが複数の下位IRPに分かれることもあり、要求の粒度も寿命も層ごとに変わります。
4.3. 各ドライバーの3つの選択肢
IRPを受け取ったドライバーにできることは、本質的に3つしかありません。310
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つは排他の選択肢ではありません。いちばん普通の道は「(2)で下へ渡した先で、どこかの層が(3)の保留を選ぶ」で、その場合 STATUS_PENDING は間のドライバーにも呼び出し元にもそのまま伝わります。あとから下の層が完了させると、渡すときに完了ルーチンを登録していた各層が逆順で呼び戻される──つまり「渡す」と「保留」は同じ1本のIRPの上で重なって起こります。
この3択が、WindowsのI/Oの柔軟性の源泉です。
- キャッシュヒットで即完了すれば速い(第4回のキャッシュマネージャー)。
- 素通しするフィルターを何枚でも差し込める(第6回のミニフィルター)。
- 保留にできるから、遅いデバイスを待つ間スレッドを固めなくて済む(第2回・第3回の非同期I/O)。
連載の残り全部が、実はこの図の脚注です。
5. ReadFile一往復を追う
登場人物と道具が揃ったので、ReadFile 1回の往復を通しで追います。キャッシュを外れてディスクまで行くケースです(キャッシュに乗る話は第4回で)。
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. 行きと帰りは別の出来事
この図の急所は、STATUS_PENDING の行です。ストレージドライバーはハードウェアにコマンドを発行した時点でいったん手を離し、「行き」の処理はそこで終わります。データが読み上がったことは、あとから割り込みという別のイベントで通知され、そこから「帰り」の完了処理が始まります。10
つまりカーネルの中では、I/Oの発行と完了は分離できるように作られています。「同期I/O」という別の配管があるわけではなく、同期I/Oの正確な意味は「完了する前には呼び出しが戻らない」という保証です。待ちが発生するのは要求がペンディングになったときだけで、ドライバーがその場で完了できる要求(図5の「完了する」の道)なら、同期I/Oでもスレッドは一度も眠らずに結果を持って戻ります。Win32が同期・非同期をハンドルの開き方(FILE_FLAG_OVERLAPPED)で切り替えるのも、非同期が「特別な追加機能」ではなく待ち方の違いだからです。11
この視点を持つと、第2回で扱う疑問──なぜ非同期かどうかは呼び出しごとではなくハンドルを開くときに決まるのか(OVERLAPPED 構造体そのものは発行中の操作1つごとに必要です)、「非同期のはずが同期的に完了する」とは何か──が、仕組みの必然として見えてきます。.NETの async/await でI/O待ちがスレッドを消費しない理由(「C# async/await実務判断表」で実務側から書いた話)も、根拠はこの図です。
5.2. 例外もある ── IRPを作らない近道
正直に付け加えると、すべてのI/OがIRPになるわけではありません。キャッシュに乗っているファイルへの同期の読み書きのために、ファイルシステムはファストI/Oという「IRPを組み立てずにキャッシュから直接コピーする」近道を提供しています。ProcmonのOperation列で FASTIO_ で始まる行がそれです。近道が使えない条件や、キャッシュマネージャーとの関係は第4回で扱います。
6. CloseHandleの裏側 ── cleanupとcloseは別物
最後に、開いたものの閉じ方です。ここはハンドルリーク調査や「ファイルが使用中」問題に直結します。
CloseHandle がすることは、プロセスのハンドルテーブルからエントリを1つ消すことです。ファイルオブジェクトにはハンドルカウント(ハンドルの数)と参照カウント(カーネル内からの参照の数)があり、2つは別々に減っていきます。
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段階
- IRP_MJ_CLEANUPは「最後のハンドルが閉じられた」の通知です。ただし未完了のI/Oが残っていれば、ファイルオブジェクトの解放はまだかもしれない、とドキュメント自身が明記しています。12
- IRP_MJ_CLOSEは「参照カウントが0になった」の通知です。cleanupとcloseの間には隙間があり、即時に続くとは限りません。13
この2段階を知っていると、現場の不思議が説明できます。
- メモリマップトファイルを閉じてもファイルが解放されない。マップされたセクションがファイルオブジェクトへの参照を持ち続けるためで、ビューのアンマップとセクションのクローズが済むまでcloseは来ません。共有メモリまわりの実務は「共有メモリの落とし穴と実務ベストプラクティス」で扱いました。
- 「使用中のファイル」の犯人捜しはハンドルだけでは終わらない。ハンドルを全部閉じていても、カーネル内の参照(マップ済みイメージなど)がファイルを掴んでいることがあります。Process Explorerの検索がハンドルとDLL(マップ済みファイル)の両方を対象にしているのは、まさにこの理由です。
- .NETの
SafeFileHandleやファイナライザーが「いつか閉じてくれる」を当てにしてはいけないのも、閉じ忘れたハンドルがcleanupを遅らせ、共有違反やロック保持を長引かせるからです。
7. 自分の目で確かめる
ここまでの内容は、管理者権限のあるWindows PCが1台あれば全部観察できます。
WinObjで名前空間を見る。SysinternalsのWinObjを起動し、GLOBAL?? ディレクトリを開くと、C: が \Device\HarddiskVolumeN へのシンボリックリンクであること(図1)がそのまま表示されます。\Device 配下では、ドライバーたちが作ったデバイスオブジェクトの実名が見られます。14
ProcmonでIRPの言葉を読む。Process Monitorのメニューで Filter > Enable Advanced Output を有効にすると、Operation列が ReadFile ではなく IRP_MJ_READ や FASTIO_READ というカーネル側の語彙に変わります。1つのファイルコピーを追うだけで、IRP_MJ_CREATE → FASTIO_READ/IRP_MJ_READ → IRP_MJ_WRITE → IRP_MJ_CLEANUP → IRP_MJ_CLOSE という本記事の流れが実際に並びます。Procmonの実務的な使い方は「Process Monitor(ProcMon)実践ガイド」にまとめています。
.NETから底を意識する。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」というWin32の素の形に近い書き方もできます。この表の右側の意味が分かっていれば、左側の選択は暗記ではなくなります。
8. まとめ
- WindowsのI/Oは、オブジェクトマネージャーの名前空間で宛先(デバイスオブジェクト)を決め、要求をIRPに詰めてデバイススタックに流す、という一本の設計で貫かれています。2318
C:はシンボリックリンク、\\.\は名前の置き場の直接指定、UNCはリダイレクターへの解決。「すべてがファイルに見える」の正体は名前解決の一貫性です。45- 登場人物はドライバーオブジェクト(処理関数の表)、デバイスオブジェクト(宛先)、ファイルオブジェクト(開いた1回の状態)の3つ。HANDLEはファイルオブジェクトへの参照です。6789
- ドライバーの選択肢は完了・素通し・保留の3つ。フィルターの割り込みも、キャッシュの即答も、非同期I/Oも、この3択の応用です。310
- 発行と完了は分離できるように作られており、同期I/Oは「完了する前には戻らない」という保証です。ペンディングになった要求では、行き(発行)と帰り(割り込み→完了)が別のイベントとして進みます。1110
CloseHandleは「ハンドルを返す」だけ。cleanup(最後のハンドル)とclose(最後の参照)の2段階を知っていれば、「閉じたのに使用中」は謎ではなくなります。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 ↩6
-
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, WinObj - Sysinternals. WinObjがNTオブジェクトマネージャーの名前空間を表示するツールであり、デバイスオブジェクトやシンボリックリンクを含む名前空間内のオブジェクトを閲覧できることについて。 ↩ ↩2 ↩3
-
Microsoft Learn, CreateFileW function. CreateFileがファイルだけでなく、物理ディスク・ボリューム・コンソール・通信ポート(COMポート)・パイプなどのデバイスを開いてハンドルを返せること、デバイスを開く際に “\\.\” 形式の名前を使うこと、FILE_FLAG_OVERLAPPEDをはじめとする各種フラグの意味について。 ↩ ↩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に割り込める理由
Windowsのフィルタードライバーとミニフィルターを図解で解説する連載の最終回です。フィルターマネージャーとアルティチュード、pre/postコールバック、Procmonやウイルス対策が全I/Oを検査できる仕組み、「あの環境だけ遅い」の調査手順まで整理します。
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の挙動を仕組みから納得して使えます。
著者プロフィール
記事の著者プロフィールページです。
小村 豪
合同会社小村ソフト 代表
Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。
公開リンク