更新记录(仅首版,2026年07月29日 发布)
- 首次发布
引用本文(DOI(已登记存档): 10.5281/zenodo.22175274)
以下 DOI 指向先前登记的存档,内容可能与当前正文不同。引用当前正文时,请使用本页网址。
Go Komura(2026)。《Windows I/O 底层原理(第 2 篇)——同步 I/O 与异步 I/O:OVERLAPPED 的真正含义》。小村软件有限公司。 https://comcomponent.com/zh-CN/blog/windows-io-sync-async-overlapped/
- DOI(已登记存档)
- 10.5281/zenodo.22175274
- DOI(上次登记版本)
- 10.5281/zenodo.22175275
上一篇(第 1 篇)看到,Windows 的 I/O 请求会变成 IRP 这种数据包在设备栈中流动,而且请求的发出与完成,在内核内部是分开的。本篇要讲的,是从应用程序一侧使用这种分离的异步 I/O(重叠 I/O)。
加了 FILE_FLAG_OVERLAPPED,调用却还是被阻塞。复用 OVERLAPPED 之后数据损坏了。取消之后立刻释放缓冲区就崩溃。理解这些现象的关键,是“模式属于句柄,状态属于每个操作,收尾要等确认完成之后”这样的分工。
本文按顺序追踪从打开文件、发出 I/O、接收结果到收尾处理的整个过程。先把 Win32 的机制理清,再确认 .NET 的 FileStream、ReadAsync、CancellationToken 分别接到哪里。
本文是系列“Windows I/O 底层原理”的第 2 篇。整个系列的结构放在第 1 篇的开头。
1. 先说结论:三组容易混淆的区分
异步 I/O 如果只看 API 名称来判断,就会变得难懂。先把设置的对象和判断完成的时点分开。
| 容易混淆的地方 | 区分的要点 |
|---|---|
| 句柄的模式与操作的状态 | 同步、异步的模式在 CreateFile 时就已确定。OVERLAPPED 持有的是向该句柄发出的单个操作的状态 |
| 发出的结果与完成的接收 | ERROR_IO_PENDING 不是失败,而是受理。TRUE 表示同步完成,但默认情况下通知同样会送达。不要在两边都处理结果 |
| 取消请求与可以收尾的时点 | CancelIoEx 只是取消的请求。释放结构体和缓冲区要在确认该操作已完成之后 |
同步 I/O 在完成之前不会返回调用方。异步 I/O 则可以使用在完成之前就返回的路径。不过,即使是异步模式,I/O 也可能在调用内部完成,它并不保证“绝对不会被阻塞”。12
实现时按确定模式 → 准备操作专用的结构体与缓冲区 → 判定发出的结果 → 接收完成 → 收尾处理的顺序考虑。即使提出了取消请求,接收完成这一步也不能省。34
如果目标已经明确,可以按下面的索引直接跳转。
| 想了解的问题、遇到的困难 | 首先阅读的位置 |
|---|---|
| 同步 I/O 与异步 I/O 到底差在哪里 | 第 2 章:等待的机制、第 3.1 节:句柄的模式 |
| 用了 OVERLAPPED 之后数据损坏,或离开函数后崩溃 | 第 3.2 节:每个操作的状态与生命期 |
| ReadFile 返回 FALSE,或因同步完成而重复处理 | 第 3.3 节:发出结果的三种分支 |
| 想选择完成的接收方式,或回调迟迟不来 | 第 4 章:通知方式的比较、第 4.3 节:APC 的等待方式 |
| 改成异步了,调用却仍然被阻塞 | 第 5 章:同步完成的条件与响应性 |
| 取消不起作用,或取消之后崩溃 | 第 6 章:确认完成之后再收尾 |
| 明明用了 ReadAsync,线程却在增加 | 第 7 章:.NET 中句柄与 API 的组合 |
图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 36 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle
2. 同步 I/O:等待完成的线程不消耗 CPU 地睡眠
2.1. 负责等待的是 I/O 管理器
没有加 FILE_FLAG_OVERLAPPED 打开的句柄是同步模式。ReadFile 要等到 I/O 完成才返回。1
当驱动程序因为等待硬件响应而把请求置为挂起(pending)时,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。等待完成之后 ReadFile 才返回
不过,同步 I/O 并不意味着一定会睡眠。像缓存命中这样当场就能完成的请求,不会进入等待就直接返回结果(第 1 篇图 5 中“立即完成”的那条路径)。得到保证的只是“完成之前不会返回”。
2.2. 不消耗 CPU 与能不能干别的活是两回事
处于等待状态的线程会被排除在调度器的执行对象之外,因此不消耗 CPU。比起自己不停轮询,交给等待更好的理由,在《为什么在 Windows 上应优先选择事件等待而非 Sleep(1)》中也有说明。
另一方面,等待中的线程干不了别的活。如果是 UI 线程,界面就会卡住;如果服务器为每条连接都准备一条线程,几百条连接就会让线程数随之膨胀。同步 I/O 的弱点不在 CPU 占用率,而在于在完成之前那条线程无法挪作他用。
在同步模式下,内核还会管理文件指针(当前位置)。因此连续调用 ReadFile 会“接着上次的位置”读。持有位置的是句柄背后的文件对象,所以用 DuplicateHandle 复制出来的句柄之间共享同一个位置(第 1 篇 3.3 节)。
另外还有 CancelSynchronousIo,用来对另一条线程中正在执行的同步 I/O 提出取消请求。它与面向异步 I/O 的 API 如何分工,在第 6 章汇总。5
3. 异步 I/O 的准备与发出:把模式、状态、返回值分开
3.1. 异步模式在打开文件时就已决定
给 CreateFile 传入 FILE_FLAG_OVERLAPPED,句柄背后的文件对象就会成为异步模式。模式不是按每次调用切换的东西。同一个文件可以分别用同步用、异步用两个句柄打开,这种情况下文件对象也会有两个。1
异步模式下系统不管理文件指针。由于可以同时发出多个操作,对磁盘上的文件,每次都要用 OVERLAPPED.Offset / OffsetHigh 指定读写位置。像串口和命名管道这类没有寻道位置的设备,不会用到这个位置指定,保持为 0 即可。即使不指定位置,操作专用的 OVERLAPPED 仍然必不可少。6
反过来,即使给同步模式的句柄传入 OVERLAPPED,也不会变成异步。它会从 Offset 指定的位置开始读,但一直阻塞到完成的行为不变。要确认的不是有没有传结构体,而是句柄以哪种模式打开。6
3.2. 让 OVERLAPPED 与缓冲区一一对应到单个操作
OVERLAPPED 是用来标识正在进行中的操作、并承载其位置、状态与结果的结构体。把它想成“单个操作的凭证”,就能看清它与句柄之间的分工。3
| 成员 | 作用 |
|---|---|
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:句柄持有模式,OVERLAPPED 持有每个操作的位置与状态
这里要遵守的有两点:个数与生命期。如果要同时发出 3 个 I/O,就准备 3 个 OVERLAPPED。让多个未完成的操作共用同一个结构体,会导致不可预测的结果或数据损坏。2
另外,在完成之前要让结构体和数据缓冲区保持有效,不修改、不复用、不释放其内容。因为内核还在使用那片区域。如果用局部变量的 OVERLAPPED 发出操作,又在未完成的状态下离开函数,就等于让内核去用一块生命期已经结束的栈空间。32
确认完成之后再复用时,也要重新初始化,避免上一个操作的状态残留。如果选择事件方式,hEvent 要用手动重置事件。它与等待方式的关系在 4.2 节说明。3
3.3. 把 ReadFile 的返回值分成三种,确定各自的处理位置
向异步句柄发出 ReadFile 的结果,要用返回值与 GetLastError() 的组合来判定。关键是不要把所有的 FALSE 都当成失败。6
ReadFile 的返回值 |
GetLastError() |
含义 | 调用方要做的事 |
|---|---|---|---|
TRUE |
(不看) | 当场就完成了(同步完成) | 默认情况下完成通知也会另行送达。结果处理交给通知一侧 |
FALSE |
ERROR_IO_PENDING(997) |
已受理,正在进行中 | 什么都不做。不碰 OVERLAPPED 和缓冲区,等待完成通知 |
FALSE |
其他 | 发出本身失败了 | 完成通知不会来。要当场做错误处理,并对 OVERLAPPED 和缓冲区做收尾 |
flowchart TB
A["ReadFile(异步句柄,带 OVERLAPPED)"]
Q{"返回值是什么?"}
T["TRUE<br/>当场就完成了(同步完成)<br/>默认情况下完成通知也会另行送达"]
P["FALSE + ERROR_IO_PENDING<br/>已受理。完成会在之后通知"]
E["FALSE + 其他错误<br/>发出本身失败了"]
W["等待完成通知<br/>(第 4 章的 4 种方式)"]
A --> Q
Q --> T
Q --> P
Q --> E
P --> W
图3:处理同步完成、已受理进行中、发出失败这三种分支
下面的函数只做这个判定,然后返回给调用方。前提是句柄,以及操作专用的结构体、缓冲区与事件的准备,还有接收完成的处理,都另行准备。
// 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 返回的同步完成也是正常路径,必须处理。同步完成的原因在第 5 章说明。
结果处理只能做一次。在默认行为下,即使是同步完成的操作,只要句柄关联到了 IOCP,完成数据包就会被压入队列;如果用事件方式,事件也会被置为信号状态。如果在 TRUE 之后和收到通知时都处理,就会对同一个操作重复处理,还可能把结构体释放两次。安全的基本写法,是把 TRUE 与 ERROR_IO_PENDING 两条路径的结果处理统一到通知一侧。1
与此相对,发出本身失败的那条路径,要在发出处做错误处理和收尾。通知不会来却转入等待,就会永远等下去。
也存在省掉同步完成时 IOCP 通知的优化,但那是与默认行为不同的设计。使用 FILE_SKIP_COMPLETION_PORT_ON_SUCCESS 时的适用范围,放在 5.3 节单独说明。7
4. 选择完成通知:按 I/O 的条数和处理的线程来决定
既然发出与完成是分开的,就必须决定“如何接收完成”。先按同时处理的 I/O 数量和执行完成处理的线程来比较这 4 种方式。1
| 方式 | 运行完成处理的线程 | 可同时发出的 I/O 数量 | 适合的场景 |
|---|---|---|---|
| (1)句柄置为信号状态 | 任何等待它的线程 | 实际上只有 1 个。发出多个就无法区分是哪一个完成了 | 几乎没有(4.1) |
(2)事件 + GetOverlappedResult |
任何等待它的线程 | 每个操作需要一个事件。用 WaitForMultipleObjects 一起等待时上限是 64 个 |
几个并发 I/O 以内。设备通信(4.2) |
(3)APC(ReadFileEx) |
发出它的那条线程,而且只在它处于 alertable wait 期间 | 条数没有限制,但完成处理全部在那一条线程上串行执行 | 想在单线程内闭环完成的通信处理(4.3) |
| (4)I/O 完成端口 | 关联到端口的一组工作线程 | 可以用少数线程承接大量 I/O | 服务器、线程池(4.4) |
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:完成通知的 4 条路径。发出方式不同,接收方式也随之改变
4.1. 句柄置为信号状态:无法区分多个操作
不指定 hEvent 就发出操作时,完成时文件句柄本身会变为信号状态。但如果同一个句柄上有多个操作在进行,就无法区分是哪一个完成了。1
除了“异步 I/O 一次只发出一个”这种特殊情况,不用它更安全。它看起来省事,却无法构成按操作管理结果的机制。
4.2. 事件与 GetOverlappedResult:几个并发 I/O 的基本形态
在每个操作的 OVERLAPPED.hEvent 上设置手动重置事件后发出。用 WaitForSingleObject 等待之后,再用 GetOverlappedResult 取得成败与传输字节数。要一起等待多个事件就用 WaitForMultipleObjects,但同时最多只能等待 64 个。18
把 GetOverlappedResult 的 bWait 设为 TRUE,也可以等到完成之后再取出结果。如果这里用的是自动重置事件,当别处的等待消耗掉信号之后,GetOverlappedResult 就可能一直等下去。之所以要用手动重置,正是为了避开这种等待错配的问题。83
要稳妥地处理几个并发 I/O,这是一种脉络清晰的方式。串口“边读边写”的处理中也会用到。实际案例请参考《串口通信应用的陷阱》。
4.3. APC:在发出线程上持续 alertable wait 直到完成
ReadFileEx / WriteFileEx 是指定完成例程(回调)的方式。I/O 完成后,该例程会被压入发出它的那条线程的 APC 队列。当那条线程通过 SleepEx 或 WaitForSingleObjectEx 等进入 alertable wait 时,它才会被执行。91011
由于完成处理在同一条线程上串行执行,对于能在单线程内闭环的处理可以免去加锁。另一方面,只要发出线程不进入 alertable wait,完成例程就不会运行。要和 UI 的消息循环并用还需要 MsgWaitForMultipleObjectsEx,等待方式的设计因此变得棘手。作为通用方案,人们更多会选择事件或 IOCP。
在 APC 上容易被忽略的有三点:是否成功发出、等待方式是否正确、自己的操作是否已完成。下面的代码是对比错误等待方式与正确等待方式的节选,并不是把两者接连执行的示例。前提是句柄与缓冲区的准备,以及更新每个操作完成标志的 OnReadCompleted,都另行准备。
// 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 返回 0 时,完成例程并没有被压入队列。要紧接着取 GetLastError(),做错误处理后退出。漏掉这一步,completed 就永远不会置起,程序会对着一个并不存在的 I/O 反复调用 SleepEx 和 CancelIoEx。9
不要把等待超时当成 I/O 结束。SleepEx 超时后会离开 alertable wait,但已经发出的 I/O 可能还在。不要在这里离开作用域,让 ov 或 buf 失效。要么继续等到完成,要么在中止时提出取消请求,并等待它的完成被投递。3.2 节的生命期规则,在超时之后同样成立。
不要只凭 WAIT_IO_COMPLETION 就断定自己的 I/O 已经结束。这个返回值的含义是至少执行了一个 APC。如果同一条线程上压入了别的 I/O 或 QueueUserAPC 的 APC,它同样会返回。要用自己的完成例程更新的标志来判定,还没完成就继续等。10
遇到“APC 不来”时,除了发出是否成功,还要检查等待函数:用的是不是 SleepEx(..., TRUE) 而非 Sleep,是不是 WaitForSingleObjectEx(..., TRUE) 而非 WaitForSingleObject。结尾的 Ex,以及 alertable 参数的 TRUE就是检查点。10
4.4. IOCP:用少数工作线程承接大量 I/O
I/O 完成端口(IOCP)的做法是把句柄关联到端口上。完成数据包进入端口的队列,工作线程用 GetQueuedCompletionStatus 取出。这是用少数线程处理大量并发 I/O 的机制。12
它也是支撑 .NET 异步 I/O 的路径。完成通知的队列与并发执行线程数的控制该如何结合,将在下一篇第 3 篇中详细讲解。
5. 同步完成这个例外:“异步”与“不会被阻塞”不是一回事
5.1. 在调用内部就完成的典型条件
即使在异步模式下正确地发出,I/O 也可能在调用内部就完成。同步完成的意思是函数返回之前 I/O 已经完成,而不是保证它很快返回。要把因缓存命中而很快结束的情况,和在调用内部被阻塞的情况分开考虑。2
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:即使以异步方式发出也会同步完成的主要条件。它与调用返回所花的时间要区分开
微软的疑难解答文档列举了以下原因。2
| 条件 | 之所以被同步处理的原因,以及实现上的注意事项 |
|---|---|
| 可以立即满足的请求、缓存命中 | 数据在内存里,驱动程序就能当场完成。即使结束得快,那种假定一定会返回 ERROR_IO_PENDING 的代码也会出问题 |
| 启用缓存的读取中缺少所需的页 | Windows 的缓存是用文件映射实现的。由于没有异步的缺页错误机制,有时会被同步处理 |
| 经过 NTFS 压缩、EFS 加密的文件 | 文件系统驱动程序会把访问转换为同步 |
| 会延长文件的写入 | 改变长度的写入会变成同步 |
重要的是,不只是缓存命中,缓存里没有数据时也可能发生同步处理。缓存本身的机制将在系列第 4 篇中讲解。
5.2. 把发出结果的分支与 UI 的响应性分开设计
首先需要做的,是把 3.3 节的三种分支全部处理掉。要把 TRUE 也当作正常结果来预期,并在默认行为下把结果处理统一到通知一侧。
不过,分支写对了也不等于响应性就有了保证。不能说“因为是异步 I/O 所以 UI 不会卡住”,所以对于不能被卡住的线程,需要把 I/O 的发出本身也分离出去,交给专用线程或线程池。相关的实践在《在普通 Windows 上尽量做到软实时的实践指南》中也有说明。
5.3. 省略同步完成时的通知,只是 IOCP 专有的优化
在高频 I/O 场景下,可以做省掉同步完成时通知的优化。用 SetFileCompletionNotificationModes 启用 FILE_SKIP_COMPLETION_PORT_ON_SUCCESS 之后,立即成功的 I/O 就不会把完成数据包压入 IOCP。这是在把设计切换为当场处理结果、而非交给通知一侧时才做的指定。7
被省掉的只有压入 IOCP 的数据包,OVERLAPPED.hEvent 的置位并不会被抑制。请不要把同样的优化套用到事件方式上。把默认的通知路径与优化后的路径混在一起,会导致 3.3 节所说的重复处理,或者错误地一直等待通知。与 IOCP 的组合在第 3 篇讲解。
6. 取消与终止处理:提出请求、确认完成、关闭句柄
6.1. 按取消对象选择 API
取消用的 API,要按操作和发出线程的差异来分别使用。4135
| API | 对象与指定方式 |
|---|---|
CancelIoEx |
不管是哪条线程发出的,都向指定句柄上未完成的 I/O 提出请求。第二个参数是 OVERLAPPED 时只针对那一个操作,是 NULL 时针对该句柄的全部操作 |
CancelIo |
只以调用线程自己发出的操作为对象 |
CancelSynchronousIo |
以指定的另一条线程中正在执行的同步 I/O 为对象 |
CancelIoEx 是在 Vista 中引入的。在异步 I/O 中,已经没有理由特意去用对发出线程有限制的旧 CancelIo,应以 CancelIoEx 为基本选择。
6.2. CancelIoEx 成功并不意味着 I/O 结束
CancelIoEx 是向未完成的 IRP 提出取消请求的 API,而不是等待操作完成的 API。即使它成功了,也只是到了提出取消请求这一步。已经接近完成的操作,可能来不及取消而正常完成。14
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:被取消的操作同样会作为完成被通知。收尾要在确认之后进行
实际被取消的操作,会在完成通知中以 ERROR_OPERATION_ABORTED 返回。无论是正常完成还是被取消,在收到通知之前都不释放结构体和缓冲区。提前释放会让内核正在使用的区域消失,从而导致内存损坏。遇到取消之后的访问冲突时,首先要检查的就是这个生命期。414
6.3. 关闭句柄之前,先回收已经发出的操作
终止处理的基本流程是提出取消请求 → 确认完成 → 关闭句柄。
正如第 1 篇所见,关闭最后一个句柄时,cleanup 处理会去取消未完成的 IRP。但如果留着已经发出的 I/O 只把句柄关掉,完成通知与缓冲区生命期的管理就容易崩溃。不要把收尾全部甩给关闭操作,应先把未完成的操作处理干净。
想用超时中止处理时也一样。操作系统不会连应用的中止条件都替你决定,所以要把超时之后的取消与接收完成的步骤成套设计。如果用 APC 方式,就像 4.3 节那样,一直进行 alertable wait 直到完成被投递。
7. 与 .NET 的对应关系:不只看 ReadAsync,还要看打开文件的地方
7.1. 让句柄的模式与调用的 API 保持一致
FileStream 的 useAsync,或者 FileOptions.Asynchronous,对应 Win32 的 FILE_FLAG_OVERLAPPED。与第 1 篇的对照表一样,在 .NET 中同样是打开文件时的模式最关键。1516
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,句柄的模式不同,操作系统一侧的处理路径也不同
| 句柄与 API 的组合 | 内部发生的事情 |
|---|---|
异步模式 + ReadAsync / WriteAsync |
使用操作系统异步 I/O 的组合 |
同步模式 + ReadAsync / WriteAsync |
由线程池的线程代为执行同步读写的“伪异步” |
异步模式 + 同步的 Read / Write |
会产生内部等待完成那部分的额外开销 |
即使是“伪异步”,调用方所在的线程也不会被阻塞,但背后有另一条线程在等待。数量少时实际危害不大,但在服务器或高频处理中,就会成为线程池枯竭和可伸缩性下降的原因。原则是让模式与 API 保持一致。1615
7.2. 用三个例子比较 FileStream 的创建方式
下面的 (A) 与 (B) 中,ReadAsync 的调用方式完全相同。不同的只有打开文件时的 useAsync。(C) 是 .NET 6 以后明确指定句柄与位置的例子。
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);
}
排查既有代码时,不要只看 ReadAsync / WriteAsync 的调用处,还要找到创建 FileStream 的地方。File.OpenRead 和 new FileStream(path, FileMode.Open) 这类短重载都以同步模式打开。从 SafeFileHandle 创建 FileStream 时,也要让 isAsync 参数与句柄的实际模式一致。
7.3. 使用 RandomAccess 时明确指定句柄与偏移量
.NET 6 中 FileStream 的内部实现被全面重写,并新增了 File.OpenHandle 与 RandomAccess。它们是直接操作 SafeFileHandle、每次都传入读写位置的 API。16
像 (C) 那样明确写出模式与 fileOffset 的形式,正好对应本文讲的异步句柄与每个操作各自的 OVERLAPPED.Offset这一分工。
7.4. 即使用 CancellationToken,取消也仍然只是请求
对于异步模式的句柄,用 CancellationToken 取消文件 I/O,内部会连到 CancelIoEx。传入令牌的 ReadAsync 以 OperationCanceledException 结束时,背后运转的也是第 6 章的机制。不保证立即中断这一点同样成立。
同步模式下的“伪异步”没有可供取消的重叠操作,因此走不了这条路径。近年的 .NET 运行时中,也有对同步执行中的调用尝试用 CancelSynchronousIo 取消的机制,但行为取决于运行时的版本和操作的种类,并不保证一定能中断。如果设计上要以取消为前提,正道还是让句柄的模式对齐,使用操作系统的异步 I/O。
async/await 上层的实践,比如 ConfigureAwait 以及与 UI 线程的关系,请参考《C# async/await 实战判断表 - Task.Run 与 ConfigureAwait》和《用一张图整理 WPF/WinForms 的 async 与 UI 线程》。本文讲的是在它们之下,操作系统如何推进读写。
8. 总结:把从发出到收尾的过程连成一条线来检查
检查异步 I/O 时,按下面的顺序追代码。
- 在打开的地方确认同步、异步的模式。如果是磁盘上的文件,就为每个异步操作指定位置。
- 在发出的地方确认有操作专用的
OVERLAPPED与缓冲区,并且处理了 3.3 节的三种分支。 - 在接收完成的地方确认等待方式与事件、APC、IOCP 等方式相匹配,并且没有对同一个结果重复处理。
- 在终止的地方确认没有仅凭超时或取消请求就释放资源。
同步 I/O 与异步 I/O 不是两套各自独立的管路。区别只在于是等到完成再返回,还是走完成之前就返回的路径。不过异步模式下同样会发生同步完成,所以发出结果的分支与响应性的设计要分开来做。12
模式属于句柄,状态属于每个操作,收尾要等确认完成之后。无论是直接使用 Win32 的 OVERLAPPED,还是使用 .NET 的 FileOptions.Asynchronous,这一分工都是共通的。即使是已经取消的操作,在收到完成之前也要继续管理。3415
接下来是第 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 上尽量做到软实时的实践指南
- 认为每次 Send 的单位都能对应一次 Receive 的误解——把 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
-
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
-
Microsoft Learn,OVERLAPPED structure。关于 OVERLAPPED 结构体保存异步输入输出所需的信息,Offset/OffsetHigh 保存文件位置、hEvent 保存完成时被置为信号状态的事件、Internal/InternalHigh 保存操作的状态码与传输字节数,操作执行期间不得修改该结构体、必须让它保持有效,以及使用事件时的注意事项等内容。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn,CancelIoEx function。关于 CancelIoEx 会不论是哪条线程发出的、都对指定句柄上未完成的 I/O 打上取消标记,指定 lpOverlapped 时只针对那一个操作、传 NULL 时针对全部未完成的 I/O,被取消的操作会以 ERROR_OPERATION_ABORTED 完成,以及并不保证所有操作都能被取消、必须等到完成处理结束等内容。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn,CancelSynchronousIo function。关于 CancelSynchronousIo 会对指定线程正在执行的同步 I/O 操作打上取消标记,以及被取消的操作会以 ERROR_OPERATION_ABORTED 作为失败返回等内容。 ↩ ↩2
-
Microsoft Learn,ReadFile function。关于用 FILE_FLAG_OVERLAPPED 打开的句柄必须提供 lpOverlapped,读取的起始位置要用 OVERLAPPED 结构体的 Offset/OffsetHigh 指定,以异步方式处理时会返回 FALSE 与 ERROR_IO_PENDING,系统不会维护异步句柄的文件指针,以及对未加 FILE_FLAG_OVERLAPPED 打开的句柄传入 OVERLAPPED 时虽然会从指定偏移处读取、但 ReadFile 要等读取完成才返回等内容。 ↩ ↩2 ↩3
-
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
-
Microsoft Learn,ReadFileEx function。关于 ReadFileEx 接收读取完成时被调用的完成例程(FileIOCompletionRoutine),完成例程只在调用它的线程处于 alertable wait 状态时才执行,以及必须使用用 FILE_FLAG_OVERLAPPED 打开的句柄等内容。 ↩ ↩2
-
Microsoft Learn,Alertable I/O。关于在 alertable I/O 中指向完成例程的条目会被压入线程的 APC 队列,线程通过 SleepEx、WaitForSingleObjectEx、WaitForMultipleObjectsEx 等进入 alertable 状态时 APC 才会执行,以及 APC 一定在发出它的那条线程的上下文中执行等内容。 ↩ ↩2 ↩3
-
Microsoft Learn,Asynchronous Procedure Calls。关于 APC 是在特定线程的上下文中异步执行的函数、每条线程都有自己的 APC 队列,以及用户模式 APC 只在线程处于 alertable 状态时才执行等内容。 ↩
-
Microsoft Learn,I/O Completion Ports。关于 I/O 完成端口为在多处理器系统上处理大量异步 I/O 请求提供了高效的线程模型,把文件句柄关联到端口之后完成数据包会进入队列、由工作线程用 GetQueuedCompletionStatus 取出,以及端口会控制并发执行的线程数等内容。 ↩
-
Microsoft Learn,CancelIo function。关于 CancelIo 只能取消调用线程自己发出的 I/O 操作,要连同其他线程发出的操作一起取消则应使用 CancelIoEx 等内容。 ↩
-
Microsoft Learn,Canceling pending I/O operations。关于未完成 I/O 的取消机制,即使提出取消请求、操作也可能已经走向完成,应在确认被取消的操作已完成之后再释放资源,以及同步操作用 CancelSynchronousIo、异步操作用 CancelIo/CancelIoEx 的分工等内容。 ↩ ↩2
-
Microsoft Learn,Asynchronous file I/O (.NET)。关于 .NET 中异步文件 I/O 的思路,在 FileStream 上使用异步 I/O 时要在构造函数中指定 useAsync(FileOptions.Asynchronous)以启用操作系统级别的异步 I/O,以及同步方法与异步方法的取舍等内容。 ↩ ↩2 ↩3
-
Microsoft .NET Blog,File IO improvements in .NET 6。关于 .NET 6 中 FileStream 的内部实现被全面重写,策略会按句柄是否以异步模式打开而分开,可以用 File.OpenHandle 直接取得 SafeFileHandle、并用 RandomAccess 进行明确指定偏移量的读写(线程安全),以及对非异步模式的句柄发起的异步调用会被卸载到线程池上等内容。 ↩ ↩2 ↩3
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
Windows I/O 底层原理(第 4 篇)——缓存管理器:你的 WriteFile 何时才真正写入磁盘
本文是图解讲解 Windows 缓存管理器的系列第 4 篇。梳理以文件映射方式实现的缓存、预读与延迟写入、FlushFileBuffers 与 FILE_FLAG_NO_BUFFERING 的取舍,以及断电导致数据丢失的条件。
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 底层原理(第 6 篇·完结篇)——迷你过滤器的机制与用 Procmon 排查延迟
讲解迷你过滤器监视和控制文件 I/O 的机制。梳理 FltMgr、高度、pre/post 回调与 fltmc 的读法,汇总用 Procmon 定位慢操作的步骤,以及排除项设置和 Dev Drive 的注意事项。
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,写出明确指定模式与偏移量的直白代码。