Windows I/O 的深层(第2回) ── 同步 I/O 与异步 I/O:OVERLAPPED 的真正含义

· · Windows, Win32, I/O, 异步, OVERLAPPED, 内核, .NET, CSharp

上一回(第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 时就已确定,不能按调用逐次切换。由于异步句柄不会由系统管理文件指针,因此对于磁盘上的文件,每次都要通过 OVERLAPPEDOffset 指定位置(第 3 章)。21
  • OVERLAPPED 结构体是「一个操作的凭证」。需要的数量与正在进行中的操作数相同,官方文档明确指出,共享或重复使用会导致数据损坏。在完成之前,结构体和缓冲区都不能触碰(第 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 管理器──它会先等待完成,然后才把控制权交还给应用。

驱动程序(设备栈)I/O 管理器应用线程驱动程序(设备栈)I/O 管理器应用线程线程在内核中进入等待状态不消耗 CPU 地睡眠ReadFile(同步句柄)发出 IRPSTATUS_PENDING(等待响应)完成(IoCompleteRequest)返回结果并唤醒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 完成时的传输字节数(供系统使用)
OVERLAPPED结构体 = 一个操作的凭证Offset,读取哪个位置hEvent,如何得知完成Internal/InternalHigh状态与结果,由系统写入句柄,文件对象 = 模式同步模式由内核管理当前位置ReadFile直到完成才返回异步模式,FILE_FLAG_OVERLAPPED不管理当前位置发出与完成相互分离在CreateFile时一次性确定每次发出ReadFile/WriteFile时准备一份

图2:模式属于句柄,状态属于操作(凭证)。混淆这种分工就会酿成事故

由此,就自然得出了官方文档中明确写明的两条禁止事项。3

  1. 同时飞行的操作有多少个,就需要多少个 OVERLAPPED发出 3 个操作就需要 3 个。重复使用会导致「不可预测的结果或数据损坏」。
  2. 在完成之前,OVERLAPPED 和数据缓冲区都必须保持有效,不能去碰它们。因为内核会来向这块区域写入数据。用局部变量的 OVERLAPPED 发出请求,然后函数就退出了──这是让内核踩到栈上的典型事故。

3.3. 发出操作后有三种返回方式

对异步句柄调用 ReadFile,会有三种返回方式。2

ReadFile,异步句柄,带OVERLAPPED返回值是?TRUE当场完成,同步完成默认情况下完成通知也会另外送达FALSE + ERROR_IO_PENDING已被接受,完成会在之后通知FALSE + 其他错误发出本身失败了等待完成通知第4章的四种方式

图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

I/O在内核中完成IoCompleteRequest到APC确定结果1,文件句柄变为信号状态接收方式,WaitForSingleObject,传入句柄2,OVERLAPPED的hEvent变为信号状态接收方式,WaitForSingleObject加GetOverlappedResult3,完成例程被排入发出线程的APC队列接收方式,在SleepEx等alertable wait中执行4,完成数据包被放入I/O完成端口接收方式,GetQueuedCompletionStatus,详见第3回

图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 取出结果(成败与传输字节数)。13GetOverlappedResultbWait 传 TRUE,也可以做到「等到完成为止再取出」。如果把事件设为自动重置,一旦别的等待消耗掉了信号,就会有 GetOverlappedResult 卡住的陷阱,因此推荐使用手动重置。134

这是稳健处理数量不多的同时 I/O 时,最容易看清全貌的方法。对于串口这类必须「边读边写」的设备,这种形式至今仍在一线使用(参见《串口通信应用的陷阱》)。

4.3. APC ── 送达发出请求的线程

ReadFileEx/WriteFileEx 接收的不是事件,而是完成例程(回调函数)。完成后,该例程会被排入发出请求的线程的 APC 队列,并在该线程进入 SleepExWaitForSingleObjectExalertable 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,不断重复 SleepExCancelIoEx 的循环。而且从表面上看,这只是「设备没有响应」而已,要找到真正原因往往要花很长时间。返回 0 时,应该当场取得 GetLastError()(哪怕只是中间夹了一个其他 API 调用,值就会被覆盖),直接退出而不进入等待。

只调用一次 SleepEx 是不够的。因超时而返回时,线程会在那一刻退出 alertable wait。此时 I/O 仍处于发出状态,如果之后离开作用域导致 ovbuf 消失,内核就会踩到一块它以为还活着的缓冲区(第3.2节)。请务必做到以下两者之一:要么在完成例程一侧记录是否已完成,并持续等待直到该标志成立;要么用 CancelIoEx 取消,然后等待完成的送达。

返回 WAIT_IO_COMPLETION 只意味着「至少执行了一个 APC」,并不代表那就是自己这个 I/O 的完成例程。如果同一个线程上还排着别的 I/O,或者 QueueUserAPC 排入的 APC,也会因此返回。所以不能只看返回值来判断,而要查看自己设置的那个标志。

另外,等待函数本身的选择很简单。把 Sleep 换成 SleepEx(..., TRUE),把 WaitForSingleObject 换成 WaitForSingleObjectEx(..., TRUE)。如果写好了完成例程却什么都没发生,请先检查等待函数末尾是否带 Ex,以及 alertable 参数是否为 TRUE5

4.4. I/O 完成端口 ── 真正可扩展的方案(下一回)

用少数线程承接大量同时 I/O 的机制,就是I/O 完成端口(IOCP)。把句柄关联到端口后,完成事件会进入端口的队列,工作线程通过 GetQueuedCompletionStatus 取出。6 这也是 .NET 的 async/await 的 I/O 最终抵达的地方。下一回将用整整一篇来深挖这个主题。

5. 「明明是异步却同步完成」的问题

这是异步 I/O 设计中最先绊倒人的地方。即使以异步模式正确地发出请求,I/O 以同步方式完成也是很常见的事情。微软在故障排查文档中列举了典型的原因。3

都不符合向异步句柄发出ReadFile/WriteFile是否符合同步完成的条件能立即满足的请求数据已在缓存中等NTFS压缩文件压缩文件不会以异步方式处理NTFS加密,EFS文件延长文件长度的写入以TRUE立即返回= 在调用中已执行到完成以ERROR_IO_PENDING返回= 真正以异步方式进行中

图5:同步完成的主要条件。缓存、压缩、加密、扩展写入都属于「不会以异步方式进行」

各自都有其原因。3

  • 缓存命中。许多驱动程序都有「能立即完成的请求就当场完成」的特殊处理。对磁盘来说,就是数据已经在内存缓存中的情况。既然速度更快,本不该有什么不满,但那些「假定一定会返回 ERROR_IO_PENDING」的代码,会在这里出问题。
  • 反过来,即使数据不在缓存中,也存在陷阱。Windows 的缓存是用文件映射实现的,由于页面不存在时的缺页处理没有异步机制,所以启用了缓存的异步读取,有时也会被同步处理。缓存机制本身将在第4回讨论。
  • NTFS 压缩・EFS 加密。文件系统驱动程序会把对压缩/加密文件的访问转换为同步方式。
  • 延长文件的写入。会改变长度的写入会变成同步方式。

对实务的启示很简单。

  1. 务必写出「以 TRUE 立即返回」这条路径。图3中的三个分支全部都是正常路径。不过由于默认情况下即使同步完成,完成通知也会另外送达,把结果处理统一交给通知路径,会更安全(第3.3节)。
  2. 不能用它来保证响应性。「因为是异步,所以 UI 不会卡死」这种说法并不成立。对于绝不能卡死的线程,需要从设计上让它一开始就不发出 I/O(拆分到专用线程或线程池中)。这部分的实务在《在普通 Windows 上尽量做到软实时的实践指南》中也有讨论。
  3. 在高频 I/O 场景下,同步完成也是一次优化的机会。存在一个名为 SetFileCompletionNotificationModes 的 API,可以在同步完成时省去写入 I/O 完成端口的数据包,与 IOCP 组合使用时效果明显(第3回)。12

6. 取消与善后 ── 「请停止」只是一个请求

想要停止耗时很长的 I/O(没有响应的网络对端、迟迟不来的串口数据)时,正规做法是 CancelIoEx7

驱动程序I/O 管理器应用驱动程序I/O 管理器应用对相应的未完成IRP请求取消(做标记)处于可以取消的状态就中止接近完成时也可能正常完成看到这个通知之后再释放OVERLAPPED与缓冲区CancelIoEx(句柄, OVERLAPPED)调用取消例程IoCompleteRequest(STATUS_CANCELLED)完成通知送达GetOverlappedResult返回ERROR_OPERATION_ABORTED

图6:取消的实际过程。被取消的操作同样会以「完成」的形式返回

只要了解这套机制,就能自然得出以下三个结论。

  • 取消是一种异步的「请求」。即使 CancelIoEx 调用成功,也只是「做了标记」而已。原本已经接近完成的操作,仍有可能正常完成。8
  • 被取消的操作,同样会以 ERROR_OPERATION_ABORTED 的形式在完成通知中返回。在收到那个通知之前,OVERLAPPED 与缓冲区都还在被内核使用。如果提前释放,就会造成内存破坏。「一取消程序就崩溃」的原因,几乎都出在这里。78
  • 在关闭句柄之前,先处理好未完成的 I/O。正如第1回中所见,最后一个句柄关闭时,cleanup 处理会对未完成的 IRP 执行取消,但「发出的 I/O 仍未处理完就只关闭句柄」这种代码,往往会在完成通知与缓冲区生命周期的管理上出问题。取消 → 看到完成 → 关闭才是应当遵循的顺序。

补充两点。旧的 CancelIo 只能取消调用线程自身发出的操作(这是在 Vista 引入 CancelIoEx 之前的限制,如今没有理由刻意使用它)。16 另外,对于因同步 I/O 而卡住的其他线程,应使用 CancelSynchronousIo11 「超时不是操作系统会替你操心的事,取消需要自己去设计」──这正是异步 I/O 实务的核心所在。

7. 从 .NET 的视角看 ── 模式不一致会产生「伪异步」

到目前为止的内容,与 .NET 的代码是直接相通的。FileStream 构造函数中的 useAsync(或 FileOptions.Asynchronous),正是 FILE_FLAG_OVERLAPPED 的直通开关(参见第1回的对应表)。

await fs.ReadAsync句柄是否为异步模式FileOptions.Asynchronous?真正的异步I/O发出相当于OVERLAPPED的请求完成经由IOCP送到线程池,第3回伪异步由线程池的线程代为执行同步Read并等待

图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.OpenReadnew 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传入了令牌的 ReadAsyncOperationCanceledException 结束的背后,第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 的底层,运作的正是 CancelIoEx910

接下来是第3回《I/O 完成端口(IOCP)与 .NET 线程池 ── async/await 的地下室》。本回第4.4节只是提到了名字的 IOCP,为什么会被设计成把「完成通知的队列」与「执行线程数的控制」合而为一?await 之后的代码究竟在哪个线程上运行?我们将继续深挖下去。

相关文章

相关咨询领域

合同会社小村软件承接使用异步 I/O 的 Windows 业务应用・设备通信应用的设计,以及「卡死」「取消后崩溃」「线程池枯竭」等故障的原因调查。

参考链接

  1. 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

  2. Microsoft Learn,ReadFile function。关于以 FILE_FLAG_OVERLAPPED 打开的句柄必须传入 lpOverlapped、读取起始位置要通过 OVERLAPPED 结构体的 Offset/OffsetHigh 指定、以异步方式处理时会返回 FALSE 并附带 ERROR_IO_PENDING、系统不会为异步句柄维护文件指针,以及对不带 FILE_FLAG_OVERLAPPED 打开的句柄传入 OVERLAPPED 时会从指定偏移量读取,但直到读取完成 ReadFile 才会返回等内容。  2 3 4 5

  3. 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

  4. Microsoft Learn,OVERLAPPED structure。关于 OVERLAPPED 结构体保存异步输入输出所需的信息,Offset/OffsetHigh 保存文件位置,hEvent 保存完成时被置于信号状态的事件,Internal/InternalHigh 保存操作的状态码与传输字节数,操作执行期间不得修改该结构体、必须保持其有效,以及使用事件时的注意事项等内容。  2 3 4

  5. Microsoft Learn,Alertable I/O。关于在 alertable I/O 中,完成例程的入口会被排入线程的 APC 队列,线程通过 SleepEx、WaitForSingleObjectEx、WaitForMultipleObjectsEx 等进入 alertable 状态时 APC 会被执行,以及 APC 一定会在发出请求的线程上下文中执行等内容。  2 3 4

  6. Microsoft Learn,I/O Completion Ports。关于 I/O 完成端口为在多处理器系统上处理大量异步 I/O 请求提供了一种高效的线程模型,把文件句柄关联到端口后完成数据包会被放入队列,工作线程通过 GetQueuedCompletionStatus 取出,以及端口会控制并发执行的线程数等内容。  2

  7. Microsoft Learn,CancelIoEx function。关于 CancelIoEx 会不论是哪个线程发出的,都对指定句柄上未完成的 I/O 标记取消,指定 lpOverlapped 则只有该操作会成为对象,传 NULL 则该句柄的全部未完成 I/O 都会成为对象,被取消的操作会以 ERROR_OPERATION_ABORTED 完成,以及并不保证所有操作都一定会被取消,需要等待完成处理结束等内容。  2 3 4

  8. Microsoft Learn,Canceling pending I/O operations。关于未完成 I/O 的取消机制,以及即使请求了取消,操作也有可能已经在朝完成的方向进行、应当在确认被取消的操作已经完成之后再释放资源,同步操作使用 CancelSynchronousIo、异步操作使用 CancelIo/CancelIoEx 的这套区分等内容。  2 3 4

  9. Microsoft .NET Blog,File IO improvements in .NET 6。关于 .NET 6 中 FileStream 的内部实现被全面重写、策略会因句柄是否以异步模式打开而不同、可以用 File.OpenHandle 直接获取 SafeFileHandle 并通过 RandomAccess 进行明确指定偏移量的读写(线程安全),以及对非异步模式句柄的异步调用会被卸载到线程池等内容。  2 3 4

  10. Microsoft Learn,Asynchronous file I/O (.NET)。关于 .NET 中异步文件 I/O 的基本思路,以及在 FileStream 中使用异步 I/O 时需要在构造函数中指定 useAsync(FileOptions.Asynchronous)以启用操作系统级的异步 I/O,还有同步方法与异步方法的使用场景区分等内容。  2 3

  11. Microsoft Learn,CancelSynchronousIo function。关于 CancelSynchronousIo 会对指定线程正在执行的同步 I/O 操作标记取消,以及被取消的操作会以 ERROR_OPERATION_ABORTED 作为失败返回等内容。  2

  12. Microsoft Learn,SetFileCompletionNotificationModes function。关于通过 FILE_SKIP_COMPLETION_PORT_ON_SUCCESS 可以选择在 I/O 立即成功时不向 I/O 完成端口写入完成数据包,通过 FILE_SKIP_SET_EVENT_ON_HANDLE 可以省略对文件句柄事件的置位等内容。  2

  13. Microsoft Learn,GetOverlappedResult function。关于 GetOverlappedResult 用于获取异步操作的结果(成败与传输字节数)、向 bWait 传入 TRUE 会一直等到操作完成为止,以及当在 OVERLAPPED 的 hEvent 中指定了自动重置事件、而信号又被其他等待消耗掉时,bWait=TRUE 的调用可能检测不到完成而一直等下去,因此应当使用手动重置事件等内容。  2 3

  14. Microsoft Learn,ReadFileEx function。关于 ReadFileEx 会接收在读取完成时被调用的完成例程(FileIOCompletionRoutine)、完成例程会在调用线程处于 alertable wait 状态时执行,以及需要以 FILE_FLAG_OVERLAPPED 打开的句柄等内容。 

  15. Microsoft Learn,Asynchronous Procedure Calls。关于 APC 是在特定线程上下文中异步执行的函数、每个线程都拥有自己的 APC 队列,以及用户模式 APC 只有在线程处于 alertable 状态时才会执行等内容。 

  16. Microsoft Learn,CancelIo function。关于 CancelIo 只能取消调用线程自身发出的 I/O 操作,要取消包括其他线程发出的操作在内的全部操作则需要使用 CancelIoEx 等内容。 

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

加上 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,写出明确指定模式与偏移量的直白代码。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表