Windows I/Oの深層(第1回) ── すべての読み書きはIRPになる:I/Oシステムの全体像

· · Windows, Win32, I/O, カーネル, デバイスドライバー, .NET, CSharp, 不具合調査

File.ReadAllText の1行、あるいは ReadFile の1呼び出し。この関数が戻ってくるまでの間に、Windowsの中では何が起きているのでしょうか。

不具合調査でProcess Monitorを開くと、IRP_MJ_READFASTIO_READ という見慣れない語が並びます。ハンドルリークを追いかけると、CloseHandle を呼んだはずのファイルがまだ生きています。「ネットワークドライブだと挙動が違う」「ウイルス対策ソフトを入れた環境でだけ遅い」──業務アプリの現場で繰り返し出会うこれらの現象は、すべてWindowsのI/Oシステムという同じ地面の上で起きています。

この記事から、その地面を底から掘る連載「Windows I/Oの深層」を始めます。名著『インサイドWindows』(Windows Internals)がカーネルの設計まで降りて解説するように、この連載も「APIの使い方」ではなく「なぜそう動くのか」を扱います。1 予定している構成は次のとおりです。

  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回の本記事は、以降のすべての回の土台になる「登場人物と要求の流れ」を図で押さえます。ドライバーを書くための記事ではありません。アプリケーション開発者が、自分の呼んだ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

\ (名前空間のルート)\Device(ドライバーが作るデバイスオブジェクト)\GLOBAL??(Win32から見えるグローバルな名前の置き場)\BaseNamedObjects(名前付きミューテックスなど)HarddiskVolume3Serial0Mup (ネットワークリダイレクター)C: → \Device\HarddiskVolume3COM1 → \Device\Serial0PhysicalDrive0 → \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") の名前解決はこう進みます。

アプリが渡した名前C:\project\report.csvWin32層がNT形式に変換\??\C:\project\report.csvオブジェクトマネージャーが名前空間を検索\??\C: はシンボリックリンクだと分かるリンクをたどって置き換え\Device\HarddiskVolume3\project\report.csv\Device\HarddiskVolume3 でボリュームのデバイスオブジェクトに到達残りの \project\report.csv の解決はI/OマネージャーがIRP_MJ_CREATEを発行してファイルシステムドライバー(NTFS)に任せる

図2: CreateFileの名前解決。前半はオブジェクトマネージャー、デバイスに到達した後半はファイルシステムの仕事

この図から、普段の疑問がいくつか解けます。

  • \\.\ プレフィックスの意味。\\.\PhysicalDrive0\\.\COM10\\.\ は、Win32デバイス名前空間(≒ \?? ディレクトリ)を直接指すための記法です。ドライブレターを経由せず、リンクの置き場を直接指名しています。515
  • CONNUL がファイル名に使えない理由。これらはMS-DOSデバイス名として予約されており、パスのどこに書いてもデバイス側に解決されうるからです。5 このあたりの実務的な落とし穴は「MAX_PATHとWindowsのパス・ファイル名の落とし穴」にまとめています。
  • UNCパスも特別扱いではない。\\server\share はネットワークリダイレクターのデバイス(\Device\Mup)に解決され、その先はSMBクライアントがネットワーク越しに要求を運びます。ローカルとUNCで挙動が変わる問題の根っこは、解決先のデバイスが違うことにあります(「ネットワークドライブとUNCパスの落とし穴」参照)。

なお、正確を期すと \?? は実在するディレクトリの別名ではなく、「まずログオンセッションごとのローカルなDOSデバイスマップを見て、なければ \GLOBAL?? に落ちる」という検索順を表す仮想的な入口です。net usesubst で作ったドライブレターはこのローカル側に入るため、同じ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_CLEANUPIRP_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

カーネル空間プロセス(ユーザーモード)ファイルオブジェクト 1report.csv を読み取りで開いた現在オフセット: 4096ファイルオブジェクト 2report.csv を追記で開いた現在オフセット: 65536デバイスオブジェクトHarddiskVolume3 相当ドライバーオブジェクト NTFSMajorFunction = 処理関数の表HANDLE 0x1A4HANDLE 0x1B8

図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 たとえばローカルディスク上のファイルへの読み取りは、おおよそ次の道のりを通ります。

ストレージ側のスタックファイルシステム側のスタックボリューム/パーティション管理(volmgr など)ディスククラスドライバー(disk.sys)ストレージポート/ミニポート(storport など)ファイルシステムフィルター(ウイルス対策・暗号化・Procmonなど)NTFSファイル内オフセットをボリューム上の位置に変換I/OマネージャーがIRPを組み立てる(IRP_MJ_READ+スタックロケーション)ディスク装置

図4: 読み取り要求が通る道。ファイルシステム側とストレージ側は別々のデバイススタックで、NTFSはストレージ側宛ての下位IRPを新たに発行して仕事を依頼する

この図で覚えてほしいことは2つです。

  1. フィルターは正規の住人である。ウイルス対策ソフトがすべてのファイルI/Oを検査できるのは、ハックではなく、この「間に挟まる」仕組みがOSの公式な拡張点だからです。19 Process Monitorも同じ場所に立って全I/Oを記録しています。「あの環境でだけファイルアクセスが遅い」の調査で最初に疑うべき場所でもあります(詳細は第6回)。
  2. 層ごとに要求の意味が翻訳される。アプリは「このファイルのオフセット4096から8KB」と言い、NTFSがそれを「このボリュームのこのクラスター」に、ストレージスタックが「このディスクのこのセクター」に翻訳します。上の層は下の層の事情を知りません。ここで大事な補足がひとつあります。1つのIRPがアプリからディスクまで素通しで届くわけではないことです。ファイルシステム側とストレージ側は別々のスタックで、NTFSはファイル宛てのIRPを処理した結果として、ボリューム(ストレージスタック)宛ての下位IRPを新たに作って発行します。断片化したファイルなら1つの読み取りが複数の下位IRPに分かれることもあり、要求の粒度も寿命も層ごとに変わります。

4.3. 各ドライバーの3つの選択肢

IRPを受け取ったドライバーにできることは、本質的に3つしかありません。310

下の層がその場で完了した下の層が保留にした(保留は呼び出し元まで伝わる)ドライバーがIRPを受け取るこの要求をどう扱うか(1) 自分で完了するIoCompleteRequest を呼ぶ例: キャッシュにあるデータで即答(2) 下のデバイスへ渡すIoCallDriver を呼ぶ例: フィルターが検査して素通し(3) 保留(ペンディング)にするSTATUS_PENDING を返してIRPをキューへ例: ハードウェアの応答待ち割り込みなどを契機にあとから IoCompleteRequest完了処理がスタックを逆順に上る(各層の完了ルーチンが呼ばれる)

図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回で)。

ディスク装置ストレージスタックフィルター+NTFSI/Oマネージャーアプリのスレッドディスク装置ストレージスタックフィルター+NTFSI/Oマネージャーアプリのスレッドハンドルからファイルオブジェクトを解決しIRP(IRP_MJ_READ)を組み立てる同期I/O: ここで完了を待って眠る非同期I/O: 制御が戻り別の仕事ができる割り込み処理(ISR)からDPCで完了処理を続行必要な下位IRPがすべて完了したら各層の完了ルーチンを逆順に実行し要求元スレッドへのAPCで結果を確定ReadFile → NtReadFile(システムコール)IoCallDriver(スタックの先頭へ)位置をボリューム上に翻訳しストレージスタック宛ての下位IRPを発行読み取りコマンドを発行STATUS_PENDING(下位IRPは保留)元のIRPも保留のまま返る(行きはここで終わり)割り込み「データを読み終えた」下位IRPを完了(IoCompleteRequest)NTFS側の完了ルーチンが受け取る元のIRP(IRP_MJ_READ)を完了状態とバイト数が確定(イベント通知など)

図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つは別々に減っていきます。

ファイルシステムI/OマネージャーオブジェクトマネージャーアプリファイルシステムI/Oマネージャーオブジェクトマネージャーアプリハンドルテーブルからエントリを削除ハンドルカウントを減らすそのファイルオブジェクトの未完了I/Oの取り消しやロック解放alt[それが最後のハンドルだった]ただし未完了I/Oやセクション(メモリマップ)などカーネル内の参照が残っていればファイルオブジェクトはまだ生きているファイルオブジェクトの後始末が完了ここで初めて「閉じ終わった」alt[参照カウントも0になった]CloseHandle(h)IRP_MJ_CLEANUPIRP_MJ_CLOSE

図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_READFASTIO_READ というカーネル側の語彙に変わります。1つのファイルコピーを追うだけで、IRP_MJ_CREATEFASTIO_READ/IRP_MJ_READIRP_MJ_WRITEIRP_MJ_CLEANUPIRP_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.OpenHandleRandomAccess クラスで、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方式、キャンセル、そして「非同期のはずが同期で返ってくる」罠──を掘ります。

関連記事

関連する相談領域

合同会社小村ソフトでは、Windows業務アプリのファイルI/Oまわりの設計・不具合調査(ハンドルリーク、「ファイルが使用中」、特定環境でのI/O遅延など)を扱っています。

参考リンク

  1. Microsoft Learn, Windows Internals - Sysinternals. 書籍『Windows Internals』(邦訳『インサイドWindows』)の紹介ページ。WindowsのカーネルアーキテクチャーとI/Oシステムを含む内部構造を扱う定番書であり、本連載が扱う内容をさらに深く学ぶための出発点として。 

  2. Microsoft Learn, I/O manager. Windowsカーネルモード I/Oマネージャーがアプリケーションとデバイスドライバーの提供するインターフェイスの間の通信を管理すること、デバイスはOSと一致しない速度で動作するため、OSとドライバーの間の通信は主としてIRP(I/O要求パケット)を通じて行われること、IRPがネットワークパケットやWindowsメッセージに似た存在としてOSからドライバーへ、またドライバーからドライバーへ渡されることについて。  2 3

  3. Microsoft Learn, I/O request packets. デバイスドライバーに送られる要求のほとんどがIRPに詰められること、OSのコンポーネントやドライバーがIoCallDriver(デバイスオブジェクトへのポインターとIRPへのポインターを取る)でドライバーへIRPを送ること、IRPが通常はデバイススタックとして積み重なった複数のドライバーによって処理され、まずスタック最上位のデバイスオブジェクトへ送られること、各ドライバーがIRPを処理して完了させるか、下のドライバーへ転送するかを選べることについて。  2 3 4 5 6

  4. Microsoft Learn, Introduction to MS-DOS device names. MS-DOSデバイス名がNTスタイルのデバイス名へのシンボリックリンクであること、ユーザーモードのWindowsアプリはMS-DOSデバイス名(ドライブレターやCOMポート名)でデバイスにアクセスするのに対し、ドライバーやカーネルはNTスタイルの名前を使うこと、ドライバーがIoCreateSymbolicLinkで \DosDevices\名前 からデバイスへのシンボリックリンクを作ることについて。  2 3

  5. Microsoft Learn, Naming files, paths, and namespaces. CON、PRN、AUX、NUL、COM1〜COM9、LPT1〜LPT9がファイル名として予約されていること、Win32の名前空間に「ファイル名前空間」と「デバイス名前空間」があり、”\\.\” プレフィックスがWin32デバイス名前空間へのアクセスを意味すること(例: \\.\PhysicalDrive0)、これらの名前解決の規則について。  2 3 4

  6. Microsoft Learn, Introduction to driver objects. ドライバーのロード時にI/OマネージャーがDRIVER_OBJECT構造体を作ること、ドライバーオブジェクトがドライバーの標準ルーチン群への入口(ディスパッチテーブルであるMajorFunction配列を含む)を保持すること、I/Oマネージャーがこの表を使って要求に対応する処理関数を呼び出すことについて。  2 3

  7. Microsoft Learn, Introduction to device objects. DEVICE_OBJECT構造体が論理・仮想・物理デバイスを表し、I/O要求の宛先(ターゲット)になること、ドライバーがIoCreateDeviceでデバイスオブジェクトを作ること、デバイスオブジェクトが自分を作成したドライバー(ドライバーオブジェクト)と結び付いていることについて。  2 3

  8. Microsoft Learn, Using files in a driver. カーネルにおいてファイルオブジェクトが「開かれたファイル(またはデバイス)のインスタンス」を表すこと、ファイルのオープンのたびにファイルオブジェクトが作られ、開いた際のコンテキスト(現在のバイトオフセットなど)を保持することについて。  2 3

  9. Microsoft Learn, File handles. CreateFileが返すファイルハンドルが、プロセスに固有であり、開かれたファイルオブジェクトと結び付いていること、同じファイルを複数回開けばそれぞれ別のハンドル(と開いた状態)になること、ハンドルが不要になったらCloseHandleで閉じるべきことについて。  2 3

  10. Microsoft Learn, Completing IRPs. I/O操作を完了させるのはIoCompleteRequestの呼び出しであること、完了時にはスタック上位のドライバーが登録したIoCompletionルーチンが順に呼ばれること、要求の完了が発行とは別のタイミングで起こり、最終的に要求元へ状態が返されるまでの流れについて。  2 3 4 5

  11. Microsoft Learn, Synchronous and asynchronous I/O. 同期I/Oでは関数がI/O完了まで戻らずスレッドが待たされるのに対し、非同期I/O(オーバーラップI/O)では要求を発行した関数がすぐ戻り、スレッドが他の仕事を続けられること、非同期I/OにはFILE_FLAG_OVERLAPPEDを指定してハンドルを開く必要があること、完了の通知を受け取る複数の方法があることについて。  2 3

  12. Microsoft Learn, IRP_MJ_CLEANUP. この要求の受信が「対象デバイスオブジェクトに関連付けられたファイルオブジェクトの最後のハンドルが閉じられた」ことを示すこと、ただし未処理のI/O要求のためにファイルオブジェクトの解放はまだの場合があること、このIRPがハンドルを閉じたプロセスのコンテキストで送られることについて。  2 3

  13. Microsoft Learn, IRP_MJ_CLOSE. この要求の受信が「ファイルオブジェクトの参照カウントが0になり、ファイルオブジェクトが解放されようとしている」ことを示すこと、cleanup要求の後に送られるが、未処理I/Oの完了を待つため即座に続くとは限らないことについて。  2 3

  14. Microsoft Learn, WinObj - Sysinternals. WinObjがNTオブジェクトマネージャーの名前空間を表示するツールであり、デバイスオブジェクトやシンボリックリンクを含む名前空間内のオブジェクトを閲覧できることについて。  2 3

  15. Microsoft Learn, CreateFileW function. CreateFileがファイルだけでなく、物理ディスク・ボリューム・コンソール・通信ポート(COMポート)・パイプなどのデバイスを開いてハンドルを返せること、デバイスを開く際に “\\.\” 形式の名前を使うこと、FILE_FLAG_OVERLAPPEDをはじめとする各種フラグの意味について。  2

  16. 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など)の一覧と、それぞれの要求が何を意味し、どのドライバーが対応すべきかについて。 

  17. Microsoft Learn, I/O stack locations. I/OマネージャーがレイヤードドライバーのチェーンにあるドライバーごとにIRP内へI/Oスタックロケーションを用意すること、各スタックロケーションにメジャー/マイナーファンクションコードとその要求のパラメーターが入ること、各ドライバーがIoGetCurrentIrpStackLocationで自分用のスタックロケーションを取得して要求内容を知ることについて。 

  18. Microsoft Learn, Device nodes and device stacks. デバイスオブジェクトが積み重なってデバイススタックを構成すること、IRPがまずスタック最上位のデバイスオブジェクトに送られ、各層で処理または下位への転送が行われること、フィルタードライバーのデバイスオブジェクトがスタックに挟まる形で存在することについて。  2

  19. 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を検査できる仕組み、「あの環境だけ遅い」の調査手順まで整理します。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

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 ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る