The Depths of Windows I/O (Part 1) — Every Read and Write Becomes an IRP: The Big Picture of the I/O System
· Go Komura · Windows, Win32, I/O, Kernel, Device Driver, .NET, C#, Bug Investigation
A single line of File.ReadAllText, or one call to ReadFile. What happens inside Windows between the moment you call the function and the moment it returns?
Open Process Monitor to chase down a bug and unfamiliar terms like IRP_MJ_READ and FASTIO_READ line up in front of you. Track down a handle leak and a file you were sure CloseHandle had dealt with is still alive. “It behaves differently on a network drive.” “It’s only slow on machines with antivirus software installed.” These phenomena, which come up again and again on the front lines of business applications, all happen on the same ground: the Windows I/O system.
This article opens a series, “The Depths of Windows I/O”, that digs into that ground from the bottom up. Just as the celebrated book Windows Internals (the Japanese edition is titled Inside Windows) goes all the way down to the kernel’s design to explain it, this series is not about “how to use the API” but about “why it behaves the way it does”.1 Here is the planned line-up:
- The Big Picture of the I/O System — Every Read and Write Becomes an IRP (this article)
- Synchronous and Asynchronous I/O — What OVERLAPPED Really Means
- I/O Completion Ports (IOCP) and the .NET Thread Pool — The Basement Under async/await
- The Cache Manager — When Does Your WriteFile Actually Reach Disk?
- Inside NTFS — Understanding the File System Through the MFT
- Filter Drivers and Minifilters — Why Procmon and Antivirus Scanners Can Intercept Your I/O
This first instalment lays out, with diagrams, the cast of characters and the flow of a request that every later instalment builds on. It is not an article about writing drivers. It is an article for letting application developers follow the API call they just made all the way to its destination.
Prerequisites: you can follow this article if you have used FileStream in C#, or called CreateFile/ReadFile in C/C++. No driver-development experience is required, and kernel-side code appears only as conceptual illustration. As a rough reading time, budget about 25 minutes to go through it in full while looking at the diagrams, or 2–3 minutes if you only want the bottom line of Section 1.
A guide for skipping ahead: this is long, so here are entry points by purpose.
- Just want the big picture → Section 1 (the bottom line) → Section 2 (name resolution) → Section 5 (one round trip of
ReadFile) - Want the terminology sorted out (driver/device/file object, IRP) → Sections 3 and 4
- Want to understand how things break in practice (handle leaks, “file in use”) → Section 6
- Want to try it yourself → Section 7
1. The Bottom Line First
- Windows I/O is packet-driven. Most requests sent to a device driver are packed into a packet called an IRP (I/O Request Packet) and flow from top to bottom down a stack of drivers (the device stack).23
- CreateFile doesn’t necessarily open a “file”. Name resolution happens in the Object Manager namespace, and
C:is, underneath, a symbolic link to an NT device name such as\Device\HarddiskVolume3(Section 2).45 - There are three characters in this story. The driver object, which holds a table of handler functions; the device object, which is the destination of a request; and the file object, which holds the state of one particular open. The HANDLE you hold is a reference to a file object (Section 3).6789
- Each driver has only three choices. Complete the IRP itself, pass it down to the driver below, or hold it pending and complete it later. This combination of three choices explains filter drivers, caching, and asynchronous I/O all at once (Section 4).310
- There is no separate mechanism called “synchronous I/O” down at the bottom of the kernel. Synchronous I/O is really just the guarantee “the call does not return before completion”; waiting only happens when a request goes pending. This is where the doors to Part 2 and Part 3 open (Section 5).11
- CloseHandle doesn’t mean “close” — it means “return one handle”. There are two stages: cleanup, when the last handle closes, and close, when every reference inside the kernel has also vanished. That gap is exactly why a file can say “in use” after you thought you had closed it (Section 6).1213
- You can observe this layer yourself. WinObj shows you the namespace, and Process Monitor shows you the flow of IRPs. Procmon’s own vocabulary is the vocabulary of this article (Section 7).14
2. What “Everything Looks Like a File” Really Means
2.1. Where Does the Name You Pass to CreateFile Go?
Every Win32 file API begins with a “name”. A path like C:\project\report.csv, a UNC path like \\server\share\data.csv, a device designation like \\.\COM3 — all of these can be passed to the very same CreateFile.15
Behind this consistency lies a single namespace managed by the kernel’s Object Manager. Inside the kernel, devices, events, and shared-memory sections alike are all registered as “objects” in this tree-shaped namespace. Sysinternals’ WinObj lets you look straight into this namespace.14
flowchart TB
ROOT["\ - root of the namespace"]
DEV["\Device<br/>device objects created by drivers"]
GLB["\GLOBAL??<br/>where the globally visible names Win32 sees live"]
BNO["\BaseNamedObjects<br/>named mutexes and the like"]
ROOT --> DEV
ROOT --> GLB
ROOT --> BNO
DEV --> D1["HarddiskVolume3"]
DEV --> D2["Serial0"]
DEV --> D3["Mup - network redirector"]
GLB --> L1["C: -> \Device\HarddiskVolume3"]
GLB --> L2["COM1 -> \Device\Serial0"]
GLB --> L3["PhysicalDrive0 -> \Device\Harddisk0\DR0"]
Figure 1: The Object Manager namespace (excerpt). What lives under \GLOBAL?? are symbolic links; the real objects live under \Device
The key point is that the names a Win32 application uses (drive letters, COM port names) and the names the kernel uses (NT device names) live in different tiers. Symbolic links are what connect the two.
2.2. A Drive Letter Is a Symbolic Link
When a driver wants its device to be visible from Win32 applications, it calls IoCreateSymbolicLink to create a symbolic link from an MS-DOS device name, such as \DosDevices\COM1, to an NT device name.4 C: works the same way — underneath, it is a link to a volume device such as \Device\HarddiskVolume3.
So name resolution for CreateFile("C:\project\report.csv") proceeds like this:
flowchart TB
A["Name passed by the app<br/>C:\project\report.csv"]
B["Win32 layer converts it to NT form<br/>\??\C:\project\report.csv"]
C["Object Manager searches the namespace<br/>and finds \??\C: is a symbolic link"]
D["Follows the link and substitutes it<br/>\Device\HarddiskVolume3\project\report.csv"]
E["\Device\HarddiskVolume3<br/>reaches the volume's device object"]
F["Resolving the remaining \project\report.csv is left<br/>to the file system driver, NTFS -<br/>the I/O manager issues IRP_MJ_CREATE"]
A --> B
B --> C
C --> D
D --> E
E --> F
Figure 2: Name resolution for CreateFile. The first half is the Object Manager’s job; once a device is reached, the second half becomes the file system’s job
This picture answers a few questions that come up all the time.
- What the
\\.\prefix means. The\\.\in\\.\PhysicalDrive0or\\.\COM10is notation for pointing directly at the Win32 device namespace (roughly the\??directory). It skips the drive letter and names the location of the link directly.515 - Why
CONandNULcan’t be used as file names. They are reserved as MS-DOS device names, and wherever they appear in a path they can be resolved to the device side instead.5 The practical pitfalls around this are collected in “MAX_PATH and Windows Path/Filename Pitfalls”. - UNC paths aren’t a special case either.
\\server\shareresolves to the network redirector’s device (\Device\Mup), and from there the SMB client carries the request across the network. The root cause of behaviour differing between local paths and UNC paths is that the device it resolves to is different (see “Pitfalls of Network Drives and UNC Paths”).
To be precise, \?? is not really an alias for one physical directory — it is a virtual entry point representing a lookup order: “first check the per-logon-session local DOS device map, and if it isn’t there, fall through to \GLOBAL??”. Drive letters created with net use or subst land in that local side, which is why the same PC can show different drives to different users (logon sessions), and why a service cannot see a user’s mapped network drives. Figure 1 above shows only the global side (\GLOBAL??).
In other words, the precise way to say “on Windows, everything looks like a file” is this: every name is ultimately resolved to a device object, and every request from that point on is unified into the same format — the IRP. So what exactly is that device object? Let’s sort out the cast of characters.
3. The Cast Is Three Objects
The structure of the Windows I/O system can be drawn as a relationship among three kinds of kernel objects.
Technical terms are about to pile up. So that you don’t get lost, here is a translation table up front, mapping “what you see in the code you normally write” to “what the kernel calls it”. Sections 3 and 4 are written in the vocabulary of the right-hand column.
| What you see on the Win32 / .NET side | The kernel-side counterpart | In a nutshell |
|---|---|---|
HANDLE / SafeFileHandle |
An entry in the handle table (a reference to a file object) | A ticket number for “one open” (3.3) |
The open state a FileStream is holding |
File object | The container for the sharing mode, flags, and current position (3.3) |
A drive letter C: or \\.\COM3 |
Device object (the result of resolving the name) | The destination of the request (Section 2, 3.2) |
| “The NTFS driver”, “the disk driver” | Driver object | A table of handler functions, one per request type (3.1) |
One call to ReadFile / stream.Read |
Normally, one IRP | The request packet carried to its destination (Section 4). Synchronous reads and writes to a cached file take a shortcut called fast I/O that skips creating an IRP (5.2). In Procmon these are the lines that start with FASTIO_ |
FileOptions / CreateFile flags |
Attributes recorded on the file object | Determine read-ahead and asynchronous behaviour (translation table in Section 7) |
The value of GetLastError / a .NET exception |
NTSTATUS (STATUS_PENDING and so on) |
The completion status, translated into a Win32 error code on its way up |
3.1. The Driver Object — A Table of Handler Functions
When a driver is loaded, the I/O manager creates a driver object (DRIVER_OBJECT) to represent it.6 From an application developer’s point of view, the most important member is the MajorFunction array — a table mapping “request type” to “handler function”, where the request type is expressed by a major function code such as IRP_MJ_CREATE (open), IRP_MJ_READ (read), IRP_MJ_WRITE (write), IRP_MJ_CLEANUP, or IRP_MJ_CLOSE.16
Forcing it into C#, the picture looks roughly like this:
// Conceptual illustration. In reality this is a C struct inside the kernel
class DriverObject
{
// Indexed by IRP_MJ_XXX. 28 kinds in total
public DispatchRoutine[] MajorFunction = new DispatchRoutine[28];
}
// For ntfs.sys, MajorFunction[IRP_MJ_READ] holds "NTFS's read handler"
3.2. The Device Object — The Destination of a Request
For every device it looks after, a driver creates a device object (DEVICE_OBJECT).7 This is the destination of an I/O request. It isn’t necessarily one-to-one with a physical device — it can be a logical entity such as a volume (HarddiskVolume3), or a “device that exists only to intercept traffic” created by a filter driver. A device object points back to the driver object that created it, so once you know the destination, you also know the table of handler functions.
3.3. The File Object — The State of “One Open”
Every time CreateFile succeeds, the kernel creates one file object. This isn’t “the file on disk” itself — it represents one session of having opened that file (or device).8 Open the same file twice and you get two file objects. The current file pointer (for a synchronous handle), and the sharing mode and flags used when it was opened, all live here.
And the HANDLE your application receives is a reference to a file object, reached through the process’s own handle table.9
flowchart LR
subgraph P["Process - user mode"]
H1["HANDLE 0x1A4"]
H2["HANDLE 0x1B8"]
end
subgraph K["Kernel space"]
FO1["File object 1<br/>report.csv opened for reading<br/>current offset: 4096"]
FO2["File object 2<br/>report.csv opened for append<br/>current offset: 65536"]
DO["Device object<br/>equivalent to HarddiskVolume3"]
DR["Driver object NTFS<br/>MajorFunction = table of handlers"]
end
H1 --> FO1
H2 --> FO2
FO1 --> DO
FO2 --> DO
DO --> DR
Figure 3: The relationship among the three objects. A handle points, via the handle table, at a file object; a file object points at a device; a device points at a driver
With this picture in mind, a few pieces of hands-on knowledge stop being things you memorise and start being things that are simply obvious.
- What a handle leak really is: a pile of file objects (and the resources chained behind them) that keep being referenced and can never be freed. What you’re counting is entries in the handle table — exactly what Process Explorer and
handle.exeshow you (see “Process Explorer / Handle / VMMap in Practice”, and as an investigation write-up, “Investigating Long-Run Crashes of an Industrial Camera App — The Handle Leak”). - Why two handles opened on the same file have independent file pointers: the pointer lives on the file object side. Conversely, a handle duplicated with
DuplicateHandlepoints at the same file object, so the two handles share the pointer. - A sharing violation is the kernel checking the sharing modes of the existing group of file objects against the new
CreateFilerequest. We covered the practical side of mutual exclusion in “Mutual Exclusion Fundamentals for File-Based Integration”.
4. The IRP — An I/O Request Becomes a Parcel
4.1. Why Turn It Into a Packet?
When the I/O manager receives a request from an application — open, read, write, and so on — it packs it into a packet called an IRP (I/O Request Packet) and hands it to a driver. Most requests to a device driver arrive as IRPs.3 Devices don’t run at the same speed as the OS (a disk is orders of magnitude slower than the CPU), so requests are turned into “parcels” rather than “calls”, in a form that lets issuing and completion be separated.2
Alongside a header carrying the overall request information, an IRP has an area called an I/O stack location — one for each driver it is expected to pass through. Each driver reads “its own instructions” (the major function code and its parameters) from its own stack location.17
4.2. Going Down the Device Stack
Device objects stack up to form a device stack.18 A read from a file on a local disk, for example, roughly follows this path:
flowchart TB
IOM["The I/O manager assembles the IRP<br/>(IRP_MJ_READ + stack locations)"]
subgraph FSSTACK["File system side of the stack"]
FLT["File system filters<br/>(antivirus, encryption, Procmon, etc.)"]
NTFS["NTFS<br/>converts the in-file offset into a position on the volume"]
end
subgraph STSTACK["Storage side of the stack"]
VOL["Volume / partition management<br/>(volmgr, etc.)"]
DISK["Disk class driver<br/>(disk.sys)"]
PORT["Storage port / miniport<br/>(storport, etc.)"]
end
HW[("Disk hardware")]
IOM --> FLT
FLT --> NTFS
NTFS --> VOL
VOL --> DISK
DISK --> PORT
PORT --> HW
Figure 4: The path a read request takes. The file system side and the storage side are separate device stacks; NTFS issues a new lower IRP addressed to the storage side to get its work done
Two things are worth remembering from this picture.
- Filters are legitimate residents. The reason antivirus software can inspect every piece of file I/O is not a hack — it is because this “insert yourself in the middle” mechanism is an official extension point of the OS.19 Process Monitor stands in the same spot and records every I/O request. It is also the first place to suspect when investigating “file access is slow only in this particular environment” (more in Part 6).
- The meaning of a request gets translated at every layer. The application says “8 KB starting at offset 4096 of this file”; NTFS translates that into “this cluster on this volume”; the storage stack translates it further into “this sector on this disk”. Each upper layer knows nothing about the layer below it. There’s one important qualification here: a single IRP does not travel from the application straight through to the disk unchanged. The file system side and the storage side are separate stacks, and as part of handling the IRP addressed to the file, NTFS creates and issues a new, lower-level IRP addressed to the volume (the storage stack). For a fragmented file, a single read can even split into multiple lower IRPs — the granularity and lifetime of a request change at every layer.
4.3. Each Driver’s Three Choices
There are, fundamentally, only three things a driver that receives an IRP can do.310
flowchart TB
RECV["A driver receives the IRP"]
Q{"How does it handle this request?"}
DONE["(1) Complete it itself<br/>call IoCompleteRequest<br/>e.g. answer immediately from data in the cache"]
PASS["(2) Pass it to the device below<br/>call IoCallDriver<br/>e.g. a filter inspects it, then passes it straight through"]
PEND["(3) Hold it pending<br/>return STATUS_PENDING and queue the IRP<br/>e.g. waiting on hardware to respond"]
LATER["Call IoCompleteRequest later<br/>triggered by an interrupt or similar"]
UP["Completion processing runs back up the stack<br/>in reverse order (each layer's completion routine is called)"]
RECV --> Q
Q --> DONE
Q --> PASS
Q --> PEND
PASS -->|"the layer below completed it on the spot"| UP
PASS -->|"the layer below held it pending<br/>(the pending status propagates back to the caller)"| LATER
PEND --> LATER
DONE --> UP
LATER --> UP
Figure 5: The three choices a driver has on receiving an IRP. These three are not mutually exclusive — the most common path is “pending happens further down after being passed on” — and every path ends in completion via IoCompleteRequest
These three are not mutually exclusive choices. The most ordinary path is “(2) pass it down, and some layer further down chooses (3), pending”, in which case STATUS_PENDING propagates unchanged, both to the intervening drivers and to the original caller. When a lower layer completes it later, each layer that registered a completion routine when it passed the IRP down is called back, in reverse order — in other words, “passing on” and “pending” happen layered on top of the very same single IRP.
This three-way choice is the wellspring of Windows I/O’s flexibility.
- A cache hit lets it complete immediately, which is fast (the Cache Manager, in Part 4).
- Any number of filters can insert themselves and pass the request straight through (minifilters, in Part 6).
- Because a request can be held pending, a thread doesn’t have to freeze up while waiting on a slow device (asynchronous I/O, in Parts 2 and 3).
The rest of this series, really, is just a set of footnotes to this one diagram.
5. Following One Round Trip of ReadFile
Now that the cast and the props are in place, let’s follow one full round trip of ReadFile, in the case where it misses the cache and goes all the way to disk (we’ll cover the cached case in Part 4).
sequenceDiagram
participant App as Application thread
participant IOM as I/O manager
participant FS as Filter + NTFS
participant ST as Storage stack
participant HW as Disk hardware
App->>IOM: ReadFile -> NtReadFile (system call)
Note over IOM: Resolves the file object from the handle<br/>and assembles the IRP (IRP_MJ_READ)
IOM->>FS: IoCallDriver (to the top of the stack)
FS->>ST: Translates the position onto the volume<br/>and issues a lower IRP addressed to the storage stack
ST->>HW: Issues the read command
ST-->>FS: STATUS_PENDING (the lower IRP is pending)
FS-->>IOM: The original IRP also returns pending<br/>(the outbound leg ends here)
Note over App: Synchronous I/O: the thread sleeps here waiting for completion<br/>Asynchronous I/O: control returns and other work can proceed
HW-->>ST: Interrupt - "data has been read"
Note over ST: Continues completion processing<br/>via a DPC, triggered from the ISR
ST->>FS: Completes the lower IRP (IoCompleteRequest)<br/>received by NTFS's completion routine
Note over FS: Once all the lower IRPs<br/>it needed have completed
FS->>IOM: Completes the original IRP (IRP_MJ_READ)
Note over IOM: Runs each layer's completion routine in reverse order,<br/>and settles the result via an APC to the requesting thread
IOM->>App: Status and byte count are settled (e.g. an event is signalled)
Figure 6: One round trip of ReadFile when it misses the cache. Completion of the lower IRP and completion of the original IRP are separate steps, and the “outbound” and “return” legs proceed as separate events too
5.1. The Outbound Leg and the Return Leg Are Separate Events
The critical line in this diagram is STATUS_PENDING. The storage driver lets go once it has issued a command to the hardware, and the “outbound” processing ends right there. The fact that data has finished being read is announced later, through a completely separate event — an interrupt — and that is what kicks off the “return” completion processing.10
In other words, inside the kernel, issuing an I/O request and completing it are built so they can be separated. There is no separate plumbing called “synchronous I/O” — the precise meaning of synchronous I/O is simply the guarantee that the call does not return before completion. Waiting only happens when a request goes pending; for a request a driver can complete on the spot (the “complete” path in Figure 5), the thread returns with its result without ever sleeping, even for synchronous I/O. The reason Win32 switches between synchronous and asynchronous by how the handle is opened (FILE_FLAG_OVERLAPPED) is precisely because asynchronous isn’t “a special extra feature” — it’s a difference in how you wait.11
Once you hold this view, the questions Part 2 tackles — why whether a call is asynchronous is decided when the handle is opened, not per call (even though the OVERLAPPED structure itself is needed for every individual operation in flight), and what it means for “something that should be asynchronous” to “complete synchronously” — start to look like the natural consequence of how the mechanism is built, rather than arbitrary facts. The reason .NET’s async/await doesn’t consume a thread while waiting on I/O (a story we covered from the practical side in “A Practical Decision Table for C# async/await”) is grounded in this very diagram, too.
5.2. There’s an Exception Too — a Shortcut That Skips the IRP
To be honest, not all I/O becomes an IRP. For synchronous reads and writes to a file that’s sitting in the cache, the file system offers a shortcut called fast I/O — copying directly out of the cache without assembling an IRP at all. In Procmon’s Operation column, these are the lines that start with FASTIO_. We’ll cover the conditions under which this shortcut can’t be used, and its relationship with the Cache Manager, in Part 4.
6. Behind CloseHandle — Cleanup and Close Are Different Things
Finally, how to close what you’ve opened. This connects directly to handle-leak investigations and “file in use” problems.
What CloseHandle does is remove one entry from the process’s handle table. A file object carries a handle count (the number of handles) and a reference count (the number of references held from inside the kernel), and the two go down independently.
sequenceDiagram
participant App as Application
participant OB as Object Manager
participant IOM as I/O manager
participant FS as File system
App->>OB: CloseHandle(h)
Note over OB: Removes the entry from the handle table<br/>and decrements the handle count
alt that was the last handle
IOM->>FS: IRP_MJ_CLEANUP
Note over FS: Cancels outstanding I/O on that file object<br/>and releases locks
end
Note over OB: But if kernel-internal references remain -<br/>outstanding I/O, a memory-mapped section, etc. -<br/>the file object is still alive
alt the reference count has also reached zero
IOM->>FS: IRP_MJ_CLOSE
Note over FS: The file object's teardown finishes -<br/>only now is it truly "closed"
end
Figure 7: The two stages — cleanup (the last handle closed) and close (every reference is also gone)
- IRP_MJ_CLEANUP announces “the last handle has been closed”. But the documentation itself notes that release of the file object may still be pending if outstanding I/O remains.12
- IRP_MJ_CLOSE announces “the reference count has reached zero”. There is a gap between cleanup and close, and one does not necessarily follow the other immediately.13
Knowing these two stages explains a number of mysteries from the field.
- A memory-mapped file isn’t released even after you close it. A mapped section keeps holding a reference to the file object, so close doesn’t arrive until the view has been unmapped and the section has been closed. We covered the practical side of shared memory in “Shared Memory Pitfalls and Practical Best Practices”.
- Hunting down the culprit behind a “file in use” doesn’t end with handles alone. Even with every handle closed, a kernel-internal reference — a mapped image, for instance — can still be gripping the file. This is exactly why Process Explorer’s search covers both handles and DLLs (mapped files).
- You should never rely on .NET’s
SafeFileHandleor a finalizer to “eventually” close things for you, precisely because a forgotten, unclosed handle delays cleanup and drags out sharing violations and held locks.
7. See It for Yourself
Everything up to this point can be observed with nothing more than one Windows PC with administrator rights.
You only need three things.
- Administrator rights. Process Monitor loads a kernel driver, so it has to run as administrator. WinObj also hides some objects from anyone who isn’t.
- The Sysinternals tools. WinObj and Process Monitor are distributed free of charge by Microsoft. Get them individually from their download pages (WinObj, Process Monitor), or grab the whole set with the Sysinternals Suite. There’s no installer — just unzip and run the exe.
- Something small to do while you watch. Saving one file from Notepad, or copying one small file, is plenty. Try it on your local disk, not a shared business folder or a production machine.
Look at the namespace with WinObj. Launch Sysinternals’ WinObj and open the GLOBAL?? directory, and you’ll see, right there, that C: is a symbolic link to \Device\HarddiskVolumeN (Figure 1). Under \Device, you can see the real names of the device objects the drivers have created.14
Read the vocabulary of IRPs in Procmon. Turn on Filter > Enable Advanced Output in Process Monitor’s menu, and the Operation column switches from ReadFile to the kernel-side vocabulary: IRP_MJ_READ, FASTIO_READ, and so on. Just watching one file copy, you’ll see the very flow this article describes play out for real: IRP_MJ_CREATE → FASTIO_READ/IRP_MJ_READ → IRP_MJ_WRITE → IRP_MJ_CLEANUP → IRP_MJ_CLOSE. We’ve put together the practical side of using Procmon in “A Practical Guide to Process Monitor (ProcMon)”.
Stay aware of the bottom layer from .NET. C#’s FileStream calls CreateFileW internally and holds the handle as a SafeFileHandle. The FileOptions passed to its constructor map almost directly onto Win32 flags.
.NET (FileOptions) |
Win32 (CreateFile flag) |
Meaning (relevant instalment) |
|---|---|---|
Asynchronous |
FILE_FLAG_OVERLAPPED |
Opens the handle for asynchronous I/O (Parts 2 and 3) |
WriteThrough |
FILE_FLAG_WRITE_THROUGH |
Doesn’t let writes stop at the cache (Part 4) |
SequentialScan |
FILE_FLAG_SEQUENTIAL_SCAN |
A hint for read-ahead (Part 4) |
RandomAccess |
FILE_FLAG_RANDOM_ACCESS |
A hint to suppress read-ahead (Part 4) |
DeleteOnClose |
FILE_FLAG_DELETE_ON_CLOSE |
Delete once the last handle closes (an application of the mechanism in Section 6) |
From .NET 6 onward, File.OpenHandle together with the RandomAccess class also lets you write in a style much closer to Win32’s raw form — “handle plus offset-specified I/O” — without going through the FileStream abstraction. Once you understand what the right-hand column of this table means, the choices on the left stop being things you memorise.
8. Summary
- Windows I/O is built on a single, consistent design: decide the destination (a device object) in the Object Manager’s namespace, pack the request into an IRP, and flow it down the device stack.2318
C:is a symbolic link,\\.\directly names where a link lives, and a UNC path resolves to a redirector. The real secret behind “everything looks like a file” is the consistency of name resolution.45- There are three characters in the cast: the driver object (a table of handler functions), the device object (the destination), and the file object (the state of one open). A HANDLE is a reference to a file object.6789
- A driver’s choices come down to three: complete, pass through, or hold pending. Filter interception, immediate answers from the cache, and asynchronous I/O are all applications of this one three-way choice.310
- Issuing and completing a request are built to be separable, and synchronous I/O is the guarantee that “the call does not return before completion”. For a request that goes pending, the outbound leg (issuing) and the return leg (interrupt then completion) proceed as separate events.1110
CloseHandleonly “returns a handle”. Once you know the two stages — cleanup (the last handle) and close (the last reference) — “closed, but still in use” stops being a mystery.1213
Next up is Part 2, “Synchronous and Asynchronous I/O — What OVERLAPPED Really Means”. We dig into the mechanism that lets an application actually use the “separation of issuing and completion” we saw in this article — FILE_FLAG_OVERLAPPED, the four ways to be notified of completion, cancellation, and the trap where something “supposed to be asynchronous” comes back synchronously.
Related Articles
- A Practical Guide to Process Monitor (ProcMon) — Pinpointing “Settings Not Applied” and “ACCESS DENIED” in 10 Minutes
- Process Explorer / Handle / VMMap in Practice — Chasing Hangs, Leaks, and “File in Use” from the State Right Now
- Investigating Long-Run Crashes of an Industrial Camera App - The Handle Leak (Part 1)
- Mutual Exclusion Fundamentals for File-Based Integration - Best Practices for File Locks and Atomic Claims
- Shared Memory Pitfalls and Practical Best Practices
- A Practical Decision Table for C# async/await - Task.Run and ConfigureAwait
- Pitfalls of Network Drives and UNC Paths — Working With File Servers (Shared Folders) From a Business Application
- MAX_PATH and Windows Path/Filename Pitfalls — the 260-Character Limit, Reserved Names, Trailing Dots, and Case Sensitivity
Related Consulting Areas
KomuraSoft LLC handles design and bug investigation around file I/O in Windows business applications — handle leaks, “file in use” errors, I/O slowdowns confined to specific environments, and more.
- Windows Application Development
- Bug Investigation and Root Cause Analysis
- Legacy Asset Utilization and Migration Support
- Contact Us
References
-
Microsoft Learn, Windows Internals - Sysinternals. The introduction page for the book Windows Internals (published in Japanese as Inside Windows), the standard reference covering the Windows kernel architecture, including the I/O system, and a starting point for going deeper into what this series covers. ↩
-
Microsoft Learn, I/O manager. On how the Windows kernel-mode I/O manager manages communication between applications and the interfaces device drivers provide; how, because devices operate at speeds that don’t match the OS, communication between the OS and drivers happens mainly through IRPs (I/O Request Packets); and on IRPs as something resembling network packets or Windows messages, passed from the OS to a driver and from driver to driver. ↩ ↩2 ↩3
-
Microsoft Learn, I/O request packets. On how most requests sent to a device driver are packaged as IRPs; how OS components and drivers send an IRP to a driver via IoCallDriver (which takes a pointer to a device object and a pointer to the IRP); how an IRP is normally processed by multiple drivers stacked as a device stack, first being sent to the device object at the top of the stack; and on each driver’s ability to choose whether to process and complete the IRP or forward it to the driver below. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, Introduction to MS-DOS device names. On how an MS-DOS device name is a symbolic link to an NT-style device name; how a user-mode Windows application accesses a device by its MS-DOS device name (a drive letter or COM port name), while drivers and the kernel use the NT-style name; and on how a driver creates the symbolic link from
\DosDevices\nameto the device using IoCreateSymbolicLink. ↩ ↩2 ↩3 -
Microsoft Learn, Naming files, paths, and namespaces. On how CON, PRN, AUX, NUL, COM1 through COM9, and LPT1 through LPT9 are reserved as file names; on how the Win32 namespace has a “file namespace” and a “device namespace”, and how the
\\.\prefix means accessing the Win32 device namespace (for example,\\.\PhysicalDrive0); and on the rules governing this name resolution. ↩ ↩2 ↩3 ↩4 -
Microsoft Learn, Introduction to driver objects. On how the I/O manager creates a DRIVER_OBJECT structure when a driver is loaded; how the driver object holds the entry points into the driver’s set of standard routines, including the MajorFunction array, which is a dispatch table; and on how the I/O manager uses this table to call the handler function corresponding to a request. ↩ ↩2 ↩3
-
Microsoft Learn, Introduction to device objects. On how a DEVICE_OBJECT structure represents a logical, virtual, or physical device and becomes the target of an I/O request; how a driver creates a device object with IoCreateDevice; and on how a device object is tied to the driver (driver object) that created it. ↩ ↩2 ↩3
-
Microsoft Learn, Using files in a driver. On how, in the kernel, a file object represents “an instance of an opened file (or device)”; and on how a file object is created every time a file is opened, holding the context of that open, such as the current byte offset. ↩ ↩2 ↩3
-
Microsoft Learn, File handles. On how the file handle returned by CreateFile is specific to a process and tied to an opened file object; how opening the same file multiple times produces separate handles (and separate open states) each time; and on the fact that a handle should be closed with CloseHandle once it is no longer needed. ↩ ↩2 ↩3
-
Microsoft Learn, Completing IRPs. On how calling IoCompleteRequest is what completes an I/O operation; how, at completion, each IoCompletion routine registered by an upper-stack driver is called in turn; and on the flow by which completion of a request happens at a separate time from issuing it, eventually returning status to the requester. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Synchronous and asynchronous I/O. On how, with synchronous I/O, a function does not return until the I/O completes and the thread is made to wait, whereas with asynchronous I/O (overlapped I/O) the function that issued the request returns immediately and the thread can continue other work; on the need to open the handle with FILE_FLAG_OVERLAPPED for asynchronous I/O; and on the several available ways to receive notification of completion. ↩ ↩2 ↩3
-
Microsoft Learn, IRP_MJ_CLEANUP. On how receiving this request indicates that “the last handle to a file object associated with the target device object has been closed”; on how release of the file object may still be pending because of outstanding I/O requests; and on how this IRP is sent in the context of the process that closed the handle. ↩ ↩2 ↩3
-
Microsoft Learn, IRP_MJ_CLOSE. On how receiving this request indicates that “the file object’s reference count has reached zero and the file object is about to be released”; and on how it is sent after the cleanup request, but not necessarily immediately afterward, since it waits for outstanding I/O to complete. ↩ ↩2 ↩3
-
Microsoft Learn, WinObj - Sysinternals. On how WinObj is a tool that displays the NT Object Manager’s namespace, letting you browse objects within it, including device objects and symbolic links. ↩ ↩2 ↩3
-
Microsoft Learn, CreateFileW function. On how CreateFile can open and return handles not just to files but to devices such as physical disks, volumes, the console, communication (COM) ports, and pipes; on the use of
\\.\-style names when opening a device; and on the meaning of various flags including FILE_FLAG_OVERLAPPED. ↩ ↩2 -
Microsoft Learn, IRP major function codes. A list of IRP major function codes (IRP_MJ_CREATE, IRP_MJ_READ, IRP_MJ_WRITE, IRP_MJ_CLEANUP, IRP_MJ_CLOSE, IRP_MJ_DEVICE_CONTROL, IRP_MJ_PNP, and others) and what each request means and which driver should handle it. ↩
-
Microsoft Learn, I/O stack locations. On how the I/O manager prepares an I/O stack location inside the IRP for each driver in a chain of layered drivers; how each stack location holds the major/minor function code and that request’s parameters; and on how each driver retrieves its own stack location, and hence the content of the request, via IoGetCurrentIrpStackLocation. ↩
-
Microsoft Learn, Device nodes and device stacks. On how device objects stack up to form a device stack; how an IRP is first sent to the device object at the top of the stack, with each layer either processing it or forwarding it further down; and on how a filter driver’s device object sits inserted within that stack. ↩ ↩2
-
Microsoft Learn, Filter Manager Concepts. On how the filter manager is a kernel-mode driver that ships with Windows; how minifilter drivers can intercept I/O requests to the file system through pre- and post-operation callbacks; and on how each minifilter’s interception point (its altitude) determines its order within the I/O stack. ↩
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
The Depths of Windows I/O (Part 6, Final) — Filter Drivers and Minifilters: Why Procmon and Antivirus Scanners Can Intercept I/O
The final instalment of a series illustrating Windows filter drivers and minifilters. It covers the Filter Manager and altitudes, pre/pos...
The Depths of Windows I/O (Part 4) — Cache Manager: When Does Your WriteFile Actually Reach the Disk?
Part 4 of an illustrated series on the Windows cache manager. It covers the cache implemented as a file mapping, read-ahead and lazy writ...
The Depths of Windows I/O (Part 2) — Synchronous and Asynchronous I/O: What OVERLAPPED Really Means
Part 2 of a series explaining Windows synchronous and asynchronous I/O (overlapped I/O) with diagrams. It covers what FILE_FLAG_OVERLAPPE...
The Depths of Windows I/O (Part 5) — NTFS Internals: Understanding the File System Through the MFT
Part 5 of a series explaining NTFS internals with diagrams. Covers the MFT and file records, multiple data streams (Zone.Identifier), har...
Practical Multithreading Best Practices: .NET Edition — What to Decide Before You Add More Threads
A practical rundown of the design rules that keep multithreaded .NET/C# code from occasionally crashing or hanging: ride on Task instead ...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Bug Investigation & Long-Run Failures
Topic page for intermittent failures, communication diagnosis, long-run crashes, and failure-path test foundations.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
We support Windows desktop applications that involve resident processing, device integration, operational logging, and maintainable structure.
Bug Investigation & Root Cause Analysis
We investigate difficult production issues such as intermittent failures, long-run crashes, leaks, and communication stoppages.
Frequently Asked Questions
Common questions about the topic of this article.
- What is an IRP?
- An IRP (I/O Request Packet) is the packet the Windows kernel's I/O manager uses to bundle up an application's read, write, and other requests and hand them to device drivers. Microsoft's driver development documentation explains that most requests sent to device drivers are packaged as IRPs. An IRP carries a major function code that indicates the kind of request (create, read, write, cleanup, and so on) and a stack location for each driver it passes through, and it is processed as it flows down the device stack from top to bottom. Each driver chooses whether to complete the IRP itself, pass it to the driver below, or hold it pending and complete it later. Application developers never touch an IRP directly, but notations such as IRP_MJ_READ that appear in Process Monitor's Operation column are this very mechanism made visible.
- Why can Windows open a file, a serial port, and a printer with the same CreateFile call?
- Because whatever name you pass to CreateFile is ultimately resolved, through the Object Manager namespace, to a device object, and the I/O manager then creates a file object tied to that device and hands back a handle — the same path every time. A drive letter such as C: is, underneath, a symbolic link to an NT device name such as \Device\HarddiskVolume3, and a designation like \\.\COM1 likewise resolves to the serial port's device object. Whatever device the name resolves to, every subsequent request is packaged into the same IRP format and delivered to a driver, which is why files and devices alike can be opened and read or written through the same API. UNC paths follow the identical mechanism — they simply resolve to the network redirector's device. This combination of "namespace plus packet" is the real source of Windows I/O's consistency.
- Why doesn't a file get released immediately after CloseHandle is called?
- Because what CloseHandle does is "return one handle", not "close the file". A file object inside the kernel carries two separate counts: a handle count (the number of open handles) and a reference count (the number of references held by kernel components). IRP_MJ_CLEANUP is sent to the file system once the last handle is closed, but as long as kernel-internal references remain — outstanding I/O, a memory-mapped file section, and so on — the file object itself stays alive, and IRP_MJ_CLOSE is sent only once the reference count reaches zero. Many of the phenomena where a file cannot be deleted after being memory-mapped, or an application reports a file as "in use" after it has already closed it, are explained by this two-stage mechanism.
- What use is knowledge of IRPs and device stacks to an application developer?
- Even if you never write an IRP yourself, it pays off in both investigation and design. First, Process Monitor's Operation column (with advanced output enabled) displays exactly the vocabulary of IRPs, such as IRP_MJ_CREATE and IRP_MJ_READ, so knowing the terms at this layer lets you actually read the log. Second, once you know that filter drivers, such as antivirus software, sit across every path that file I/O takes, you have a lead when investigating a problem like "file access is slow only in this particular environment". And once you understand that Windows I/O is built so issuing and completing a request can be separated, and that synchronous I/O is merely a guarantee that "the call does not return before completion", asynchronous I/O, I/O completion ports, and .NET's async/await behaviour all make sense from first principles rather than as memorised facts.