上一回(第1回)我们看到,Windows 的 I/O 请求会变成 IRP 这种数据包,在设备栈中流动;并且,发出与完成,在内核的最底层从一开始就是分离的。
这一回,我们要深入挖掘应用程序一侧用来使用这种分离机制的方式──异步 I/O(重叠 I/O)。加上了 FILE_FLAG_OVERLAPPED,结果却以同步方式返回;把 OVERLAPPED 重复使用后数据损坏了;调用了 CancelIoEx 却停不下来;取消之后又因为访问违例而崩溃──这类「异步 I/O 的恐怖故事」,全都是因为没有把这套机制作为一张图整体掌握而产生的。本文就来把这张图画出来。
本文是系列「Windows I/O 的深层」的第2回。全系列的整体结构见第1回开头。
1. 先说结论
- 同步 I/O 与异步 I/O 的分水岭,不在内核,而在「是否等待」。同步 I/O 的保证是「完成之前不会返回」。只有当请求进入挂起(pending)状态时,I/O 管理器才会等待完成,而线程则处于不消耗 CPU 的等待状态中睡眠(第 2 章)。1
FILE_FLAG_OVERLAPPED是句柄(文件对象)的模式。它在 CreateFile 时就已确定,不能按调用逐次切换。由于异步句柄不会由系统管理文件指针,因此对于磁盘上的文件,每次都要通过OVERLAPPED的Offset指定位置(第 3 章)。21OVERLAPPED结构体是「一个操作的凭证」。需要的数量与正在进行中的操作数相同,官方文档明确指出,共享或重复使用会导致数据损坏。在完成之前,结构体和缓冲区都不能触碰(第 3 章)。34- 完成的接收方式实质上有四种。句柄置于信号状态(不推荐)、
OVERLAPPED的事件 +GetOverlappedResult、APC(alertable wait),以及 I/O 完成端口(下一回)(第 4 章)。156 - 即使以异步方式发出,也有可能同步完成。数据已在缓存中、NTFS 压缩/加密、会延长文件的写入──都是典型例子。「异步=绝对不会阻塞」并不成立(第 5 章)。3
- 取消是一种「请求」。即使调用了
CancelIoEx,操作仍会以ERROR_OPERATION_ABORTED的完成状态返回。在看到这个完成通知之前,不能进行善后处理(第 6 章)。78 - .NET 的
FileOptions.Asynchronous正是这种模式的直通开关。当句柄的模式与所调用的 API 不一致时,就会产生由线程池代为处理的「伪异步」,或是无谓的等待(第 7 章)。910
2. 同步 I/O ── 线程在哪里睡眠
首先看默认的样貌。没有加上 FILE_FLAG_OVERLAPPED 而打开的句柄是同步模式。调用 ReadFile 后,函数要等到 I/O 完成才会返回。1
正如第1回中所见,驱动程序会把请求置为挂起(pending)状态,等待硬件的响应。那么在同步 I/O 中,究竟是谁在等待?答案是 I/O 管理器──它会先等待完成,然后才把控制权交还给应用。
sequenceDiagram
participant App as 应用线程
participant IOM as I/O 管理器
participant DRV as 驱动程序(设备栈)
App->>IOM: ReadFile(同步句柄)
IOM->>DRV: 发出 IRP
DRV-->>IOM: STATUS_PENDING(等待响应)
Note over App,IOM: 线程在内核中进入等待状态<br/>不消耗 CPU 地睡眠
DRV->>IOM: 完成(IoCompleteRequest)
IOM-->>App: 返回结果并唤醒<br/>ReadFile 以 TRUE/FALSE 返回
图1:同步 I/O(请求进入挂起状态的情况)。I/O 管理器会等待完成后再返回给应用
需要说明的是,这张图对应的是请求进入挂起状态的情况。如果驱动程序能够当场完成请求(例如缓存命中,即第1回图5中「立即完成」的路径),那就完全不会产生等待,函数会带着结果直接返回。同步 I/O 的保证是「完成之前不会返回」,而不是「一定会睡眠」。
需要掌握的要点有两个。
- 「等待」不会消耗 CPU。处于等待状态的线程会被调度器排除在执行对象之外。与其靠轮询把自己逼入死角,不如让它等待──这个话题在《为什么在 Windows 上应优先选择事件等待而非 Sleep(1)》中写过。
- 在同步模式的句柄上,内核会管理文件指针(当前位置)。因此连续的
ReadFile才能够「接着往下」读取。这种状态属于文件对象而非句柄,因此用DuplicateHandle复制出来的句柄会共享同一个位置(第1回3.3节)。
同步 I/O 的弱点归结为一点:在等待期间,那个线程无法做其他任何工作。如果在 UI 线程上执行同步 I/O,界面就会卡死;如果服务器为每个连接都开一个线程,那么几百个连接就足以让线程堆积如山。另外,也存在一个名为 CancelSynchronousIo 的 API,可以从外部把因为同步 I/O 而卡住的其他线程解救出来(第6章)。11
3. 异步 I/O ── 句柄的模式与操作的凭证
3.1. 模式由句柄决定
把 FILE_FLAG_OVERLAPPED 传给 CreateFile,句柄背后的文件对象就会以异步模式打开。1 这里重要的是,这是以句柄为单位的性质。做不到「只让这一次调用是异步的」。可以用两个句柄──一个用于同步、一个用于异步──打开同一个文件(这只是多创建了一个文件对象而已)。
异步模式的句柄还有另一个重大区别:系统不会管理文件指针。因为在多个操作同时飞行的状态下,「当前位置」本身就没有意义。对于磁盘上的文件这类具有位置概念的设备,每次读写的位置都要通过 OVERLAPPED 结构体的 Offset/OffsetHigh 明确指定。2 而对于串口或命名管道这类没有寻位概念的设备,Offset 不会被用作位置指定(保持为 0 即可)。即便如此,正如下一节所见,OVERLAPPED 结构体本身仍然是每个操作都需要一份的。
3.2. OVERLAPPED 是「一个操作的凭证」
OVERLAPPED 结构体的作用是标识一个正在进行中的操作,并承载它的状态。4
| 成员 | 作用 |
|---|---|
Offset / OffsetHigh |
这个操作要读写的文件位置(在发出时指定。没有位置概念的设备不使用) |
hEvent |
完成时被置于信号状态的事件(可选。推荐手动重置) |
Internal |
操作的状态。完成前存放相当于 STATUS_PENDING 的值(供系统使用) |
InternalHigh |
完成时的传输字节数(供系统使用) |
flowchart LR
subgraph H["句柄,文件对象 = 模式"]
M1["同步模式<br/>由内核管理当前位置<br/>ReadFile直到完成才返回"]
M2["异步模式,FILE_FLAG_OVERLAPPED<br/>不管理当前位置<br/>发出与完成相互分离"]
end
subgraph O["OVERLAPPED结构体 = 一个操作的凭证"]
F1["Offset,读取哪个位置"]
F2["hEvent,如何得知完成"]
F3["Internal/InternalHigh<br/>状态与结果,由系统写入"]
end
C["在CreateFile时一次性确定"] --> H
R["每次发出ReadFile/WriteFile时准备一份"] --> O
图2:模式属于句柄,状态属于操作(凭证)。混淆这种分工就会酿成事故
由此,就自然得出了官方文档中明确写明的两条禁止事项。3
- 同时飞行的操作有多少个,就需要多少个
OVERLAPPED。发出 3 个操作就需要 3 个。重复使用会导致「不可预测的结果或数据损坏」。 - 在完成之前,
OVERLAPPED和数据缓冲区都必须保持有效,不能去碰它们。因为内核会来向这块区域写入数据。用局部变量的OVERLAPPED发出请求,然后函数就退出了──这是让内核踩到栈上的典型事故。
3.3. 发出操作后有三种返回方式
对异步句柄调用 ReadFile,会有三种返回方式。2
flowchart TB
A["ReadFile,异步句柄,带OVERLAPPED"]
Q{"返回值是?"}
T["TRUE<br/>当场完成,同步完成<br/>默认情况下完成通知也会另外送达"]
P["FALSE + ERROR_IO_PENDING<br/>已被接受,完成会在之后通知"]
E["FALSE + 其他错误<br/>发出本身失败了"]
W["等待完成通知<br/>第4章的四种方式"]
A --> Q
Q --> T
Q --> P
Q --> E
P --> W
图3:异步发出的三个分支。只有同时正确处理 TRUE(立即完成)与 ERROR_IO_PENDING,异步 I/O 才能正常运作
落实到代码中的判断依据是返回值与 GetLastError 的组合。下面这张表可以直接对应到分支逻辑上。
ReadFile 的返回值 |
GetLastError() |
含义 | 调用方应做的事 |
|---|---|---|---|
TRUE |
(不看) | 当场完成(同步完成) | 默认情况下完成通知也会另外送达。结果处理交给通知一侧处理 |
FALSE |
ERROR_IO_PENDING(997) |
已被接受,正在进行 | 什么都不做。不触碰 OVERLAPPED 与缓冲区,等待完成通知 |
FALSE |
除此之外 | 发出本身失败了 | 完成通知不会到来。当场进行错误处理,并善后 OVERLAPPED 与缓冲区 |
// C++ / Win32
// hFile : 以 FILE_FLAG_OVERLAPPED 打开的句柄
// ov : 为这个操作专门分配的 OVERLAPPED(Offset 和 hEvent 已设置好)
// buf/len: 这个操作专用的缓冲区。在收到完成通知之前不要释放
DWORD IssueRead(HANDLE hFile, OVERLAPPED* ov, BYTE* buf, DWORD len)
{
// 异步发出时,向 lpNumberOfBytesRead 传 NULL,
// 传输字节数在完成后通过 GetOverlappedResult 获取
if (ReadFile(hFile, buf, len, nullptr, ov))
{
// (1) 同步完成。默认情况下完成通知也会送达,这里不处理结果
return ERROR_SUCCESS;
}
DWORD err = GetLastError();
if (err == ERROR_IO_PENDING)
{
// (2) 已被接受。不触碰 ov 与 buf,等待完成通知
return ERROR_IO_PENDING;
}
// (3) 发出本身失败。完成通知不会到来,由调用方在此处善后
return err;
}
ERROR_IO_PENDING 不是错误,而是「已接受」。把它当成普通错误来处理,或者反过来,写代码时根本没有考虑 TRUE(同步完成)这种情况──是两大典型 bug。同步完成为什么会发生,将在第5章讨论。
这里还有一个重要的注意事项。在默认设置下,即使操作是同步完成(TRUE)的,完成通知也会另外送达。如果句柄已关联到 I/O 完成端口,完成数据包就会被放入队列;如果采用事件方式,事件也会被置于信号状态。因此,如果代码写成「TRUE 就当场处理结果,通知来了再处理一次」,就会造成对同一个操作重复处理、把凭证释放两次的事故。安全的基本写法是:把「TRUE(同步完成)」与「ERROR_IO_PENDING」这两条路径的结果处理,统一交给通知一侧来做。不过第三条路径──发出本身失败的情况(FALSE + 其他错误)不会有完成通知到来。如果把这种情况也交给等待通知去处理,就会永远等下去,所以应当由发出方当场进行错误处理,并善后凭证。只有当你想切换成「同步完成时跳过通知、当场处理」的做法时,才显式启用 SetFileCompletionNotificationModes(FILE_SKIP_COMPLETION_PORT_ON_SUCCESS)──但这项设置抑制的只是写入 I/O 完成端口的数据包,OVERLAPPED.hEvent 的置位并不会被抑制。它对事件方式无效,是专用于 IOCP 路径的优化(第5章)。12
另外,如果对同步模式的句柄传入 OVERLAPPED,读取会从 Offset 指定的位置开始,但直到完成为止都会阻塞这一行为并不会改变。2 并不是「传了 OVERLAPPED 就是异步」。模式终究是由句柄持有的。
4. 如何得知完成 ── 四种通知路径
既然发出与完成已经分离,「完成了」这件事如何被接收,就成了设计的核心。路径实质上有四种。1
flowchart TB
DONE["I/O在内核中完成<br/>IoCompleteRequest到APC确定结果"]
N1["1,文件句柄变为信号状态<br/>接收方式,WaitForSingleObject,传入句柄"]
N2["2,OVERLAPPED的hEvent变为信号状态<br/>接收方式,WaitForSingleObject加GetOverlappedResult"]
N3["3,完成例程被排入发出线程的APC队列<br/>接收方式,在SleepEx等alertable wait中执行"]
N4["4,完成数据包被放入I/O完成端口<br/>接收方式,GetQueuedCompletionStatus,详见第3回"]
DONE --> N1
DONE --> N2
DONE --> N3
DONE --> N4
图4:完成通知的四条路径。用哪一种接收,取决于发出方式(是否有 hEvent、是否用 ReadFileEx、是否关联端口)
先把全貌整理成表格。各小节就是这张表内容的说明。
| 方式 | 执行完成处理的线程 | 可同时发出的 I/O 数量 | 适合的场景 |
|---|---|---|---|
| (1) 句柄置于信号状态 | 等待中的任意线程 | 实质上只有 1 个。发出多个后无法区分是哪个完成了 | 几乎用不上(4.1) |
(2) 事件 + GetOverlappedResult |
等待中的任意线程 | 每个操作都需要一个事件。用 WaitForMultipleObjects 一起等待时上限为 64 个 |
数量不多的同时 I/O。设备通信(4.2) |
(3) APC(ReadFileEx) |
发出请求的线程,而且仅限于处于 alertable wait 期间 | 数量没有限制,但完成处理全部在这一个线程中串行执行 | 希望在单线程内完结的通信处理(4.3) |
| (4) I/O 完成端口 | 与端口关联的工作线程组 | 可以用少数线程承接大量请求 | 服务器、线程池(4.4) |
4.1. 句柄置于信号状态 ── 不要使用
不设置 hEvent 而发出请求时,完成时文件句柄本身会变为信号状态。乍看很省事,但如果同一个句柄上有多个操作同时在飞,就无法区分是哪一个完成了。1 除了「异步 I/O 每次只发一个」这种特殊情况以外,不使用它才是安全的做法。
4.2. 事件 + GetOverlappedResult ── 基本形式
在 OVERLAPPED.hEvent 中放入手动重置事件后发出请求,用 WaitForSingleObject(或用 WaitForMultipleObjects 同时等待多个)等待,再用 GetOverlappedResult 取出结果(成败与传输字节数)。13 把 GetOverlappedResult 的 bWait 传 TRUE,也可以做到「等到完成为止再取出」。如果把事件设为自动重置,一旦别的等待消耗掉了信号,就会有 GetOverlappedResult 卡住的陷阱,因此推荐使用手动重置。134
这是稳健处理数量不多的同时 I/O 时,最容易看清全貌的方法。对于串口这类必须「边读边写」的设备,这种形式至今仍在一线使用(参见《串口通信应用的陷阱》)。
4.3. APC ── 送达发出请求的线程
ReadFileEx/WriteFileEx 接收的不是事件,而是完成例程(回调函数)。完成后,该例程会被排入发出请求的线程的 APC 队列,并在该线程进入 SleepEx、WaitForSingleObjectEx 等alertable wait 时执行。14515
这种方式的特点是「完成处理一定会在发出请求的线程上执行」。这样就不再需要加锁,但反过来,只要发出线程不进入 alertable wait,完成例程就永远不会运行。要和 UI 线程的消息循环组合使用,还需要用到 MsgWaitForMultipleObjectsEx,等待方式的设计颇为棘手,因此通用场景下往往会选择事件方式或 IOCP。
而「APC 没有被送达」是这种方式的典型 bug。原因几乎只有一个:等待方式不是 alertable 的。
// C++ / Win32。hFile 是以 FILE_FLAG_OVERLAPPED 打开的句柄,
// ov 和 buf 在完成之前保持有效(第3.2节的前提)
// 不好的例子: 完成例程永远不会被调用
ReadFileEx(hFile, buf, len, ov, OnReadCompleted);
Sleep(1000); // 不是 alertable 的等待。APC 不会被送达
// 好的例子: 持续进行 alertable 等待,直到这个 I/O 结束为止
//
// 在完成例程一侧设置这个标志(可以放在 ov 所挂靠的自定义结构体中)
volatile bool completed = false;
// 务必确认是否发出成功。返回 0 时说明完成例程根本没有被排入队列
if (!ReadFileEx(hFile, buf, len, ov, OnReadCompleted))
{
const DWORD err = GetLastError(); // 立刻获取。之后调用其他 API 会覆盖它
ReportError(err); // 设备被拔出、句柄无效等
return; // ★ 不要进入下面的等待循环
}
while (!completed)
{
DWORD r = SleepEx(1000, TRUE); // 第二个参数 TRUE 表示 alertable
if (r == WAIT_IO_COMPLETION)
{
// 某个 APC 被执行了。但不一定是自己的 I/O,
// 因此要看 completed 来判断,不是的话就继续等待
continue;
}
// 因超时而返回。此时 I/O 仍然处于发出状态,
// 如果要中止,就用 CancelIoEx 取消,并等待完成被送达
CancelIoEx(hFile, ov);
}
不要在没有查看 ReadFileEx 返回值的情况下就进入等待循环。如果因为设备刚被拔出、句柄已经失效等原因导致发出本身失败,ReadFileEx 会返回 0,此时完成例程一个也不会被排入队列。在这种状态下进入 while (!completed),completed 就永远不会成立,从而变成一个针对根本不存在的 I/O,不断重复 SleepEx 与 CancelIoEx 的循环。而且从表面上看,这只是「设备没有响应」而已,要找到真正原因往往要花很长时间。返回 0 时,应该当场取得 GetLastError()(哪怕只是中间夹了一个其他 API 调用,值就会被覆盖),直接退出而不进入等待。
只调用一次 SleepEx 是不够的。因超时而返回时,线程会在那一刻退出 alertable wait。此时 I/O 仍处于发出状态,如果之后离开作用域导致 ov 或 buf 消失,内核就会踩到一块它以为还活着的缓冲区(第3.2节)。请务必做到以下两者之一:要么在完成例程一侧记录是否已完成,并持续等待直到该标志成立;要么用 CancelIoEx 取消,然后等待完成的送达。
返回 WAIT_IO_COMPLETION 只意味着「至少执行了一个 APC」,并不代表那就是自己这个 I/O 的完成例程。如果同一个线程上还排着别的 I/O,或者 QueueUserAPC 排入的 APC,也会因此返回。所以不能只看返回值来判断,而要查看自己设置的那个标志。
另外,等待函数本身的选择很简单。把 Sleep 换成 SleepEx(..., TRUE),把 WaitForSingleObject 换成 WaitForSingleObjectEx(..., TRUE)。如果写好了完成例程却什么都没发生,请先检查等待函数末尾是否带 Ex,以及 alertable 参数是否为 TRUE。5
4.4. I/O 完成端口 ── 真正可扩展的方案(下一回)
用少数线程承接大量同时 I/O 的机制,就是I/O 完成端口(IOCP)。把句柄关联到端口后,完成事件会进入端口的队列,工作线程通过 GetQueuedCompletionStatus 取出。6 这也是 .NET 的 async/await 的 I/O 最终抵达的地方。下一回将用整整一篇来深挖这个主题。
5. 「明明是异步却同步完成」的问题
这是异步 I/O 设计中最先绊倒人的地方。即使以异步模式正确地发出请求,I/O 以同步方式完成也是很常见的事情。微软在故障排查文档中列举了典型的原因。3
flowchart TB
A["向异步句柄发出ReadFile/WriteFile"]
Q{"是否符合同步完成的条件"}
C1["能立即满足的请求<br/>数据已在缓存中等"]
C2["NTFS压缩文件<br/>压缩文件不会以异步方式处理"]
C3["NTFS加密,EFS文件"]
C4["延长文件长度的写入"]
T["以TRUE立即返回<br/>= 在调用中已执行到完成"]
P["以ERROR_IO_PENDING返回<br/>= 真正以异步方式进行中"]
A --> Q
Q --> C1
Q --> C2
Q --> C3
Q --> C4
C1 --> T
C2 --> T
C3 --> T
C4 --> T
Q -->|"都不符合"| P
图5:同步完成的主要条件。缓存、压缩、加密、扩展写入都属于「不会以异步方式进行」
各自都有其原因。3
- 缓存命中。许多驱动程序都有「能立即完成的请求就当场完成」的特殊处理。对磁盘来说,就是数据已经在内存缓存中的情况。既然速度更快,本不该有什么不满,但那些「假定一定会返回 ERROR_IO_PENDING」的代码,会在这里出问题。
- 反过来,即使数据不在缓存中,也存在陷阱。Windows 的缓存是用文件映射实现的,由于页面不存在时的缺页处理没有异步机制,所以启用了缓存的异步读取,有时也会被同步处理。缓存机制本身将在第4回讨论。
- NTFS 压缩・EFS 加密。文件系统驱动程序会把对压缩/加密文件的访问转换为同步方式。
- 延长文件的写入。会改变长度的写入会变成同步方式。
对实务的启示很简单。
- 务必写出「以 TRUE 立即返回」这条路径。图3中的三个分支全部都是正常路径。不过由于默认情况下即使同步完成,完成通知也会另外送达,把结果处理统一交给通知路径,会更安全(第3.3节)。
- 不能用它来保证响应性。「因为是异步,所以 UI 不会卡死」这种说法并不成立。对于绝不能卡死的线程,需要从设计上让它一开始就不发出 I/O(拆分到专用线程或线程池中)。这部分的实务在《在普通 Windows 上尽量做到软实时的实践指南》中也有讨论。
- 在高频 I/O 场景下,同步完成也是一次优化的机会。存在一个名为
SetFileCompletionNotificationModes的 API,可以在同步完成时省去写入 I/O 完成端口的数据包,与 IOCP 组合使用时效果明显(第3回)。12
6. 取消与善后 ── 「请停止」只是一个请求
想要停止耗时很长的 I/O(没有响应的网络对端、迟迟不来的串口数据)时,正规做法是 CancelIoEx。7
sequenceDiagram
participant App as 应用
participant IOM as I/O 管理器
participant DRV as 驱动程序
App->>IOM: CancelIoEx(句柄, OVERLAPPED)
Note over IOM: 对相应的未完成IRP<br/>请求取消(做标记)
IOM->>DRV: 调用取消例程
Note over DRV: 处于可以取消的状态就中止<br/>接近完成时也可能正常完成
DRV->>IOM: IoCompleteRequest<br/>(STATUS_CANCELLED)
IOM-->>App: 完成通知送达<br/>GetOverlappedResult返回ERROR_OPERATION_ABORTED
Note over App: 看到这个通知之后<br/>再释放OVERLAPPED与缓冲区
图6:取消的实际过程。被取消的操作同样会以「完成」的形式返回
只要了解这套机制,就能自然得出以下三个结论。
- 取消是一种异步的「请求」。即使
CancelIoEx调用成功,也只是「做了标记」而已。原本已经接近完成的操作,仍有可能正常完成。8 - 被取消的操作,同样会以
ERROR_OPERATION_ABORTED的形式在完成通知中返回。在收到那个通知之前,OVERLAPPED与缓冲区都还在被内核使用。如果提前释放,就会造成内存破坏。「一取消程序就崩溃」的原因,几乎都出在这里。78 - 在关闭句柄之前,先处理好未完成的 I/O。正如第1回中所见,最后一个句柄关闭时,cleanup 处理会对未完成的 IRP 执行取消,但「发出的 I/O 仍未处理完就只关闭句柄」这种代码,往往会在完成通知与缓冲区生命周期的管理上出问题。取消 → 看到完成 → 关闭才是应当遵循的顺序。
补充两点。旧的 CancelIo 只能取消调用线程自身发出的操作(这是在 Vista 引入 CancelIoEx 之前的限制,如今没有理由刻意使用它)。16 另外,对于因同步 I/O 而卡住的其他线程,应使用 CancelSynchronousIo。11 「超时不是操作系统会替你操心的事,取消需要自己去设计」──这正是异步 I/O 实务的核心所在。
7. 从 .NET 的视角看 ── 模式不一致会产生「伪异步」
到目前为止的内容,与 .NET 的代码是直接相通的。FileStream 构造函数中的 useAsync(或 FileOptions.Asynchronous),正是 FILE_FLAG_OVERLAPPED 的直通开关(参见第1回的对应表)。
flowchart TB
A["await fs.ReadAsync"]
Q{"句柄是否为异步模式<br/>FileOptions.Asynchronous?"}
Y["真正的异步I/O<br/>发出相当于OVERLAPPED的请求<br/>完成经由IOCP送到线程池,第3回"]
N["伪异步<br/>由线程池的线程<br/>代为执行同步Read并等待"]
A --> Q
Q -->|是| Y
Q -->|否| N
图7:同样是 ReadAsync,句柄的模式不同,其底层实现却截然不同
产生差异的只有打开文件的那一行。ReadAsync 的调用一方保持不变,仅凭阅读代码是无法察觉的。
using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Win32.SafeHandles;
string path = @"C:\temp\data.bin";
byte[] buffer = new byte[4096];
// (A) 伪异步。省略 useAsync 或设为 false,句柄就会以同步模式打开
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
bufferSize: 4096, useAsync: false))
{
// 调用方不会被阻塞,但背后由线程池中的一个线程代为执行同步 Read 并等待
await fs.ReadAsync(buffer, 0, buffer.Length);
}
// (B) 真正的异步。useAsync: true 直接对应 FILE_FLAG_OVERLAPPED
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
bufferSize: 4096, useAsync: true))
{
// 完成经由 IOCP 送到线程池(第3回)
await fs.ReadAsync(buffer, 0, buffer.Length);
}
// (C) .NET 6 及以后版本。明确指定模式与偏移量的直白写法
using (SafeFileHandle handle = File.OpenHandle(path, FileMode.Open, FileAccess.Read,
options: FileOptions.Asynchronous))
{
int read = await RandomAccess.ReadAsync(handle, buffer, fileOffset: 0);
}
(A)和(B)的差异只在 useAsync 这一个词上(写成 FileOptions.Asynchronous 效果相同),而这正是「伪异步」的复现条件本身。检查既有代码时,不要盯着 ReadAsync/WriteAsync 那一侧,而要去找创建 FileStream 的地方。File.OpenRead 或 new FileStream(path, FileMode.Open) 这类简短的重载,无一例外都是以同步模式打开的。另外,从 SafeFileHandle 创建 FileStream 时,需要让 isAsync 参数与句柄的实际模式保持一致。
- 同步模式的句柄 +
ReadAsync,是由线程池线程执行同步读取的「伪异步」。调用方不会等待,但背后有一个线程在睡眠。数量少的话实际危害不大,但在服务器或高频处理中,会成为线程池枯竭的原因。9 - 异步模式的句柄 + 同步
Read,则是反方向的不匹配,内部等待完成的那部分开销就白白浪费了。让模式与所调用的 API 保持一致才是原则。10 - .NET 6 以后,
FileStream的内部实现被全面重写,还加入了File.OpenHandle+RandomAccess这种「明确指定SafeFileHandle与偏移量来读写」的 API。9 每次都传入偏移量的这种形式,正是本文中所看到的异步句柄 +OVERLAPPED.Offset这一 Win32 原始面貌本身。 - 只要句柄是异步模式,通过
CancellationToken取消文件 I/O,内部最终都会走到CancelIoEx。传入了令牌的ReadAsync以OperationCanceledException结束的背后,第6章的那张图正原样运作着。取消是一种「请求」、不保证即时性,这一点也完全一样。而在同步模式句柄的「伪异步」中,由于不存在可以作为取消对象的重叠操作,这条路径根本用不上。近年的 .NET 运行时中,也加入了针对这种正在同步执行的调用、尝试用CancelSynchronousIo进行取消的机制,但是否有效取决于运行时版本与操作种类,并不保证一定能够中断。如果要把取消作为设计的前提,正确的做法是让模式保持一致,用上真正的异步 I/O。
另外,关于 async/await 该怎么写这种上层实务(ConfigureAwait、与 UI 线程的关系),请参考《C# async/await 实战判断表》与《用一张图整理 WPF/WinForms 的 async 与 UI 线程》。本文相当于地下一层,下一回(IOCP)则是地下二层。
8. 总结
- 同步 I/O 与异步 I/O 并不是两套不同的管道,区别只在于I/O 管理器是等待完成,还是不等待就返回。同步 I/O 的线程会处于等待状态睡眠,不消耗 CPU。1
- 模式属于句柄(文件对象),状态属于操作(
OVERLAPPED)。由于异步句柄不管理文件指针,对于有位置概念的文件,每次都要用Offset指定。24 OVERLAPPED与缓冲区在完成通知到来之前,要保持有效且不能触碰。要按同时发出的数量各准备一份。重复使用就是数据损坏。3- 完成通知有句柄、事件、APC、IOCP四条路径。多个同时 I/O 时不要用句柄置信号,事件要用手动重置,APC 的前提是 alertable wait。1135
- 即使以异步方式发出,在缓存命中、NTFS 压缩/加密、扩展写入这些情况下仍会同步完成。「以 TRUE 立即返回」这条路径要作为正常路径必写出来,并且不要用它来保证响应性。3
- 取消只是请求。在调用
CancelIoEx之后,也要看到完成通知(ERROR_OPERATION_ABORTED)之后才能善后。顺序是取消 → 确认完成 → 关闭。78 - .NET 的
FileOptions.Asynchronous直通FILE_FLAG_OVERLAPPED,模式与 API 的不一致会产生「伪异步」。CancellationToken的底层,运作的正是CancelIoEx。910
接下来是第3回《I/O 完成端口(IOCP)与 .NET 线程池 ── async/await 的地下室》。本回第4.4节只是提到了名字的 IOCP,为什么会被设计成把「完成通知的队列」与「执行线程数的控制」合而为一?await 之后的代码究竟在哪个线程上运行?我们将继续深挖下去。
相关文章
- Windows I/O 的深层(第1回) ── 所有读写都会变成 IRP:I/O 系统全貌
- C# async/await 实战判断表:Task.Run 与 ConfigureAwait 怎么选
- 用一张图整理 WPF/WinForms 的 async 与 UI 线程
- 串口通信应用的陷阱 - 直到重连与日志设计
- 为什么在 Windows 上应优先选择事件等待而非 Sleep(1)
- 在普通 Windows 上尽量做到软实时的实践指南
- TCP 中「发送单位=接收单位」的误区 ── 用字节流思维设计接收端,避免拆包与粘包
相关咨询领域
合同会社小村软件承接使用异步 I/O 的 Windows 业务应用・设备通信应用的设计,以及「卡死」「取消后崩溃」「线程池枯竭」等故障的原因调查。
参考链接
-
Microsoft Learn,Synchronous and asynchronous I/O。关于同步 I/O 中函数会阻塞到 I/O 完成为止,异步 I/O 中发出请求的函数会立即返回、线程可以继续执行其他工作,异步 I/O 需要在打开句柄时指定 FILE_FLAG_OVERLAPPED,完成的通知方式包括文件句柄置于信号状态、OVERLAPPED 结构体中指定的事件被置于信号状态、在 alertable wait 中执行的完成例程(APC),以及 I/O 完成端口,以及当多个操作同时发出时,文件句柄置于信号状态的方式无法区分是哪个操作完成等内容。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9
-
Microsoft Learn,ReadFile function。关于以 FILE_FLAG_OVERLAPPED 打开的句柄必须传入 lpOverlapped、读取起始位置要通过 OVERLAPPED 结构体的 Offset/OffsetHigh 指定、以异步方式处理时会返回 FALSE 并附带 ERROR_IO_PENDING、系统不会为异步句柄维护文件指针,以及对不带 FILE_FLAG_OVERLAPPED 打开的句柄传入 OVERLAPPED 时会从指定偏移量读取,但直到读取完成 ReadFile 才会返回等内容。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn,Asynchronous disk I/O appears as synchronous on Windows。关于即使按异步方式编码,I/O 仍会同步完成的原因,包括 NTFS 压缩文件(文件系统驱动程序不会以异步方式访问压缩文件,全部操作都会变成同步)、NTFS 加密文件、会延长文件长度的写入、请求能够立即被满足的情况(例如数据已在内存缓存中)下驱动程序会当场完成操作并返回 TRUE,Windows 的缓存是用文件映射实现的、页面不存在时的缺页处理没有异步机制,此外还包括发出 3 个 I/O 就需要 3 个 OVERLAPPED 结构体、重复使用会导致不可预测的结果或数据损坏,以及在操作完成之前不得读写对应的数据缓冲区等内容。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn,OVERLAPPED structure。关于 OVERLAPPED 结构体保存异步输入输出所需的信息,Offset/OffsetHigh 保存文件位置,hEvent 保存完成时被置于信号状态的事件,Internal/InternalHigh 保存操作的状态码与传输字节数,操作执行期间不得修改该结构体、必须保持其有效,以及使用事件时的注意事项等内容。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn,Alertable I/O。关于在 alertable I/O 中,完成例程的入口会被排入线程的 APC 队列,线程通过 SleepEx、WaitForSingleObjectEx、WaitForMultipleObjectsEx 等进入 alertable 状态时 APC 会被执行,以及 APC 一定会在发出请求的线程上下文中执行等内容。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn,I/O Completion Ports。关于 I/O 完成端口为在多处理器系统上处理大量异步 I/O 请求提供了一种高效的线程模型,把文件句柄关联到端口后完成数据包会被放入队列,工作线程通过 GetQueuedCompletionStatus 取出,以及端口会控制并发执行的线程数等内容。 ↩ ↩2
-
Microsoft Learn,CancelIoEx function。关于 CancelIoEx 会不论是哪个线程发出的,都对指定句柄上未完成的 I/O 标记取消,指定 lpOverlapped 则只有该操作会成为对象,传 NULL 则该句柄的全部未完成 I/O 都会成为对象,被取消的操作会以 ERROR_OPERATION_ABORTED 完成,以及并不保证所有操作都一定会被取消,需要等待完成处理结束等内容。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn,Canceling pending I/O operations。关于未完成 I/O 的取消机制,以及即使请求了取消,操作也有可能已经在朝完成的方向进行、应当在确认被取消的操作已经完成之后再释放资源,同步操作使用 CancelSynchronousIo、异步操作使用 CancelIo/CancelIoEx 的这套区分等内容。 ↩ ↩2 ↩3 ↩4
-
Microsoft .NET Blog,File IO improvements in .NET 6。关于 .NET 6 中 FileStream 的内部实现被全面重写、策略会因句柄是否以异步模式打开而不同、可以用 File.OpenHandle 直接获取 SafeFileHandle 并通过 RandomAccess 进行明确指定偏移量的读写(线程安全),以及对非异步模式句柄的异步调用会被卸载到线程池等内容。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn,Asynchronous file I/O (.NET)。关于 .NET 中异步文件 I/O 的基本思路,以及在 FileStream 中使用异步 I/O 时需要在构造函数中指定 useAsync(FileOptions.Asynchronous)以启用操作系统级的异步 I/O,还有同步方法与异步方法的使用场景区分等内容。 ↩ ↩2 ↩3
-
Microsoft Learn,CancelSynchronousIo function。关于 CancelSynchronousIo 会对指定线程正在执行的同步 I/O 操作标记取消,以及被取消的操作会以 ERROR_OPERATION_ABORTED 作为失败返回等内容。 ↩ ↩2
-
Microsoft Learn,SetFileCompletionNotificationModes function。关于通过 FILE_SKIP_COMPLETION_PORT_ON_SUCCESS 可以选择在 I/O 立即成功时不向 I/O 完成端口写入完成数据包,通过 FILE_SKIP_SET_EVENT_ON_HANDLE 可以省略对文件句柄事件的置位等内容。 ↩ ↩2
-
Microsoft Learn,GetOverlappedResult function。关于 GetOverlappedResult 用于获取异步操作的结果(成败与传输字节数)、向 bWait 传入 TRUE 会一直等到操作完成为止,以及当在 OVERLAPPED 的 hEvent 中指定了自动重置事件、而信号又被其他等待消耗掉时,bWait=TRUE 的调用可能检测不到完成而一直等下去,因此应当使用手动重置事件等内容。 ↩ ↩2 ↩3
-
Microsoft Learn,ReadFileEx function。关于 ReadFileEx 会接收在读取完成时被调用的完成例程(FileIOCompletionRoutine)、完成例程会在调用线程处于 alertable wait 状态时执行,以及需要以 FILE_FLAG_OVERLAPPED 打开的句柄等内容。 ↩
-
Microsoft Learn,Asynchronous Procedure Calls。关于 APC 是在特定线程上下文中异步执行的函数、每个线程都拥有自己的 APC 队列,以及用户模式 APC 只有在线程处于 alertable 状态时才会执行等内容。 ↩
-
Microsoft Learn,CancelIo function。关于 CancelIo 只能取消调用线程自身发出的 I/O 操作,要取消包括其他线程发出的操作在内的全部操作则需要使用 CancelIoEx 等内容。 ↩
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
Windows I/O 的深层(第3回) ── I/O 完成端口(IOCP)与 .NET 线程池:async/await 的地下室
本文是通过图解讲解 I/O 完成端口(IOCP)的系列第 3 回。整理了将完成队列与线程数控制融为一体的设计、并发值与 LIFO 释放,直至 .NET 线程池与 async/await 续体的执行线程等内容。
Windows I/O 的深层(第1回) ── 所有读写都会变成 IRP:I/O 系统全貌
本文是从根源讲解 Windows I/O 系统的系列第 1 回。通过图解梳理对象管理器的命名空间、驱动程序・设备・文件这三种对象、IRP 的生命周期,直至 CloseHandle 的背后机制。
Windows I/O 的深层(第4回) ── 缓存管理器:你的 WriteFile 何时才能到达磁盘
本文是图解 Windows I/O 系列的第 4 回,讲解缓存管理器:以文件映射方式实现的缓存、先读与延迟写入、FlushFileBuffers 与 FILE_FLAG_NO_BUFFERING 的使用场景划分,直至断电导致数据丢失的条件。
Windows I/O 的深层(第6回·最终回) ── 过滤器驱动程序与迷你过滤器:Procmon 与病毒扫描为何能够介入 I/O
本文是通过图解讲解 Windows 过滤器驱动程序与迷你过滤器的系列最终回。整理过滤器管理器与高度、pre/post 回调、Procmon 与杀毒软件能够检查全部 I/O 的机制,直至「唯独那个环境很慢」的排查步骤。
Windows I/O 的深层(第5回) ── NTFS 的内部结构:从 MFT 理解文件系统
本文是通过图解讲解 NTFS 内部结构的系列第 5 回。从开发者视角整理 MFT 与文件记录、多数据流(Zone.Identifier)、硬链接与 8.3 短文件名、重解析点、两种日志,直至稀疏与压缩等内容。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
常见问题
汇总了咨询这一主题时常见的问题。
- 加上 FILE_FLAG_OVERLAPPED 会发生什么变化?
- 句柄背后的文件对象会以「异步模式」打开。这是在 CreateFile 时就确定下来的、以句柄为单位的性质,不能按调用逐次切换同步与异步。对于异步模式的句柄,必须在每次调用 ReadFile/WriteFile 时都传入 OVERLAPPED 结构体。系统不会为这个句柄维护文件指针(当前位置),因此对于像磁盘上的文件这种具有「位置」概念的设备,每次读写的位置都要通过 OVERLAPPED 的 Offset 指定(对于串口等没有位置概念的设备则不使用 Offset)。发出的操作有可能在完成之前就把控制权交还给调用方,这种情况下 ReadFile 会返回 FALSE,GetLastError 会得到 ERROR_IO_PENDING。完成情况通过事件、APC、I/O 完成端口等通知方式来接收。
- 明明发出的是异步 I/O,为什么会立刻完成并返回?
- 因为异步模式意味着「不必等待完成」,而不是「绝对不会被阻塞」。微软的官方文档列举了即使以异步方式发出,I/O 仍会同步完成的典型原因:请求可以立即满足(例如数据已经在缓存中)、NTFS 压缩文件、NTFS 加密(EFS)文件,以及会延长文件长度的写入。在这些情况下,ReadFile/WriteFile 会返回 TRUE,结果在调用当场就已经确定。因此,使用异步 I/O 的代码必须同时考虑「以 ERROR_IO_PENDING 返回的情况」和「当场完成的情况」这两种可能,响应性也并非绝对能够得到保证。另外,在默认设置下,即使操作是同步完成的,完成通知(事件置位或写入 I/O 完成端口的数据包)仍会另行送达,因此把结果处理统一交给通知一侧来做会更安全。
- OVERLAPPED 结构体可以重复使用吗?
- 不能让多个操作同时共用同一个。OVERLAPPED 结构体表示的是「一个正在进行中的操作」的状态,微软的官方文档也明确指出:如果要发出 3 个 I/O,就需要 3 个 OVERLAPPED 结构体,重复使用会导致不可预测的结果或数据损坏。在操作完成之前,必须让结构体和读写缓冲区都保持有效,不能触碰其内容。完成之后如果要复用,每次都要重新初始化,以免上一次遗留的数据造成影响。用于 hEvent 的事件,使用手动重置事件会更安全。
- 要中途取消正在执行的 I/O,应该怎么做?
- 使用 CancelIoEx,无论是哪个线程发出的,都可以对指定句柄上尚未完成的 I/O 请求取消。第二个参数传入 OVERLAPPED 就只针对那一个操作,传 NULL 则该句柄上的全部操作都会成为对象。旧的 CancelIo 只能取消「调用线程自身发出的操作」。重要的是,取消只是一种「请求」,而不是即时的「保证」。已经接近完成的操作有可能正常完成,被取消的操作则会以 ERROR_OPERATION_ABORTED 的完成状态通知回来。无论哪种情况,在收到完成通知之前都不能释放 OVERLAPPED 结构体和缓冲区。对于在另一个线程中执行同步 I/O 而卡住的线程,有专门的 API CancelSynchronousIo 可用。
- .NET 的 FileStream 如果不指定 FileOptions.Asynchronous(useAsync),会发生什么?
- 由于句柄会以同步模式打开,即使调用 ReadAsync/WriteAsync,也不会成为真正的异步 I/O,而是变成由线程池线程代为执行同步读写的「伪异步」。调用方所在的线程不会被阻塞,但背后有另一个线程在等待,这会成为线程池枯竭和可伸缩性下降的原因。反过来,如果以异步模式打开却调用同步的 Read/Write,内部则会产生等待完成的额外开销。原则是让「句柄的模式」与「所调用的 API」保持一致,如果是 .NET 6 以后的版本,可以用 File.OpenHandle 和 RandomAccess,写出明确指定模式与偏移量的直白代码。