上一回(第2回)我们看到了异步 I/O 的发出,以及接收完成的 4 种途径。当时只是提到了名字、说它是「用少数线程承接大量并发 I/O 的正统方案」的,就是I/O 完成端口(IOCP)。
为什么 Web 服务器能用十几条线程处理数千个并发连接?为什么可以断言 async/await 的 I/O 等待「不消耗线程」?为什么 await 之后的续体有时在 UI 线程运行,有时又在线程池运行──这三个疑问的答案,全都归结到 IOCP 这一个设计上。这一回是本系列中与 .NET 开发者关系最直接的一回。
本文是系列「Windows I/O 的深层」的第 3 回。全书的整体结构放在第 1 回的开头。
由于本文篇幅较长,先说明各个疑问会在哪里得到解答。
- 疑问1:为什么十几条线程就能处理数千个并发连接 → 将在第 3 章(把完成队列与线程数控制融为一体的 IOCP 设计)中解答。
- 疑问2:为什么可以断言 I/O 等待「不消耗线程」 → 将在第 5.1~5.2 节(await 一次往返的全貌,以及等待期间不存在线程这一说法的确切含义)中解答。
- 疑问3:决定
await续体执行线程的是什么 → 将在第 5.3 节(被捕获的上下文与ConfigureAwait(false)的真正含义)中解答。
1. 先说结论
- IOCP 是把「完成通知队列」与「线程数控制」融为一体的机制。完成数据包按 FIFO 顺序压入队列,工作线程用
GetQueuedCompletionStatus取出(第 3 章)。1 - 线程按 LIFO 顺序被唤醒。刚刚还在工作的「热身完毕」的线程会去接下一个数据包,因此只要队列里还有数据包,几乎不会发生上下文切换(第 3.3 节)。1
- 并发值是「可运行线程数」的上限。推荐的出发点是 CPU 数量(指定 0 即为处理器数量)。正在运行的线程一旦被阻塞,就会唤醒等待中的线程来填补空缺(第 3.4 节)。12
- 端口也可以用于自定义通知。用
PostQueuedCompletionStatus可以压入与 I/O 无关的数据包,因此给工作线程分派任务、发出终止指示,都能通过同一个队列传递(第 4 章)。3 - 如果是新建的服务器实现,比起原生的 IOCP,官方更推荐使用 Windows 线程池 API(
CreateThreadpoolIo)。其内部依然是 IOCP,只是替你代劳了线程管理(第 4 章)。1 - .NET 线程池是工作线程与 I/O 完成线程的两层结构,异步 I/O 的句柄会绑定到线程池(自身的 IOCP)上。
await的 I/O 等待中不存在线程,只有完成之后的续体才会用到线程(第 5 章)。456 - 续体的去处由「被捕获的上下文」决定。在 UI 线程上
await,续体就会回到 UI 线程;如果没有可捕获的对象,就会在线程池(或让 Task 完成的那个线程)上继续。ConfigureAwait(false)是「停止捕获」的指示,而不是「一定转到线程池」的保证(第 5.3 节)。6
2. 靠堆线程硬扛的设计会在哪里崩溃
首先来确认一下 IOCP 想要解决的问题。朴素的服务器可以写成「一条连接对应一条线程」的形式:用同步 I/O 读取、处理、再写回。这种设计很好理解,但连接数一多,就会碰到两堵墙。
flowchart TB
subgraph A["每个连接1条线程,同步I/O"]
T1["线程1: 等待连接1的read"]
T2["线程2: 等待连接2的read"]
T3["线程3: 等待连接3的read"]
TN["…线程数随连接数增长<br/>大多数只是在I/O等待中沉睡"]
end
subgraph B["IOCP模型,异步I/O"]
Q["完成队列<br/>汇集全部连接的完成通知"]
W1["工作线程1"]
W2["工作线程2"]
WN["工作线程数量约等于CPU核心数,较少"]
Q --> W1
Q --> W2
Q --> WN
end
图 1:左边的线程数随连接数成比例增长。右边只用少数线程处理「已发生的事件」
- 线程不是免费的。每一条线程都会消耗栈空间(默认预留 1MB)和内核对象,数量越多,调度器与上下文切换的负担就越重。数千个连接等于数千条线程,即使其中大多数只是「在等待可读而沉睡」,代价依然高昂。
- 「想要执行的数量」一旦超过 CPU 数量就没有意义。能够同时运行的线程,物理上受限于 CPU 的数量。即使让更多的线程处于可运行状态,也只会增加切换损耗。
到第 2 回为止,我们已经看到了用「事件」或「APC」来接收 I/O 完成通知的方式。然而事件方式受限于 WaitForMultipleObjects 的 64 个上限,等待方面的设计也会变得繁琐;APC 则受制于发出请求的线程。而IOCP 从一开始就是按照「大量 I/O × 少数线程」这一形态设计出来的。1
3. IOCP 的设计 ── 队列与线程控制的一体化
3.1. CreateIoCompletionPort 的两副面孔
CreateIoCompletionPort 的名字虽只提到一件事,实际上却身兼两职:新建端口,以及把句柄关联到已有的端口。2
flowchart LR
subgraph SRC["关联的句柄,数量不限"]
H1["文件"]
H2["套接字"]
H3["命名管道"]
end
subgraph PORT["I/O完成端口"]
Q["完成数据包队列,FIFO<br/>数据包 = 传输字节数 +<br/>CompletionKey + OVERLAPPED指针"]
C["并发控制<br/>可运行线程数 ≦ 上限"]
end
subgraph W["工作线程组"]
G1["用GetQueuedCompletionStatus等待"]
G2["用GetQueuedCompletionStatus等待"]
end
H1 --> Q
H2 --> Q
H3 --> Q
Q --> G1
Q --> G2
C -.控制.-> W
图 2:IOCP 的结构。众多句柄的完成事件汇集到同一个队列中,连取出方的线程数量也一并受到控制
关联时传入的 CompletionKey,是用来告诉工作线程「这是来自哪个句柄的完成」的一个自由取值(通常的做法是放入连接对象的指针)。完成数据包中会附带 CompletionKey、该操作的 OVERLAPPED 指针,以及传输字节数。是哪个连接(CompletionKey)、哪个操作(OVERLAPPED)、进展到什么程度(字节数)──第 2 回中提到的「操作凭据」,正是在这里被回收利用的。27
对象并不局限于「文件」。套接字、命名管道、邮件槽等,只要是能够支持重叠 I/O 的句柄,都可以关联。1 第 1 回中看到的「一切皆文件」的设计,在这里同样发挥着作用。
3.2. 完成数据包的旅程
sequenceDiagram
participant DRV as 内核(IRP完成)
participant Q as 端口队列(FIFO)
participant W as 工作线程
Note over W: 用GetQueuedCompletionStatus等待
DRV->>Q: 压入完成数据包(字节数 / CompletionKey / OVERLAPPED)
Q->>W: 唤醒一条等待中的线程并交给它
Note over W: 查看数据包并进行完成处理(执行续体・发出下一个I/O等)
W->>Q: 处理完毕后再次调用GetQueuedCompletionStatus
Note over Q: 若队列中仍有数据包,则不必等待即可取得下一个
图 3:完成数据包按 FIFO 顺序压入队列,工作线程反复执行「取出后处理」的循环
异步 I/O 一旦完成,完成数据包就会按FIFO 顺序压入端口的队列。工作线程调用 GetQueuedCompletionStatus 接收一个数据包,处理完之后再次调用──这个循环就是 IOCP 编程的骨架。17 还有一个可以一次性批量取出多个数据包的 GetQueuedCompletionStatusEx,在高频 I/O 场景下能减少调用次数。8
这里先把工作循环中的经典 bug 提前消灭掉。即便 GetQueuedCompletionStatus 返回了 FALSE,只要 OVERLAPPED 指针不是 NULL,就意味着「成功取出了一个失败 I/O 的完成数据包」。7 失败的操作同样需要收尾工作(错误处理,以及第 2 回中提到的凭据与缓冲区释放),所以这个数据包必须被处理。只有当 OVERLAPPED 为 NULL 时,才能说是「没能取出数据包本身」(超时、端口关闭等)。如果偷懒写成 if (!GetQueuedCompletionStatus(...)) break;,就会把所有失败的 I/O 全部漏掉,造成泄漏。
下面给出可以直接抄写照做的骨架代码。判断的顺序与前文的说明一一对应。
/* IOCP工作线程循环的骨架(C / Win32) */
for (;;) {
DWORD bytes = 0;
ULONG_PTR key = 0;
OVERLAPPED *ov = NULL;
BOOL ok = GetQueuedCompletionStatus(port, &bytes, &key, &ov, INFINITE);
if (!ok && ov == NULL) {
/* 未能取出数据包(端口被关闭等)。只有这种情况才可以退出循环 */
break;
}
if (!ok) {
/* ov != NULL → 成功取出了「失败I/O的完成数据包」。
仍需收尾处理(错误处理・释放凭据和缓冲区),因此不退出,继续处理 */
DWORD err = GetLastError();
handle_failed_io(key, ov, err);
continue;
}
if (key == SHUTDOWN_KEY) {
/* 用PostQueuedCompletionStatus压入的终止用数据包(第4章) */
break;
}
handle_completed_io(key, ov, bytes); /* 常规完成处理。保持简短(第3.4节) */
}
要点在于不要把 ok == FALSE 用一个分支笼统处理,而要按 ov 是否为 NULL 分成两种情况。即便设置了超时(而非 INFINITE),判断方式也是一样的:超时会以 ok == FALSE 且 ov == NULL 的形式出现。
另外,某个线程第一次调用 GetQueuedCompletionStatus 时,该线程就会关联到那个端口上(一条线程同时只能关联一个端口)。1 记成「一支专属的工作团队隶属于某个端口」这幅画面,会更准确。
3.3. 线程按 LIFO 顺序被唤醒
接下来才是 IOCP 设计的精妙之处。数据包按FIFO压入队列,但等待中的线程却按LIFO被唤醒。也就是说,最近还在工作的那条线程,会去接下一个数据包。1
flowchart TB
Q["队列: P1 → P2 → P3,FIFO"]
subgraph TH["等待中的线程,LIFO栈"]
A["线程A,刚运行完,还热着"]
B["线程B,已休眠一段时间"]
C["线程C,一直在休眠"]
end
Q -->|"P1、P2、P3只要空闲<br/>都优先交给线程A"| A
B -.->|"仅当A被占用时"| Q
C -.->|"极少被唤醒"| Q
图 4:LIFO 释放。越是繁忙,同一条线程就越会持续运转,空闲的线程则可以继续沉睡
这种设计带来两个好处。
- 不会发生上下文切换。只要队列中还留有数据包,处理完毕的线程调用
GetQueuedCompletionStatus时,就会不必等待、直接拿到下一个数据包,继续运行下去。文档明确指出,在并发值为 1 的场景下「不会发生线程切换」。1 - 缓存能够保持「温热」状态被继续使用。由于是同一条线程持续运转,栈以及调度相关的状态留在 CPU 缓存中的概率也更高。而休眠的线程,则是以低成本作为应对负载高峰的备用力量被维持着。
3.4. 并发值 ── 统计「可运行」的数量
创建端口时的 NumberOfConcurrentThreads 就是并发值。它是「关联到该端口的可运行(runnable)线程数」的上限,在达到上限期间,额外的线程就无法接收数据包。1 传入 0 会使用系统的处理器数量,文档也指出作为总体上的最佳最大值,应为 CPU 数量。21
巧妙之处在于,这个数字统计的不是「醒着的线程」,而是「可运行的线程」。
flowchart TB
P["数据包到达队列"]
Q{"可运行线程数是否<br/>低于并发值"}
RUN["唤醒等待中的线程并交给它处理"]
HOLD["不唤醒任何线程,先留在队列中<br/>等运行中的线程自己来取"]
BLK["运行中的线程<br/>因其他原因进入等待状态"]
COMP["按可运行数减少的量<br/>唤醒等待中的线程来补充"]
P --> Q
Q -->|低于| RUN
Q -->|已达上限| HOLD
BLK --> COMP
图 5:并发控制。上限统计的是「可运行的数量」,因此一旦有人被阻塞,就会自动得到补充
运行中的工作线程一旦进入某种等待(锁、缺页、不小心写下的同步 I/O),可运行数就会减少,于是系统会唤醒等待中的线程来填补空缺。1 所以通常的做法并不是只创建「恰好等于 CPU 数量」的工作线程,而是让待命的线程数量多于并发值。如果处理中混杂着耗时较长的计算,也可以选择直接调高并发值本身,文档的立场是最终应通过性能分析来调整。1
不过补充机制并非万能。被阻塞的线程一旦之后又醒来,就会在那一瞬间让可运行数超出上限(文档也提到了这种超出)。1 让完成处理保持简短是最重要的原则,这一点在第 6 章的 .NET 场景中也以同样的形式发挥作用。
4. 工具箱 ── 支撑端口的那些 API
PostQueuedCompletionStatus── 不必发出实际的 I/O,就能把自定义的完成数据包压入队列。3 给工作线程分派任务、发出关闭指示(按工作线程的数量压入相应数量的终止数据包──俗称「毒丸」数据包)、来自其他线程的通知──能够用同一个队列、同一个循环来处理 I/O 完成事件与自定义消息,这大大简化了设计。GetQueuedCompletionStatusEx── 一次性取出多个完成数据包。在「一个数据包对应一次调用」的开销会造成影响的高频 I/O 场景下很有效。8SetFileCompletionNotificationModes── 针对第 2 回第 5 章中提到的「明明是异步发出的,却同步完成了」这种情况,可以选择不向端口压入数据包的模式(FILE_SKIP_COMPLETION_PORT_ON_SUCCESS)。既然同步完成的结果当场就已知晓,再绕一趟队列纯属浪费──这是一种加速手段。9- Windows 线程池 API ──
CreateThreadpoolIo/StartThreadpoolIo在内部使用 IOCP,同时替你代劳线程的创建与管理。微软建议新建的服务器应用首先考虑使用这一套 API,只有想要显式控制并发值或线程管理时,才使用原生的 IOCP。1 而 .NET 的线程池,正是把这种「IOCP + 线程管理自动化」作为 .NET 运行时实现出来的产物。
这里也列出三个陷阱。(1) 不要在工作线程中长时间阻塞(第 3.4 节的补充机制只能缓解劣化,而无法根治)。(2) 完成数据包的识别要分两层进行:CompletionKey(句柄级别)与 OVERLAPPED(操作级别)──第 2 回中提到的「凭据」的生命周期管理(在完成之前不释放),在这里同样是生命线。(3) 不要在还残留未完成 I/O 的情况下关闭句柄──cleanup 的行为(第 1 回第 6 章)与取消的规范(第 2 回第 6 章)在这里同样原封不动地适用。
5. .NET 线程池 ── 建在 IOCP 之上的两层楼
从这里开始才是正题──「async/await 的地下室」。
.NET 的线程池中存在两种线程:执行 Task.Run 与续体的工作线程,以及接收异步 I/O 完成的I/O 完成线程。ThreadPool.GetAvailableThreads(out workerThreads, out completionPortThreads) 之所以会分别返回两个数字,正是因为其内部确实是两层结构。4
而在 Windows 上,线程池拥有自己专属的 I/O 完成端口。把操作系统句柄关联到这个端口的现行低层 API 是 ThreadPoolBoundHandle.BindHandle,对已绑定句柄的异步 I/O 会配合 NativeOverlapped(正是第 2 回中 OVERLAPPED 在 .NET 一侧的化身)一起处理(历史悠久的 ThreadPool.BindHandle 也承担同样的角色并被保留了下来,但如果是新写代码,应当使用前者)。当 FileStream 或 Socket 打开异步模式的句柄时,内部就会进行这类绑定。5 也就是说:
- 第 2 回中的「异步模式句柄 + OVERLAPPED」,是发出请求的机制
- 本文中的 IOCP,是接收完成的机制
- .NET 线程池的 I/O 完成线程,就是运转
GetQueuedCompletionStatus循环的工作团队
按照这样的对应关系,Win32 的图景就原封不动地变成了 .NET 的图景。
5.1. await ReadAsync 一次往返的完整版
第 2 回的图 7 中只写了「真正的异步 I/O」这一个方框,这一回就把它彻底打开来看个究竟。
sequenceDiagram
participant U as 调用线程(UI线程等)
participant K as 内核(IRP发出〜完成)
participant Q as 线程池的IOCP
participant IO as I/O完成线程
participant C as 续体的执行位置
U->>K: ReadAsync发出异步读取(附带相当于OVERLAPPED的信息)
K-->>U: ERROR_IO_PENDING(立即返回)
Note over U: await向未完成的Task注册续体<br/>并交出线程(若是UI则转去处理下一条消息)
Note over K: 设备正在工作<br/>这段时间里,任何地方都不存在正在等待的线程
K->>Q: 压入完成数据包
Q->>IO: 按LIFO唤醒一条线程并交给它
Note over IO: 确定结果(字节数・状态)<br/>让Task完成,并调度续体
IO->>C: 投递到被捕获的上下文<br/>(UI线程,或者没有则在线程池执行)
Note over C: await之后的代码开始运行
图 6:await 一次往返的全貌。线程只在「发出」与「完成之后」这两处工作,等待期间是零线程
5.2. 「I/O 等待不消耗线程」的确切含义
这张图想要强调的是,从发出请求到完成的这段时间里,无论是用户模式还是内核,都不存在只为等待这一完成结果而存在的线程。微软的 async 详解(async in depth)在讲解 I/O 绑定的 Task 时,也一路深入到设备驱动程序与中断层面,说明「不存在任何只是为了等待其完成而存在的线程」。6 另外,内核中确实存在驱动程序把部分处理委托给系统工作线程的场景。但那只是为了推进请求而进行的一段短暂工作,绝不存在阻塞着完成、持续等待的线程──这才是被保证的范围。
用从第 1 回开始积累的知识来重新表述──IRP 不是线程,而是作为数据结构滞留在设备栈中(第 1 回);发出请求会以 ERROR_IO_PENDING 立即返回(第 2 回);完成则通过「中断 → 完成数据包」这一事件链条送达(本文)。也就是说,维持「等待」这一状态,本身就被设计成不需要用到线程这种昂贵资源。
因此,正确使用 async/await 的应用,能够用十几条线程维持「同时有 1 万个 I/O 在飞」的状态。反过来说,这种特性只属于 I/O 绑定的 Task。用 Task.Run 包裹的 CPU 处理,当然会占用一条工作线程;第 2 回第 7 章中提到的「假装的异步」,其背后同样让线程在悄悄沉睡。
5.3. 续体在哪里运行
图 6 中最后那支箭头──「把续体投递到哪里」,有着明确的规则。6
flowchart TB
A["Task完成,想要执行续体"]
Q1{"await那一刻是否捕获了<br/>SynchronizationContext<br/>或非默认的TaskScheduler"}
Q2{"是否指定了<br/>ConfigureAwait为false"}
UI["投递回被捕获的位置<br/>例如: 在UI线程的消息循环<br/>或该TaskScheduler上执行"]
TP["没有回到特定位置的义务<br/>在让其完成的线程上同步继续<br/>或在线程池的线程上执行"]
A --> Q2
Q2 -->|"是"| TP
Q2 -->|"否"| Q1
Q1 -->|"是,例如WPF/WinForms的UI线程"| UI
Q1 -->|"否,例如控制台/ASP.NET Core等"| TP
图 7:续体的去向。「await 之后可以直接操作 UI」,是因为投递回了被捕获的上下文
- 在 WPF 或 WinForms 的 UI 线程上
await,SynchronizationContext会被捕获,续体会回到 UI 线程。所以在await之后立刻操作控件也不会违反线程规则。这一设计在实务层面的应用,已经在《用一张图整理 WPF/WinForms 的 async 与 UI 线程》中讨论过。 - 被捕获的不只是
SynchronizationContext,如果是在非默认的TaskScheduler上await,该调度器同样是捕获对象。在两者都不存在的场合(控制台、ASP.NET Core、线程池等),由于不存在回到特定位置的义务,续体要么在线程池上运行,要么就在让 Task 完成的那条线程上直接同步继续。 ConfigureAwait(false)明确表示「不需要回去」,而不是「一定转到线程池」的保证。如果 await 的是一个已经完成的 Task(第 2 回中提到的同步完成也包含在内),就不会发生等待,会直接在当前线程上继续执行。关于库代码中的使用区分,请参考《C# async/await 实战判断表:Task.Run 与 ConfigureAwait 怎么选》。
最后这一点误解很多,下面用代码来对照说明。首先是把 ConfigureAwait(false) 误当成「转到线程池的指示」而写出的代码。
// 错误示例: 误以为「加上了ConfigureAwait(false),之后就会在线程池上运行」
private async void OnLoadClick(object sender, EventArgs e)
{
string csv = await File.ReadAllTextAsync(path).ConfigureAwait(false);
// 期望: 这里是线程池,所以UI不会卡住
// 实际: 如果await那一刻Task已经完成,就不会发生等待,
// 会保持在UI线程上继续执行 → 这段繁重处理会让UI卡死
var rows = ParseHeavy(csv);
// 而且,异步完成时的续体也不在UI线程上
resultLabel.Text = $"{rows.Count} 行"; // → 可能引发线程违规异常
}
ConfigureAwait(false) 所表达的,仅仅是「不需要回到被捕获的上下文」而已。它并没有指定「在哪里运行」,所以既可能继续停留在 UI 线程上,也可能转到 I/O 完成线程或线程池的线程上继续。正因为两种情况都有可能,这段代码才会出问题。
把意图分开写清楚,就会变成下面这样。
// 正确示例: 分别指示「是否回到UI」与「繁重处理放在哪里做」
private async void OnLoadClick(object sender, EventArgs e)
{
// UI代码中保持被捕获的状态(续体会回到UI线程)
string csv = await File.ReadAllTextAsync(path);
// 想把占用CPU的处理放到线程池,就用Task.Run明确表示
var rows = await Task.Run(() => ParseHeavy(csv));
// 这里确定是UI线程,可以安全地操作控件
resultLabel.Text = $"{rows.Count} 行";
}
// 而在库代码一侧(不持有UI的代码)则相反,要表明「不需要回去」
public async Task<string> ReadConfigAsync(string path)
{
string text = await File.ReadAllTextAsync(path).ConfigureAwait(false);
return text.Trim(); // 不依赖调用方的上下文
}
判断标准很简单:在操作 UI 的代码中保持被捕获的状态,想改变去处时就用 Task.Run 明确表示。请把 ConfigureAwait(false) 视为库代码一侧用来表明「无论调用方处于哪种上下文都能安全运行」的工具。
5.4. 卡顿的真相 ── 线程池饥饿
最后,只说一种会让这个地下室堵塞的模式。如果在续体或工作线程中进行同步等待(同步 I/O、Task.Result/Wait()、长时间的锁等待),该线程就会一直被占用。IOCP 自身的补充机制(第 3.4 节),只要还有待命的备用线程,就能立即发挥作用。但一旦备用耗尽,接下来就进入线程池只会缓慢地注入新线程的领域。一旦负载骤增,就会出现「想要执行续体,却没有线程可以执行」的饥饿(starvation),导致整个应用变得迟钝。
排查的入口有两个。一是用 ThreadPool.GetAvailableThreads 查看工作线程/I/O 完成线程的空闲数量。4 二是通过事件跟踪来把握线程池与阻塞的实际状况──具体步骤已整理在《用 PerfView 与 dotnet-trace 定位“变慢”的原因》中。预防方法很简单,async 的路径要一直保持 async 走到底(不要混入 sync-over-async),仅此而已。
6. 总结
- IOCP 是把完成队列(FIFO)与线程数控制融为一体的机制。它把大量句柄的完成事件汇集到同一个端口,通过
GetQueuedCompletionStatus循环进行处理。17 - 线程按 LIFO 顺序释放,因此越是繁忙,同一条线程就越会持续运转,从而将上下文切换与缓存未命中降到最低。1
- 并发值是「可运行线程数」的上限,出发点是 CPU 数量(指定 0)。运行中的线程一旦被阻塞,就会由等待中的线程补充,但让完成处理保持简短始终是最重要的原则。12
- 用
PostQueuedCompletionStatus还可以传递自定义数据包。新建实现时,Windows 线程池 API(内部依然是 IOCP)是首选方案。31 - .NET 线程池是工作线程 + I/O 完成线程的两层结构,异步句柄会绑定到线程池自身的 IOCP 上。
await的 I/O 等待中不存在线程,只有完成之后的续体才会用到线程。456 - 续体的去向取决于被捕获的上下文(回到 UI 线程,或在线程池中继续)。
ConfigureAwait(false)是「停止这种捕获」的指示。6 - 卡顿的真相几乎都是混入同步等待所导致的线程池饥饿。请让 async 的路径彻底保持 async。
下一回是第 4 回《Windows I/O 的深层(第4回) ── 缓存管理器:你的 WriteFile 究竟何时写入磁盘》。第 2 回中写到「如果命中缓存就会同步完成」,这一回也有好几处闪过缓存的影子。下一回将正面直击这个缓存本体──延迟写入、预读、FILE_FLAG_NO_BUFFERING,以及「本应写入的数据却在断电时消失」的条件。
相关文章
- Windows I/O 的深层(第1回) ── 所有读写都会变成 IRP:I/O 系统全貌
- Windows I/O 的深层(第2回) ── 同步 I/O 与异步 I/O:OVERLAPPED 的真正含义
- C# async/await 实战判断表:Task.Run 与 ConfigureAwait 怎么选
- 用一张图整理 WPF/WinForms 的 async 与 UI 线程
- TCP 中「发送单位=接收单位」的误区 ── 用字节流思维设计接收端,避免拆包与粘包
- 用 PerfView 与 dotnet-trace 定位“变慢”的原因 ── .NET 性能排查实务入门
- 在普通 Windows 上尽量做到软实时的实践指南
相关咨询领域
合同会社小村软件承接处理大量并发连接与并发 I/O 的 Windows 应用/服务器的设计工作,以及「线程池卡顿」「明明已经异步化却没有变快」这类性能问题的原因调查。
参考链接
-
Microsoft Learn,I/O Completion Ports。关于 I/O 完成端口为多处理器系统上处理大量异步 I/O 请求提供了一种高效的线程模型、异步 I/O 完成时完成数据包会按 FIFO 顺序压入端口队列、对象不局限于磁盘上的文件,而是包括套接字・命名管道・邮件槽等任何支持重叠 I/O 的句柄、在端口上等待的线程按 LIFO 顺序释放,并且在并发值为 1 且队列已有积压时不会发生线程切换、线程首次调用 GetQueuedCompletionStatus 时会关联到该端口,且同一时刻只能关联一个端口、并发值限制的是可运行线程数,作为总体上的最佳最大值应为 CPU 数量、运行中的线程因其他原因进入等待状态时等待中的线程可以处理完成数据包(以及被阻塞的线程醒来时可能暂时超出上限)、新建的服务器应用应首先考虑使用 Windows 线程池 API(CreateThreadpoolIo 等,内部使用 IOCP)等内容。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20
-
Microsoft Learn,CreateIoCompletionPort function。关于 CreateIoCompletionPort 既能新建 I/O 完成端口,也能把句柄关联到已有端口、关联时可以指定 CompletionKey(用户自定义值)并包含在完成数据包中、NumberOfConcurrentThreads 是可并行处理完成数据包的线程数上限,传入 0 会使用系统的处理器数量等内容。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn,PostQueuedCompletionStatus function。关于 PostQueuedCompletionStatus 可以在不发出异步 I/O 的情况下,把应用自定义的完成数据包压入 I/O 完成端口的队列,从而使端口除了接收 I/O 完成之外,还能作为进程内其他线程通信的入口等内容。 ↩ ↩2 ↩3
-
Microsoft Learn,The managed thread pool。关于 .NET 的线程池提供工作线程与用于异步 I/O 完成的线程、可以用 ThreadPool.GetAvailableThreads 分别获取工作线程与 I/O 完成线程的可用数量、不应在线程池的线程上进行长时间阻塞等内容。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn,ThreadPoolBoundHandle.BindHandle method。关于 ThreadPoolBoundHandle.BindHandle 会返回一个把操作系统句柄绑定到系统线程池(自身的 I/O 完成端口)上的 ThreadPoolBoundHandle、对已绑定句柄进行的低层异步 I/O 需要配合 NativeOverlapped 一起使用,以及异步 I/O 的完成处理由此转为由线程池负责等内容。 ↩ ↩2 ↩3
-
Microsoft Learn,Async in depth (.NET)。关于对于 I/O 绑定的 Task,在调用传递给操作系统之后,不存在专门用于等待其完成的线程(即所谓的「There is no thread」)、完成会经由设备驱动程序与中断被通知,并执行已注册的续体、await 在默认情况下会捕获当前上下文(SynchronizationContext 等)并在其上执行续体,若没有需要捕获的上下文则会在线程池上执行、可以用 ConfigureAwait(false) 禁用这种捕获等内容。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn,GetQueuedCompletionStatus function。关于 GetQueuedCompletionStatus 会从完成端口的队列中取出一个完成数据包(没有则等待)、取出结果中包含传输字节数・CompletionKey・OVERLAPPED 指针,以及即便返回值为 FALSE,只要 OVERLAPPED 指针不为 NULL,就意味着「取出了一个失败 I/O 操作的完成数据包」,只有 OVERLAPPED 为 NULL 时才意味着(因超时等原因)未能取出数据包等内容。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn,GetQueuedCompletionStatusEx function。关于 GetQueuedCompletionStatusEx 可以一次取出多个完成数据包、并返回取出的条目数等内容。 ↩ ↩2
-
Microsoft Learn,SetFileCompletionNotificationModes function。关于通过 FILE_SKIP_COMPLETION_PORT_ON_SUCCESS,可以在 I/O 立即成功且结果当场确定的情况下,选择不向完成端口压入数据包这一行为。 ↩
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
Windows I/O 的深层(第2回) ── 同步 I/O 与异步 I/O:OVERLAPPED 的真正含义
本文是通过图解讲解 Windows 同步 I/O 与异步 I/O(重叠 I/O)的系列第 2 回。整理 FILE_FLAG_OVERLAPPED 的含义、完成通知的四种方式、明明是异步却同步完成的条件、取消的正确做法,以及与 .NET 的对应关系。
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 开发、故障调查与既有资产活用文章的主题中心。
常见问题
汇总了咨询这一主题时常见的问题。
- I/O完成端口(IOCP)是什么?
- 这是 Windows 内核中的一种机制:把大量异步 I/O 的完成通知汇集到同一个队列中,同时还统一控制处理这些通知的线程的并发数。用 CreateIoCompletionPort 创建端口,再把文件、套接字等句柄与其关联后,每当异步 I/O 完成,完成数据包就会被压入端口的 FIFO 队列。工作线程用 GetQueuedCompletionStatus 从队列中取出数据包并处理。要点在于,这并不只是一个通知队列,它同时还兼具「把可运行线程数控制在并发值以内」的调度机制。它能让大量并发 I/O 由少数线程高效处理,是 Windows 服务器实现以及 .NET 线程池的基础。
- IOCP 的并发值(同时执行数)应该设为多少?
- 微软的文档指出,作为总体上的最佳最大值,应该是计算机的 CPU 数量。给 CreateIoCompletionPort 的 NumberOfConcurrentThreads 传入 0,就会使用系统的处理器数量,所以拿不准时可以把 0 作为出发点。这个值是「可运行状态的线程数」的上限,而不是等待中线程数的上限。如果正在运行的线程因为某种原因进入等待状态,系统就会唤醒另一个等待中的线程来填补空缺,因此如果处理中混杂着耗时较长的计算或阻塞,也可以选择把并发值设得大一些,以增加同时处理的数据包数量。文档最终建议的做法是,结合性能分析(profiling)来进行调整。
- 为什么可以断言 async/await 的 I/O 等待不消耗线程?
- 因为从 I/O 发出到完成的这段时间里,根本不存在任何专门负责照看这个操作的线程。正如本系列第 1、2 回所看到的,发出的请求会作为 IRP 流入设备栈,调用本身会以 ERROR_IO_PENDING 立即返回。await 在这里只是把续体(continuation)注册到尚未完成的 Task 上,然后把线程交还出去而已。在设备作为硬件工作的这段时间里,无论是用户模式还是内核,都不存在「只是在等待」的线程。一旦完成,完成数据包就会被压入线程池的 IOCP,此时 I/O 完成线程才会短暂运行,去调度那个已注册的续体。也就是说,线程只在「发出的瞬间」和「完成后的收尾处理」这两个时刻被使用,等待本身的这段时间是以零线程的状态推进的。
- await 之后的续体会在哪条线程上执行?
- 在默认情况下,await 那一刻的 SynchronizationContext(或 TaskScheduler)会被捕获,续体会被送回到那里执行。如果是在 WPF 或 WinForms 的 UI 线程上 await,续体就会在 UI 线程上运行,这正是 await 之后可以直接操作控件的原因。如果不存在需要捕获的上下文(控制台应用、ASP.NET Core、线程池上的代码等),续体要么在线程池的线程上执行,要么就在让 Task 完成的那个线程上直接继续。加上 ConfigureAwait(false) 会停止这种捕获,但这并不是「一定会转到线程池」的保证,而只是「不需要回到特定位置」的指示。如果 await 的是一个已经完成的 Task,就不会发生等待,会在当前线程上同步地继续执行。在库代码中推荐使用 ConfigureAwait(false),是为了避免向 UI 线程做不必要的往返,并切断对特定上下文的依赖以及死锁的隐患。
- 如果在 IOCP 的工作线程或 .NET 的 I/O 完成线程中长时间阻塞会怎样?
- 并不会立刻崩溃,但会偏离机制原本的设计前提,导致性能下降。IOCP 在正在运行的线程进入等待状态时,会唤醒等待中的线程来补充,但补充进来的线程会让并发数膨胀,上下文切换也随之增多。如果阻塞成为常态,数据包就会在队列中堆积,导致整个完成处理都被拖慢。.NET 也是同样的道理,如果在 I/O 完成线程或续体中进行同步 I/O 或 Task.Result 之类的等待,就会引发线程池的饥饿(starvation)。原则是让完成处理与续体保持简短,把繁重的工作单独拆分出去。可以用 ThreadPool.GetAvailableThreads 观察工作线程与 I/O 完成线程的空闲数量,这可以用于排查阻塞问题。