第 9 章 设备驱动 --- 9.13 同步I/O与异步I/O
本节剖析 Windows NT 中同步 I/O 与异步 I/O 的实现机制。 同步 I/O 让线程在 I/O 完成前阻塞,异步 I/O 让线程发起 I/O 后立即返回、稍后查询或等待。ReactOS 实现在 ntoskrnl/io/iomgr/irp.c(file:///d:/reactos/ntoskrnl/io/iomgr/irp.c)、ntoskrnl/io/iomgr/io.c(file:///d:/reactos/ntoskrnl/io/iomgr/) 等。理解同步/异步 I/O 的关键是把握 IO_STATUS_BLOCK、FileObject 同步标志、APC、内核事件 四个核心概念。
概述
Windows 提供 三种 I/O 同步模型:
- 同步 I/O(synchronous):线程阻塞直到 I/O 完成
- 异步 I/O (asynchronous, 默认):返回
STATUS_PENDING,线程不阻塞 - 基于事件的 I/O(event-based):调用者创建事件,I/O 完成时事件被设置
三种模型的本质区别
三种 I/O 模型的核心区别在于 调用者线程在 I/O 完成前的行为:
-
同步 I/O 中,调用线程通过
KeWaitForSingleObject在内核态挂起,不消耗 CPU 时间片。当设备完成 I/O 后,中断服务例程唤醒等待线程,线程从NtReadFile/NtWriteFile返回。这种模型编程最简单------代码是线性执行的,但线程在等待期间无法做任何其他工作。 -
异步 I/O 中,调用线程发送 IRP 后立即返回(通常在驱动返回
STATUS_PENDING后)。线程可以继续执行其他计算任务,稍后通过GetOverlappedResult或 I/O 完成端口(IOCP)获取结果。这种模型编程复杂但吞吐量高,广泛用于高性能服务器。 -
基于事件的 I/O 是异步 I/O 的特化:调用者在
IO_STATUS_BLOCK中关联一个内核事件对象,I/O 完成时事件被设置为信号态。调用者通过WaitForSingleObject等待事件,既可以等待单个 I/O 完成,也可以使用WaitForMultipleObjects同时等待多个 I/O。
IO_STATUS_BLOCK 的决定性作用
I/O 完成是同步还是异步,由 IO_STATUS_BLOCK` 的返回值决定:
c
typedef struct _IO_STATUS_BLOCK {
union {
NTSTATUS Status; // 完成状态
PVOID Pointer; // 内部使用(APC 等)
};
ULONG_PTR Information; // 传输的字节数
} IO_STATUS_BLOCK, *PIO_STATUS_BLOCK;
当 NtReadFile 返回时,如果 IoStatus->Status != STATUS_PENDING,说明 I/O 已在返回前同步完成(即驱动立即完成了 IRP)。如果 IoStatus->Status == STATUS_PENDING,说明 I/O 已排队、尚未完成,调用者必须稍后检查结果。这个返回值决定了后续的所有行为------是否需要等待、是否需要设置完成例程、是否可以使用重叠 I/O。
线程阻塞机制
同步 I/O 的线程阻塞是通过 内核等待 实现的,不是简单的忙等待:
- 线程调用
KeWaitForSingleObject挂起在FileObject->Event(同步文件对象的内置事件)上; - 线程状态从
Standby/Running变为Waiting,调度器选择其他就绪线程执行; - 设备完成 I/O → 中断 → DPC →
IopCompleteRequest设置事件 →KeSetEvent唤醒线程; - 线程恢复执行,读取
IO_STATUS_BLOCK中的结果。
这种机制确保同步 I/O 的等待代价为零------等待期间不消耗 CPU,上下文切换只有两次(进入等待和唤醒)。
性能权衡
三种 I/O 模型的性能特征各有不同。同步 I/O 每次操作都需要线程阻塞和唤醒,上下文切换的开销在高速存储设备上不可忽视(每次切换约 1-5 µs)。对于 SSD(延迟约 10-50 µs),同步 I/O 的上下文切换开销占 I/O 总延迟的 10-30%。异步 I/O 消除了上下文切换,线程可以连续提交大量 I/O 请求,然后批量等待完成。基于事件的 I/O 位于两者之间,适用于中等并发度(数十到数百个并发 I/O)的场景------例如数据库引擎通常使用事件模型管理多个 I/O 请求。
本节内容概览
- 9.13.0 框架图
- 9.13.1 同步与异步的本质区别
- 9.13.2
IO_STATUS_BLOCK与IO_STATUS_BLOCK - 9.13.3
FO_SYNCHRONOUS_IO标志 - 9.13.4 同步 I/O 路径
- 9.13.5 异步 I/O 路径
- 9.13.6 取消 I/O
- 9.13.7
NtReadFile/NtWriteFile系统调用 - 9.13.8 总结与代码索引
学习目标
- 区分同步 I/O 与异步 I/O 的本质
- 掌握
IO_STATUS_BLOCK字段含义 - 知道
ReadFile的同步/异步分支 - 理解
IoCancelIrp的流程
涉及的内核子系统
| 子系统 | 头文件/源文件 | 核心作用 |
|---|---|---|
| I/O IRP 处理 | ntoskrnl/io/iomgr/irp.c(file:///d:/reactos/ntoskrnl/io/iomgr/irp.c) | IRP 生命周期 |
| I/O 主入口 | ntoskrnl/io/iomgr/io.c(file:///d:/reactos/ntoskrnl/io/iomgr/io.c) | NtReadFile、NtWriteFile |
| 文件 I/O | ntoskrnl/io/iomgr/file.c(file:///d:/reactos/ntoskrnl/io/iomgr/file.c) | IopCreateFile |
| 取消 I/O | ntoskrnl/io/iomgr/cancel.c(file:///d:/reactos/ntoskrnl/io/iomgr/cancel.c) | IoCancelIrp |
| 同步等待 | ntoskrnl/io/iomgr/flush.c(file:///d:/reactos/ntoskrnl/io/iomgr/flush.c) | 同步刷新 |
9.13.0 框架图
同步路径
=========
ReadFile() -> I/O 管理器同步等待
| KeWaitForSingleObject
v
IRP 完成 -> 事件触发
|
v
返回 STATUS_SUCCESS
异步路径
=========
ReadFile() -> I/O 管理器返回 STATUS_PENDING
|
v
用户继续运行
|
v
GetOverlappedResult() 等待
|
v
事件触发 -> 完成
取消路径
=========
CancelIo() -> 标记 IRP_CANCELLED
|
v
IRP 取消例程 -> IoCancelIrp 完成
9.13.1 同步与异步的本质区别
同步 I/O(FILE_SYNCHRONOUS_IO_ALERT/FILE_SYNCHRONOUS_IO_NONALERT)
线程发起 I/O 后 阻塞 ,I/O 完成时由 I/O 管理器解锁。同步 I/O 还需要 关键段锁(按文件对象)。
打开方式:
c
HANDLE h = CreateFileW(L"file.txt",
GENERIC_READ,
FILE_SHARE_READ,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, // 不带 OVERLAPPED
NULL);
异步 I/O(默认)
线程发起 I/O 后 立即返回 STATUS_PENDING,I/O 在后台执行,线程可继续工作。
打开方式:
c
HANDLE h = CreateFileW(L"file.txt",
GENERIC_READ,
FILE_SHARE_READ,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, // OVERLAPPED 标志
NULL);
或者用 CreateFileW 后用 ReadFileEx 走 APC 路径。
9.13.2 IO_STATUS_BLOCK
c
typedef struct _IO_STATUS_BLOCK {
union {
NTSTATUS Status; // I/O 结果状态
PVOID Pointer; // 自引用(POOL 模式)
};
ULONG_PTR Information; // 实际传输字节数或 IRP 相关信息
} IO_STATUS_BLOCK, *PIO_STATUS_BLOCK;
字段含义
| 字段 | 含义 |
|---|---|
Status |
STATUS_SUCCESS / STATUS_PENDING / STATUS_END_OF_FILE / 错误码 |
Information |
读/写:实际字节数;创建:信息标志;其他:IRP 相关 |
用户态与内核态
c
// 用户态
IO_STATUS_BLOCK IoStatus;
ReadFile(hFile, Buffer, Size, &BytesRead, &Overlapped);
// ReadFile 后 IoStatus.Status 是实际状态
// 内核态
IO_STATUS_BLOCK Iosb;
NTSTATUS Status = ZwReadFile(hFile, NULL, NULL, NULL, &Iosb, Buffer, Size, NULL, NULL);
STATUS_PENDING 的特殊含义
STATUS_PENDING 表示:
- I/O 已发起 但 尚未完成
- 不应解读为错误
- 应用需调用
GetOverlappedResult等待
9.13.2b 同步与异步I/O的深层机制与实现细节
同步模型的设计哲学
Windows NT 的 I/O 子系统在设计之初就决定采用异步 I/O 作为默认模型,这与 Unix 的同步 I/O 传统截然不同。NT 的设计者 Dave Cutler 团队在设计 I/O 系统时,参考了 VMS 操作系统的 I/O 模型------VMS 的 I/O 天然是异步的,所有 I/O 操作都通过 IO Request Block(IORB)提交,完成时通过事件或 AST(Asynchronous System Trap)通知。Windows NT 将这一理念继承下来,用 IRP(I/O Request Packet)替代了 IORB,但核心的异步完成机制被完整保留。
ReactOS 在实现这一模型时,必须精确复现 Windows 的三种 I/O 同步语义:
-
同步阻塞 (
FILE_SYNCHRONOUS_IO_NONALERT):线程发起 I/O 后完全阻塞,直到 I/O 完成或出错。同一文件对象上的多个同步 I/O 请求被串行化------这是通过FileObject->Lock(一个快速互斥锁FastMutex)实现的。 -
同步可警告 (
FILE_SYNCHRONOUS_IO_ALERT):与上述类似,但等待可以被 APC(Asynchronous Procedure Call)中断。如果线程在等待期间收到内核 APC,等待会返回STATUS_KERNEL_APC,线程可以处理 APC 后重新等待或放弃。 -
异步 (默认,或
FILE_FLAG_OVERLAPPED):线程发起 I/O 后立即返回STATUS_PENDING,通过事件、APC 或轮询IO_STATUS_BLOCK来获取完成通知。
NtReadFile 的完整内核路径分析
NtReadFile(在 ReactOS 中实现于 ntoskrnl/io/iomgr/iofunc.c)是同步与异步 I/O 的分水岭。它的执行流程可以分为以下几个关键阶段:
阶段一:参数验证与句柄解析 。首先通过 ObReferenceObjectByHandle 将用户态的 FileHandle 解析为内核的 PFILE_OBJECT。这一步会验证句柄的有效性、访问权限(FILE_READ_DATA),并增加文件对象的引用计数。对于用户态调用(PreviousMode != KernelMode),还需要通过 SEH(结构化异常处理)保护来探测用户缓冲区:ProbeForWriteIoStatusBlock 验证 IoStatusBlock 的可写性,ProbeForWrite 验证读取目标缓冲区的可写性。
阶段二:快速 I/O 路径尝试 。如果文件对象具有缓存(FileObject->PrivateCacheMap != NULL),且设备是同步 I/O 模式,ReactOS 会首先尝试快速 I/O (Fast I/O)路径。快速 I/O 绕过 IRP 的创建和下发,直接调用文件系统驱动的 FastIoRead 例程,从缓存管理器(Cache Manager)中直接拷贝数据到用户缓冲区。这条路径的性能极高,因为它避免了 IRP 分配、设备栈遍历、DPC 调度等开销。ReactOS 代码中,只有当快速 I/O 返回 STATUS_SUCCESS、STATUS_BUFFER_OVERFLOW 或 STATUS_END_OF_FILE 这三种"确定性"状态时才接受结果;其他状态(如 STATUS_PENDING)意味着需要回退到完整的 IRP 路径。
阶段三:IRP 构造与缓冲区处理。根据设备对象的 I/O 类型标志,IRP 的缓冲区处理方式不同:
-
DO_BUFFERED_IO:分配一个非分页池缓冲区(ExAllocatePoolWithQuotaTag),大小为请求的Length。I/O 完成后,数据从这个系统缓冲区拷贝到用户缓冲区。这种方式安全但有一次额外的内存拷贝。 -
DO_DIRECT_IO:调用IoAllocateMdl创建 MDL,然后MmProbeAndLockPages锁定用户页面。设备驱动可以直接通过 MDL 访问物理页面,无需拷贝。这是高性能设备(如网卡、磁盘驱动)的首选方式。 -
默认(NEITHER_IO):不分配任何缓冲区,
Irp->UserBuffer直接指向用户缓冲区。驱动自行负责探测和访问用户缓冲区的安全性。
阶段四:同步等待或异步返回 。如果文件对象设置了 FO_SYNCHRONOUS_IO,NtReadFile 调用 IopPerformSynchronousRequest,内部使用 KeWaitForSingleObject 等待文件对象的内置事件(FileObject->Event)。等待完成后,从 Irp->IoStatus 获取结果并写回用户态的 IoStatusBlock。如果是异步模式,IRP 被下发到设备栈后,NtReadFile 立即返回 STATUS_PENDING,用户态通过 OVERLAPPED 结构中的事件或 APC 来获取完成通知。
IO_STATUS_BLOCK 的深层语义
IO_STATUS_BLOCK 不仅是一个简单的状态容器,它在同步和异步路径中扮演着不同的角色:
同步路径中 :IO_STATUS_BLOCK 在 NtReadFile 返回前被完全填充。Status 字段包含最终结果(STATUS_SUCCESS、STATUS_END_OF_FILE、错误码等),Information 字段包含实际传输的字节数。用户态程序可以直接使用这些值,无需额外等待。
异步路径中 :IO_STATUS_BLOCK 的生命周期更加复杂。在 NtReadFile 返回时,Status 被设为 STATUS_PENDING。此时 IO_STATUS_BLOCK 的内存必须保持有效------这就是为什么用户态的 IO_STATUS_BLOCK 通常是 OVERLAPPED 结构的一部分(OVERLAPPED.Internal 对应 Status,OVERLAPPED.InternalHigh 对应 Information)。当 I/O 完成时,I/O 管理器在内核 APC(IopCompleteRequest)中更新 IO_STATUS_BLOCK 的最终值。
ReactOS 中有一个微妙的问题:IO_STATUS_BLOCK 位于用户态内存中,内核在更新它时必须处理页面错误的可能性。ReactOS 使用 Seh2 保护来写入用户态的 IO_STATUS_BLOCK,如果写入失败(例如用户态缓冲区已被释放),IRP 的完成 APC 会妥善处理这个异常。
STATUS_PENDING 的正确处理
STATUS_PENDING 是 Windows NT I/O 模型中最容易被误解的概念。它不是一个错误,而是一个信息性状态码,表示"I/O 操作已经启动但尚未完成"。正确的处理方式是:
c
NTSTATUS Status = NtReadFile(...);
if (Status == STATUS_PENDING) {
// I/O 仍在进行中,需要等待
WaitForSingleObject(Overlapped.hEvent, INFINITE);
// 等待完成后,从 IO_STATUS_BLOCK 获取最终结果
}
在 ReactOS 的内核实现中,STATUS_PENDING 的产生时机取决于下层驱动的返回。IofCallDriver 调用下层驱动的 DispatchRoutine,如果驱动返回 STATUS_PENDING,说明它已经将 IRP 排队或正在异步处理。ReactOS 的 IopPerformSynchronousRequest 在收到 STATUS_PENDING 后,会调用 KeWaitForSingleObject 等待事件信号。
一个常见的错误是:驱动在派发例程中返回 STATUS_PENDING 但没有调用 IoMarkIrpPending。这会导致 I/O 管理器的 PendingReturned 标志未被设置,同步路径中的完成例程(IopSynchronousCompletion)不会设置事件,导致等待线程永远阻塞。ReactOS 代码中有明确的检查:
c
// IopSynchronousCompletion 中
if (Irp->PendingReturned)
KeSetEvent((PKEVENT)Context, IO_NO_INCREMENT, FALSE);
return STATUS_MORE_PROCESSING_REQUIRED;
APC 完成路径的深入分析
异步 I/O 的 APC 完成路径是 Windows NT I/O 模型中最精巧的部分之一。当 I/O 完成时,ReactOS 的 IofCompleteRequest 在遍历完成例程链后,会构造一个内核 APC 并插入到发起 I/O 的线程:
c
KeInitializeApc(&Irp->Tail.Apc,
&Thread->Tcb,
Irp->ApcEnvironment,
IopCompleteRequest, // 内核 APC 例程
NULL, NULL,
KernelMode, NULL);
KeInsertQueueApc(&Irp->Tail.Apc,
FileObject,
DataBuffer,
PriorityBoost);
这个 APC 在目标线程下次进入可警告等待状态 或用户态 APC 派发点 时被执行。IopCompleteRequest(注意这不是 IoCompleteRequest)负责:
- 将内核的
IO_STATUS_BLOCK拷贝回用户态 - 如果用户提供了 APC 例程(
UserApcRoutine),将其转换为用户态 APC 并插入线程的 APC 队列 - 释放 IRP 和相关资源
- 解除文件对象的同步锁(如果是同步 I/O)
这种两级 APC 机制(先内核 APC 完成资源清理,再用户态 APC 通知应用)确保了内核资源的正确释放,同时让应用层能够以熟悉的回调方式接收 I/O 完成通知。
取消 I/O 的竞争条件
IoCancelIrp 的实现面临一个经典的并发问题:取消操作可能与完成操作竞争 。ReactOS 的解决方案是使用取消自旋锁(Cancel SpinLock)来保护 IRP 的取消状态:
c
IoAcquireCancelSpinLock(&OldIrql);
if (Irp->Cancel || Irp->CurrentLocation >= Irp->StackCount) {
IoReleaseCancelSpinLock(OldIrql);
return FALSE; // 已经完成或已取消
}
Irp->Cancel = TRUE;
CancelRoutine = Irp->CancelRoutine;
// ... 调用取消例程
IoReleaseCancelSpinLock(OldIrql);
取消例程(CancelRoutine)在持有取消自旋锁的情况下被调用,它必须负责在某个时刻调用 IoReleaseCancelSpinLock。这个设计保证了:
- 取消例程不会被重复调用
- 取消和完成不会同时操作同一个 IRP
- 驱动在取消例程中可以安全地访问 IRP 的所有字段
然而,ReactOS 的 IopAbortInterruptedIrp 函数揭示了一个更复杂的场景:当同步等待被中断(例如线程被终止)时,ReactOS 需要先取消 IRP,然后等待驱动真正完成它。如果取消成功(驱动有取消例程),ReactOS 使用一个 10ms 的循环轮询等待事件信号;如果取消失败(驱动没有设置取消例程),则只能等待 I/O 自然完成。
同步 I/O 的串行化保证
同步 I/O 文件对象的 FileObject->Lock(快速互斥锁)提供了一个重要的语义保证:同一文件对象上的同步 I/O 请求严格按顺序执行。这意味着:
- 线程 A 调用
ReadFile获取文件对象锁 - 线程 B 调用
ReadFile被阻塞在锁上 - 线程 A 的 I/O 完成后释放锁
- 线程 B 获得锁,开始执行 I/O
这种串行化对于维护文件指针(FileObject->CurrentByteOffset)的一致性至关重要。如果两个线程同时执行同步读取,它们会共享同一个文件指针,导致读取位置混乱。ReactOS 在 NtReadFile 中,获取锁之后会读取 FileObject->CurrentByteOffset 作为本次读取的起始位置(当用户未指定 ByteOffset 时),读取完成后更新这个偏移量。
值得注意的是,异步 I/O 不 受这个锁的保护------多个异步 I/O 可以并发执行在同一个文件对象上。这就是为什么异步 I/O 要求调用者显式指定 ByteOffset(通过 OVERLAPPED.Offset),而不能依赖文件指针。
与 Windows 系统的行为对比
ReactOS 在同步/异步 I/O 方面与 Windows 的主要差异点:
-
快速 I/O :Windows 的快速 I/O 路径更加成熟,支持更多的操作类型。ReactOS 的快速 I/O 主要实现了
FastIoRead和FastIoWrite,某些高级快速 I/O(如FastIoReadCompressed、FastIoWriteCompressed)尚未完全实现。 -
I/O 完成端口(IOCP) :Windows 的 IOCP 是一种高效的异步 I/O 完成通知机制,它将完成事件排队到 completion port 而不是使用 APC。ReactOS 的 IOCP 实现基本完整,但与文件对象的
CompletionContext的交互仍有边缘情况需要处理。 -
线程池 I/O :Windows 7+ 引入了线程池 I/O(
SetThreadpoolCallbackRunsWhenAvail),允许 I/O 完成回调在线程池线程上执行。ReactOS 尚未实现这一特性。 -
STATUS_PENDING 的时机 :在某些边缘情况下,Windows 和 ReactOS 返回
STATUS_PENDING的时机可能略有不同。例如,当 IRP 在下发过程中被同步完成(驱动在 DispatchRoutine 中直接调用IoCompleteRequest),Windows 可能在某些条件下仍然返回STATUS_PENDING,而 ReactOS 的行为可能不完全一致。
调试同步/异步 I/O 问题
在 ReactOS 开发中,I/O 同步/异步相关的 bug 通常表现为:
-
死锁 :同步 I/O 等待永远不返回。原因通常是驱动返回
STATUS_PENDING但未调用IoMarkIrpPending,或者完成例程返回STATUS_MORE_PROCESSING_REQUIRED但未设置事件。 -
数据竞争 :多个线程在同一个异步文件对象上发起 I/O,但未使用
OVERLAPPED结构指定偏移,导致数据读写位置混乱。 -
APC 丢失:异步 I/O 完成后,用户态 APC 未被执行。这通常是因为线程从未进入可警告等待状态------APC 只在线程处于可警告状态时才会被派发。
-
取消后访问 :调用
CancelIo后,应用继续使用OVERLAPPED结构中的缓冲区。正确做法是等待GetOverlappedResult返回FALSE且错误码为ERROR_OPERATION_ABORTED后,才能安全地释放缓冲区。
9.13.3 FO_SYNCHRONOUS_IO 标志
FILE_OBJECT 中有标志表示 I/O 行为:
c
#define FO_FILE_OPEN 0x00000001
#define FO_SYNCHRONOUS_IO 0x00000002 // 同步 I/O
#define FO_ALERTABLE_IO 0x00000004 // 警告 I/O
#define FO_NO_INTERMEDIATE_BUFFERING 0x00000008
#define FO_WRITE_THROUGH 0x00000010
// ... 更多
FO_SYNCHRONOUS_IO 由 IopCreateFile 在解析 CreateFile 标志时设置:
c
if (CreateOptions & FILE_SYNCHRONOUS_IO_ALERT) {
FileObject->Flags |= FO_SYNCHRONOUS_IO | FO_ALERTABLE_IO;
} else if (CreateOptions & FILE_SYNCHRONOUS_IO_NONALERT) {
FileObject->Flags |= FO_SYNCHRONOUS_IO;
}
同步 I/O 锁
同步 I/O 文件对象有关键段锁(FileObject->Lock),保证同一文件对象的 同步 I/O 请求串行化:
c
VOID IopLockFileObject(IN PFILE_OBJECT FileObject)
{
ASSERT(FileObject->Flags & FO_SYNCHRONOUS_IO);
ExAcquireFastMutex(&FileObject->Lock);
}
VOID IopUnlockFileObject(IN PFILE_OBJECT FileObject)
{
ASSERT(FileObject->Flags & FO_SYNCHRONOUS_IO);
ExReleaseFastMutex(&FileObject->Lock);
}
同步 I/O 文件位置
同步 I/O 文件对象记录 当前文件指针 (FileObject->CurrentByteOffset),ReadFile 不指定偏移时使用此位置。
9.13.4 同步 I/O 路径
ReadFile 同步 I/O 路径:
c
// kernel32 ReadFileW
BOOL ReadFile(HANDLE hFile, LPVOID Buffer, DWORD Length,
LPDWORD BytesRead, LPOVERLAPPED Overlapped)
{
if (Overlapped != NULL) {
// 异步路径
return ReadFileEx_OR_Async(hFile, ...);
}
// 同步路径
NtReadFile(hFile, NULL, NULL, NULL,
&IoStatus, Buffer, Length, NULL, NULL);
// 等待
if (IoStatus.Status == STATUS_PENDING) {
NtWaitForSingleObject(hFile, ...);
}
*BytesRead = IoStatus.Information;
return NT_SUCCESS(IoStatus.Status);
}
NtReadFile 同步分支
ntoskrnl/io/iomgr/io.c(file:///d:/reactos/ntoskrnl/io/iomgr/io.c):
c
NTSTATUS NtReadFile(
HANDLE FileHandle, HANDLE Event, PIO_APC_ROUTINE ApcRoutine,
PVOID ApcContext, PIO_STATUS_BLOCK IoStatusBlock,
PVOID Buffer, ULONG Length, PLARGE_INTEGER ByteOffset, PULONG Key)
{
PFILE_OBJECT FileObject;
PDEVICE_OBJECT DeviceObject;
PIRP Irp;
NTSTATUS Status;
// 1. 解析 FileHandle
ObReferenceObjectByHandle(FileHandle, ..., IoFileObjectType, ...);
FileObject = ...;
DeviceObject = IoGetRelatedDeviceObject(FileObject);
// 2. 分配 IRP
Irp = IoBuildSynchronousFsdRequest(IRP_MJ_READ, DeviceObject, Buffer,
Length, ByteOffset, Key, ...);
// 3. 调用 IopSynchronousApiTail
return IopSynchronousApiTail(DeviceObject, Irp, FileObject,
IoStatusBlock, TRUE);
}
IopSynchronousApiTail
c
NTSTATUS IopSynchronousApiTail(IN PDEVICE_OBJECT DeviceObject, IN PIRP Irp,
IN PFILE_OBJECT FileObject,
IN PIO_STATUS_BLOCK IoStatusBlock,
IN BOOLEAN RequestIsSync)
{
KEVENT Event;
PKEVENT EventObject = NULL;
NTSTATUS Status;
// 1. 同步 I/O:获取 FileObject 锁
if (FileObject->Flags & FO_SYNCHRONOUS_IO) {
IopLockFileObject(FileObject);
}
// 2. 如果用户提供 Event,使用之
// 否则创建本地 Event
if (Irp->UserEvent) {
EventObject = Irp->UserEvent;
} else {
KeInitializeEvent(&Event, SynchronizationEvent, FALSE);
EventObject = &Event;
}
// 3. 设置完成例程(设置事件)
IoSetCompletionRoutine(Irp, IopSynchronousCompletion, EventObject, TRUE, TRUE, TRUE);
// 4. 发起 I/O
Status = IoCallDriver(DeviceObject, Irp);
// 5. 如果 pending,等待
if (Status == STATUS_PENDING) {
KeWaitForSingleObject(EventObject, Executive, KernelMode, ...);
}
// 6. 同步 I/O:解锁
if (FileObject->Flags & FO_SYNCHRONOUS_IO) {
IopUnlockFileObject(FileObject);
}
// 7. 返回状态
*IoStatusBlock = Irp->IoStatus;
return IoStatusBlock->Status;
}
IopSynchronousCompletion
c
NTSTATUS IopSynchronousCompletion(IN PDEVICE_OBJECT DeviceObject, IN PIRP Irp,
IN PVOID Context)
{
PKEVENT Event = Context;
KeSetEvent(Event, IO_NO_INCREMENT, FALSE);
return STATUS_MORE_PROCESSING_REQUIRED;
}
9.13.5 异步 I/O 路径
异步 I/O 的特点:
IoStatusBlock->Status立即被填为STATUS_PENDING- 线程不阻塞
- I/O 完成时通知机制:
- 事件 :用户创建
OVERLAPPED.hEvent,完成时事件被设置 - APC :用户提供
ApcRoutine,完成时 APC 入队 - IO_STATUS_BLOCK 轮询 :用户调用
GetOverlappedResult
- 事件 :用户创建
ReadFileEx 异步路径
c
BOOL ReadFileEx(HANDLE hFile, LPVOID Buffer, DWORD Length,
LPOVERLAPPED Overlapped,
LPOVERLAPPED_COMPLETION_ROUTINE CompletionRoutine)
{
// 设置 Overlapped->hEvent 为 NULL
// 设置 ApcContext 为 CompletionRoutine
NtReadFile(hFile, NULL, CompletionRoutine, Overlapped, ...);
return TRUE;
}
内核异步完成
c
NTSTATUS IopAsyncCompletion(IN PDEVICE_OBJECT DeviceObject, IN PIRP Irp,
IN PVOID Context)
{
PIO_APC_ROUTINE ApcRoutine = Irp->Overlay.AsynchronousParameters.UserApcRoutine;
PVOID ApcContext = Irp->Overlay.AsynchronousParameters.UserApcContext;
if (ApcRoutine) {
// 排入 APC
KeInsertQueueApc(Irp->Tail.Apc, ...);
}
return STATUS_SUCCESS; // 不是 MORE_PROCESSING
}
用户态等待
c
// 1. 等待事件
WaitForSingleObject(Overlapped->hEvent, INFINITE);
// 2. 轮询结果
BOOL ok = GetOverlappedResult(hFile, Overlapped, &BytesRead, TRUE /* 等待 */);
9.13.6 取消 I/O
IoCancelIrp
c
BOOLEAN IoCancelIrp(IN PIRP Irp)
{
KIRQL OldIrql;
PDRIVER_CANCEL CancelRoutine;
BOOLEAN Result;
IoAcquireCancelSpinLock(&OldIrql);
// 1. 检查是否已经完成
if (Irp->Cancel || Irp->CurrentLocation >= Irp->StackCount) {
IoReleaseCancelSpinLock(OldIrql);
return FALSE;
}
// 2. 标记 Cancel
Irp->Cancel = TRUE;
// 3. 调用取消例程
CancelRoutine = Irp->CancelRoutine;
if (CancelRoutine) {
Irp->CancelRoutine = NULL;
CancelRoutine(Irp->Tail.Overlay.CurrentStackLocation->DeviceObject, Irp);
Result = TRUE;
} else {
Result = FALSE;
}
IoReleaseCancelSpinLock(OldIrql);
return Result;
}
用户态 CancelIo
c
BOOL CancelIo(HANDLE hFile) {
// 取消当前线程在 hFile 上的所有未完成 IRP
NtCancelIoFile(hFile, &IoStatus);
}
取消例程
驱动在 IoSetCompletionRoutine 之前可设置 IoSetCancelRoutine:
c
PDRIVER_CANCEL IoSetCancelRoutine(IN PIRP Irp, IN PDRIVER_CANCEL CancelRoutine);
取消例程负责把 IRP 标记为已取消并完成:
c
VOID MyCancelRoutine(IN PDEVICE_OBJECT DeviceObject, IN PIRP Irp)
{
// 释放排队的资源
// 完成 IRP,状态 STATUS_CANCELLED
Irp->IoStatus.Status = STATUS_CANCELLED;
IoReleaseCancelSpinLock(Irp->CancelIrql);
IoCompleteRequest(Irp, IO_NO_INCREMENT);
}
9.13.7 NtReadFile / NtWriteFile 系统调用
NtReadFile
c
NTSTATUS NtReadFile(
HANDLE FileHandle,
HANDLE Event OPTIONAL,
PIO_APC_ROUTINE ApcRoutine OPTIONAL,
PVOID ApcContext OPTIONAL,
PIO_STATUS_BLOCK IoStatusBlock,
PVOID Buffer,
ULONG Length,
PLARGE_INTEGER ByteOffset OPTIONAL,
PULONG Key OPTIONAL);
参数:
| 参数 | 含义 |
|---|---|
FileHandle |
文件句柄 |
Event |
异步完成事件(可选) |
ApcRoutine |
APC 例程(可选) |
ApcContext |
APC 上下文 |
IoStatusBlock |
I/O 状态 |
Buffer |
读取目标缓冲 |
Length |
字节数 |
ByteOffset |
文件偏移(NULL 表示当前位置) |
Key |
多 IRP 同步键(用于目录控制等) |
关键流程
- 解析
FileHandle为PFILE_OBJECT和PDEVICE_OBJECT - 分配 IRP:
IoBuildAsynchronousFsdRequest或IoBuildSynchronousFsdRequest - 设置完成例程
- 调用
IopSynchronousApiTail或直接IoCallDriver - 等待(如同步)
- 复制 IoStatusBlock 到用户态
IoBuildSynchronousFsdRequest
c
PIRP IoBuildSynchronousFsdRequest(
IN ULONG MajorFunction, // IRP_MJ_READ 等
IN PDEVICE_OBJECT DeviceObject,
IN PVOID Buffer, // 缓冲(系统空间)
IN ULONG Length,
IN PLARGE_INTEGER StartingOffset,
IN PETHREAD RealThread, // 调用线程
IN BOOLEAN InternalDeviceIoControl
);
返回的 IRP 是 同步 的:调用 IoCallDriver 后线程即可 KeWaitForSingleObject 等待。
IoBuildAsynchronousFsdRequest
c
PIRP IoBuildAsynchronousFsdRequest(
IN ULONG MajorFunction,
IN PDEVICE_OBJECT DeviceObject,
IN PVOID Buffer,
IN ULONG Length OPTIONAL,
IN PLARGE_INTEGER StartingOffset,
IN PETHREAD RealThread
);
返回的 IRP 是 异步 的:调用 IoCallDriver 后线程立即返回。
9.13.8 总结
关键要点:
- 三种 I/O 同步模型:同步(阻塞)、异步(立即返回)、事件(通知)
IO_STATUS_BLOCK:Status+InformationSTATUS_PENDING:表示 I/O 还在进行中FO_SYNCHRONOUS_IO:FILE_OBJECT 上的同步标志- 同步路径 :
IopSynchronousApiTail+KeWaitForSingleObject - 异步路径:APC 通知或事件通知
- 取消 I/O :
IoCancelIrp+ Cancel 例程
下一节 9.14 将剖析 IRP 请求的完成与返回。
9.13.9 设计哲学问答
Q1:为什么 Windows NT 选择异步 I/O 作为默认模型而不是同步?
A :来自 VMS 的设计遗产 + 性能可扩展性。
Windows NT 的首席架构师 Dave Cutler 曾是 DEC VMS 的首席设计师,VMS 的 I/O 系统天然是异步的------所有 I/O 操作通过 IORB(I/O Request Block)提交,完成时通过 AST(Asynchronous System Trap)通知。NT 的 IRP 本质上就是 IORB 的翻版。
选择异步作为默认模型的工程理由更关键:同步 I/O 无法在用户态高效模拟异步,但异步可以轻松模拟同步 。如果内核只提供同步 I/O,应用程序需要自己创建线程池来实现并发 I/O,不仅效率低(每个线程一个内核堆栈,内存开销大),还增加了上下文切换。而内核提供异步 I/O 后,应用程序只需使用 WaitForSingleObject 或 GetOverlappedResult 即可实现同步等待------NT 的 IopSynchronousApiTail 正是这样实现的:发起异步 IRP,然后调用 KeWaitForSingleObject 等待完成。
这种"异步原生、同步包装"的设计使得 NT 的 I/O 子系统既能满足简单应用的同步需求,又能为高性能服务器提供零额外开销的异步 I/O 能力。
Q2:为什么 IopSynchronousCompletion 返回 STATUS_MORE_PROCESSING_REQUIRED 而不是 STATUS_SUCCESS?
A :防止 IRP 被提前释放。
IopSynchronousCompletion 是同步 I/O 路径的完成例程,它的职责是设置事件唤醒等待线程:
c
NTSTATUS IopSynchronousCompletion(PDEVICE_OBJECT DeviceObject, PIRP Irp, PVOID Context) {
PKEVENT Event = Context;
KeSetEvent(Event, IO_NO_INCREMENT, FALSE);
return STATUS_MORE_PROCESSING_REQUIRED; // 关键:停止完成处理
}
返回 STATUS_MORE_PROCESSING_REQUIRED 的原因在于:当完成例程被调用时,IRP 的完成处理尚未结束。如果返回 STATUS_SUCCESS,I/O 管理器会继续完成处理,最终调用 IoFreeIrp 释放 IRP。但在同步路径中,等待线程被 KeSetEvent 唤醒后会立即读取 Irp->IoStatus 中的结果------如果 IRP 已经被释放,这就是一个悬挂指针访问。
返回 MORE_PROCESSING_REQUIRED 告诉 I/O 管理器:"我已经处理完了,不要再继续完成处理了。"IRP 的释放责任转移到了唤醒后的线程------它在读取 Irp->IoStatus 后调用 IoFreeIrp 或通过 IopFreeIrp 释放。这就是为什么 IopSynchronousApiTail 在 KeWaitForSingleObject 返回后直接使用 Irp->IoStatus 而不需要担心 IRP 已被释放。
Q3:为什么同步 I/O 需要一个 FileObject 锁(FO_SYNCHRONOUS_IO)来串行化请求?
A :维护文件指针的一致性。
同步 I/O 文件对象维护一个共享的文件指针(FileObject->CurrentByteOffset)。当两个线程同时对同一个同步文件对象执行 ReadFile 时,如果没有串行化,以下竞争条件就会发生:
线程 A:读取位置 100 → 读取 50 字节 → 文件指针前进到 150
线程 B: 读取位置 ??? → 混乱
FileObject->Lock(一个快速互斥锁)保证:同一文件对象上的同步 I/O 请求严格按顺序执行。线程 A 获得锁、读取文件指针、发起 I/O、等待完成、释放锁------在这个完整序列完成之前,线程 B 被阻塞在 ExAcquireFastMutex 上。这确保了文件指针的更新是原子的。
异步 I/O 不 受这个锁保护,因为异步文件对象没有共享文件指针的概念------每个异步 I/O 请求必须通过 OVERLAPPED.Offset 显式指定偏移量。这也意味着,多个异步 I/O 可以并发执行在同一个文件对象上,但必须各自管理自己的偏移。这正是 IOCP(I/O 完成端口)服务器能够用少量线程处理数万个并发连接的基础。
Q4:为什么 NtReadFile 要先尝试快速 I/O 路径再走 IRP 路径?
A :缓存命中时避免 IRP 的全栈开销。
快速 I/O 路径绕过 IRP 的完整构造和下发流程,直接从缓存管理器(Cache Manager)拷贝数据:
正常 IRP 路径:NtReadFile → IoBuildSynchronousFsdRequest → IoCallDriver → 设备栈遍历 → 完成例程链 → 返回
快速 I/O 路径:NtReadFile → FastIoRead → 从缓存拷贝数据 → 返回
当文件已被缓存(FileObject->PrivateCacheMap != NULL)且数据在缓存中命中时,快速 I/O 可以在不进入设备栈的情况下完成读取。这在顺序读取场景中尤其高效------文件系统驱动(如 fastfat)的 FastIoRead 直接从缓存中拷贝数据,延迟通常在亚微秒级别,而 IRP 路径即使不涉及磁盘 I/O 也需要数十微秒的 IRP 分配和栈遍历开销。
ReactOS 的设计者选择只有在快速 I/O 返回确定性的成功状态(STATUS_SUCCESS、STATUS_BUFFER_OVERFLOW、STATUS_END_OF_FILE)时才接受结果。如果快速 I/O 返回 STATUS_PENDING 或失败,NtReadFile 会干净地回退到 IRP 路径,确保在所有情况下都能正确完成 I/O。
Q5:为什么取消 I/O 需要在自旋锁保护下进行?
A :防止取消与完成的经典竞争条件。
IoCancelIrp 面临一个根本性的并发问题:取消操作可能与 I/O 完成操作同时在不同 CPU 核心上执行。取消自旋锁(Cancel SpinLock)解决了这个问题:
c
// CPU 0:取消路径 // CPU 1:完成路径
IoAcquireCancelSpinLock(); // (已完成,未获取锁)
Irp->Cancel = TRUE; // 准备检查取消
CancelRoutine = Irp->CancelRoutine;
IoReleaseCancelSpinLock(); // 我获取锁了吗?
// ... // Irp->Cancel 已被设置
// CancelRoutine 已被取走
如果在无锁保护下执行,可能出现以下竞争:CPU 0 读取 Irp->CancelRoutine 的同时,CPU 1 在完成路径中清除了 CancelRoutine 并调用 IoCompleteRequest。取消例程被两个路径同时调用,导致 IRP 被两次完成------这是内核中最严重的 bug 之一。
取消自旋锁的协议是:所有对 Irp->Cancel 和 Irp->CancelRoutine 的读写都必须在此锁保护下进行 。持有锁的一方可以在读取 CancelRoutine 后安全地调用它(因为它知道在释放锁之前,CancelRoutine 不会被其他路径修改)。取消例程自身负责在某个时刻释放取消自旋锁。
Q6:为什么同步 I/O 等待使用 KeWaitForSingleObject 事件机制而不是忙等待?
A :零 CPU 消耗 + 支持 APC 中断。
同步 I/O 的线程阻塞使用内核事件等待,而不是用户态轮询(while (Status == STATUS_PENDING) Sleep(0))。选择内核事件等待有三个理由:
-
CPU 效率 :
KeWaitForSingleObject将线程状态设为Waiting,调度器立即选择其他就绪线程运行。等待期间不消耗任何 CPU 周期。而用户态轮询即使使用Sleep(0)也会产生上下文切换和调度开销。 -
即时唤醒 :当
IopSynchronousCompletion调用KeSetEvent时,等待线程从Waiting直接变为Ready,调度器在下一个调度点选择它执行。延迟通常在微秒级别------比任何轮询间隔都短得多。 -
APC 投递支持 :
KeWaitForSingleObject的WaitMode参数(KernelModevsUserMode)决定了等待是否可被 APC 中断。同步可警告 I/O(FO_ALERTABLE_IO)使用UserMode等待,允许内核 APC 中断等待并执行 APC 回调。忙等待在用户态无法响应内核 APC 投递,会导致 APC 丢失。
Q7:为什么异步 I/O 的 IO_STATUS_BLOCK 必须保持有效直到 I/O 完成?
A :内核在 I/O 完成时通过 APC 写入用户态内存。
IO_STATUS_BLOCK 在异步 I/O 路径中面临一个独特的内存生命周期问题:NtReadFile 返回后,调用者线程可能继续执行,甚至可能释放或覆盖 IO_STATUS_BLOCK 所在的内存。然而,内核 APC(IopCompleteRequest)在 I/O 完成时需要写入这个结构:
c
// 异步路径的时间线
NtReadFile(... &IoStatus, ...); // 返回 STATUS_PENDING
// 线程继续执行...
// ... I/O 在后台完成
// 内核 APC 触发:IopCompleteRequest 写入 IoStatus.Status 和 IoStatus.Information
如果 IO_STATUS_BLOCK 在 I/O 完成前被释放(例如栈变量超出作用域),内核写入的就是悬挂指针,可能导致数据损坏或系统崩溃。这就是为什么 IO_STATUS_BLOCK 通常作为 OVERLAPPED 结构的一部分分配在堆上(或者作为全局/静态变量),且调用者在调用 GetOverlappedResult 或 WaitForSingleObject 确认 I/O 完成之前不能释放它。
ReactOS 使用 Seh2 保护来写入用户态 IO_STATUS_BLOCK,如果写入失败(用户态内存已被释放),异常被捕获并妥善处理------但这不是正确的编程实践,而是一种崩溃安全的保护措施。
Q8:为什么存在 IoBuildSynchronousFsdRequest 和 IoBuildAsynchronousFsdRequest 两种 IRP 构造函数?
A :IRP 的完成行为差异需要不同的初始化。
两种函数构造的 IRP 在结构上几乎相同,关键区别在于 IRP 的完成处理方式:
-
同步 IRP (
IoBuildSynchronousFsdRequest):创建的 IRP 设置了IRP_SYNCHRONOUS_API标志。当IoCallDriver返回后,调用者会调用KeWaitForSingleObject等待完成。因此 IRP 的完成例程(IopSynchronousCompletion)只需要设置事件,不负责释放 IRP------IRP 由等待线程在唤醒后释放。 -
异步 IRP (
IoBuildAsynchronousFsdRequest):创建的 IRP 没有IRP_SYNCHRONOUS_API标志。I/O 管理器在完成处理中会调用IoFreeIrp释放 IRP。因此调用者必须在NtReadFile返回后确保所有资源(包括IO_STATUS_BLOCK和缓冲区)在 I/O 完成前保持有效。
从实现角度看,IoBuildSynchronousFsdRequest 在 IRP 中设置 RealThread 字段,用于同步 I/O 的超时和取消处理(在 IopAbortInterruptedIrp 中可见)。异步版本则不需要这个字段,因为异步 I/O 的超时由调用者自行管理。
Q9:为什么 APC 完成路径需要两级(内核 APC → 用户 APC)?
A :先清理内核资源,再通知应用层。
异步 I/O 的 APC 完成路径分为两级:
第一级 --- 内核 APC (Irp->Tail.Apc 中的 KernelApcRoutine → IopCompleteRequest):
在 DISPATCH_LEVEL 或 APC_LEVEL 执行,负责:
- 将
Irp->IoStatus拷贝到用户态的IO_STATUS_BLOCK - 解除 MDL 映射(
MmUnlockPages) - 释放系统缓冲区(如果是
DO_BUFFERED_IO) - 如果用户提供了 APC 例程,将用户 APC 插入 APC 队列
第二级 --- 用户 APC (用户提供的 FileRoutine):
在用户态 APC_LEVEL 执行,负责:
- 应用程序的回调处理(如
ReadFileEx的完成回调) - 通知应用 I/O 已完成
两级分离的设计原因在于 IRQL 限制。内核 APC 在 APC_LEVEL 执行,可以安全地访问分页内存和释放内核资源。用户 APC 需要在用户态执行,只能由线程从内核态返回用户态时触发。通过两级 APC,内核资源的清理在用户 APC 触发前已经完成------即使应用程序的用户 APC 回调没有正确处理,内核资源也不会泄漏。
Q10:为什么 FO_ALERTABLE_IO 和 FO_SYNCHRONOUS_IO 需要分开成两个独立的标志位?
A :四种排列组合对应四种不同的 I/O 语义。
FILE_OBJECT->Flags 中的 FO_SYNCHRONOUS_IO 和 FO_ALERTABLE_IO 两个标志位可以组合出四种语义,每种对应不同的打开模式:
FO_SYNCHRONOUS_IO |
FO_ALERTABLE_IO |
打开方式 | 行为 |
|---|---|---|---|
| 0 | 0 | FILE_FLAG_OVERLAPPED |
纯异步,通过事件/轮询获取结果 |
| 1 | 0 | FILE_SYNCHRONOUS_IO_NONALERT |
同步阻塞,等待期间不可被 APC 中断 |
| 1 | 1 | FILE_SYNCHRONOUS_IO_ALERT |
同步阻塞,但等待期间可被 APC 中断 |
| 0 | 1 | 无直接对应 | 理论上存在但极少使用 |
FO_ALERTABLE_IO 的存在意义在于支持 可警告等待 (Alertable Wait)。当线程在同步 I/O 中等待时,如果设置了可警告标志,内核 APC(如 IopCompleteRequest)可以中断等待并执行。这对于需要同时处理 I/O 完成和其他 APC 事件的线程至关重要------例如,线程可能在等待同步 I/O 的同时需要响应线程终止 APC(PsExitProcess),如果没有可警告等待,线程无法响应终止请求。
两个标志独立设计使得 NtReadFile 在判断等待行为时可以通过简单的位运算组合出正确的语义,而不需要额外的复杂条件分支。
本章代码索引
| 文件 | 内容 |
|---|---|
| irp.c(file:///d:/reactos/ntoskrnl/io/iomgr/irp.c) | IRP 生命周期 |
| io.c(file:///d:/reactos/ntoskrnl/io/iomgr/io.c) | NtReadFile、NtWriteFile |
| file.c(file:///d:/reactos/ntoskrnl/io/iomgr/file.c) | IopCreateFile |
| cancel.c(file:///d:/reactos/ntoskrnl/io/iomgr/cancel.c) | IoCancelIrp |
| dispatch.c(file:///d:/reactos/ntoskrnl/io/iomgr/dispatch.c) | IoCallDriver |
| iotypes.h(file:///d:/reactos/sdk/include/xdk/iotypes.h) | IO_STATUS_BLOCK 定义 |
| fileobjs.c(file:///d:/reactos/ntoskrnl/ob/fileobjs.c) | FILE_OBJECT 操作 |