更新记录(仅首版,2026年07月29日 发布)
- 首次发布
引用本文(DOI(已登记存档): 10.5281/zenodo.22175300)
以下 DOI 指向先前登记的存档,内容可能与当前正文不同。引用当前正文时,请使用本页网址。
Go Komura(2026)。《Windows I/O 底层原理(第 3 篇)——I/O 完成端口(IOCP)与 .NET 线程池:async/await 的地下室》。小村软件有限公司。 https://comcomponent.com/zh-CN/blog/windows-iocp-dotnet-threadpool/
- DOI(已登记存档)
- 10.5281/zenodo.22175300
- DOI(上次登记版本)
- 10.5281/zenodo.22175301
要用少数线程处理大量连接,应该如何汇集 I/O 的完成通知?await 等待期间是谁在工作,完成之后的代码又会在哪条线程上恢复执行?
本篇从I/O 完成端口(IOCP)汇集完成通知的机制开始,一直追到.NET 接收这些通知、并执行 await 之后的代码为止。把“接收完成通知”和“决定后续代码在哪里执行”分开来看,ConfigureAwait(false) 和线程池饥饿也就能在同一条脉络里理解。
上一篇(第 2 篇)介绍了异步 I/O 的发出,以及接收完成通知的 4 条途径。本篇要展开的 IOCP,就是其中“用少数线程承接大量并发 I/O”的那套机制。
本文是系列“Windows I/O 底层原理”的第 3 篇。整个系列的结构放在第 1 篇的开头。
按想了解的问题来读
按顺序读的话,先在第 2 章确认“为每条连接准备一条线程”这种设计的极限,再在第 3~4 章看 Win32,第 5 章看它与 .NET 的对应关系。如果正在排查问题,可以从下面的索引直接跳到需要的说明。
| 想了解的问题、遇到的困难 | 阅读位置 |
|---|---|
| 为什么数千个并发连接不需要同样数量的线程 | 第 2 章:连接数与线程数、第 3 章:完成队列与线程控制 |
| 想识别是哪个句柄的哪个 I/O 完成了 | 第 3.1 节:CompletionKey 与 OVERLAPPED |
| 取完成通知时返回了 FALSE,不知道该不该做收尾处理 | 第 3.2 节:区分失败的 I/O 与取不到数据包 |
| 想了解 FIFO、LIFO 与并发值之间的关系 | 第 3.3~3.4 节:等待线程的挑选方式与可运行数 |
| 终止通知、批量取出、同步完成分别该怎么处理 | 第 4 章:按用途选择 API |
| 想完整追踪 await ReadAsync 从发出到恢复的过程 | 第 5.1 节:await 的一次往返 |
| 想了解“I/O 等待不消耗线程”的适用范围 | 第 5.2 节:等待的线程与处理的线程 |
| 加了 ConfigureAwait(false),UI 还是卡死,或者无法操作 UI | 第 5.3 节:区分续体去处与 CPU 处理 |
| 负载一增加,整个异步处理就变慢 | 第 5.4 节:同步等待与线程池饥饿 |
1. 先说结论
IOCP 负责什么
- IOCP 是把“完成通知队列”与“线程数控制”融为一体的机制。完成数据包按 FIFO 顺序压入队列,工作线程用
GetQueuedCompletionStatus取出(第 3 章)。1 - 线程按 LIFO 顺序被唤醒。刚刚还在工作、已经“热”起来的线程会继续接走下一个数据包,所以只要队列不空,几乎不会发生上下文切换(第 3.3 节)。1
- 并发值是“可运行线程数”的上限。推荐的出发点是 CPU 数量(传 0 就取处理器数量)。正在运行的线程一旦阻塞,系统会唤醒等待中的线程来填补空缺(第 3.4 节)。12
选择 API 时的要点
- 端口也能用来传递自定义通知。用
PostQueuedCompletionStatus可以压入与 I/O 无关的数据包,因此给工作线程派活、发终止指示,都能走同一个队列(第 4 章)。3 - 要新写服务器实现,官方推荐的不是裸用 IOCP,而是 Windows 线程池 API(
CreateThreadpoolIo)。它内部仍然是 IOCP,只是替你承担了线程管理(第 4 章)。1
在 .NET 和 await 层面要区分的事
- .NET 线程池是工作线程与 I/O 完成线程的两层结构,异步 I/O 的句柄会绑定到线程池(自身的 IOCP)上。
await的 I/O 等待期间不存在线程,只有完成之后的续体才会占用线程(第 5 章)。456 - 续体的去处由“被捕获的上下文”决定。在 UI 线程上
await,后续代码就回到 UI 线程;没有可捕获的对象时,就在线程池(或让 Task 完成的那条线程)上继续。ConfigureAwait(false)是“停止捕获”的指示,并不保证一定转到线程池(第 5.3 节)。6
图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 32 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle
2. 靠堆线程硬扛的设计会在哪里崩掉
先确认 IOCP 想解决的问题。朴素的服务器可以写成“一条连接配一条线程”:用同步 I/O 读取、处理、再写回,是一种很好理解的设计。
问题出在连接变多之后。请在图 1 中对比按等待中的连接数同步增加线程的设计与只让少数线程处理已完成的工作的设计。
flowchart TB
subgraph A["每条连接一条线程(同步 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 是一个可以自由取值的量,用来告诉工作线程“这是来自哪个句柄的完成通知”。惯常做法是放入连接对象的指针。2
| 拿到的信息 | 用来识别或确认什么 |
|---|---|
| CompletionKey | 是来自哪个句柄、哪条连接的完成通知 |
| OVERLAPPED 指针 | 该连接上的哪一个操作完成了 |
| 传输字节数 | 传输了多少数据 |
这正是把第 2 篇中提到的“一个操作的凭证”在完成时回收起来的机制。
对象并不限于“文件”。套接字、命名管道、邮件槽等,只要是能进行重叠 I/O 的句柄都可以关联上来。1 第 1 篇看到的“一切看起来都是文件”的设计,在这里同样发挥作用。
3.2. 完成数据包的旅程
sequenceDiagram
participant DRV as 内核(IRP 完成)
participant Q as 端口的队列(FIFO)
participant W as 工作线程
Note over W: 用 GetQueuedCompletionStatus 等待
DRV->>Q: 压入完成数据包<br/>(字节数 / CompletionKey / OVERLAPPED)
Q->>W: 唤醒一条等待中的线程并把数据包交给它
Note over W: 查看数据包并做完成处理<br/>(执行续体、发出下一个 I/O 等)
W->>Q: 处理完毕后再次调用 GetQueuedCompletionStatus
Note over Q: 队列中若还有数据包<br/>就不必等待直接取下一个
图3:完成数据包按 FIFO 顺序压入,工作线程反复执行“取出后处理”的循环
异步 I/O 完成后,完成数据包会按 FIFO 顺序压入端口的队列。工作线程反复执行下面这个循环。17
- 用
GetQueuedCompletionStatus接收一个数据包。 - 对收到的那个操作做完成处理。
- 处理结束后,再次调用
GetQueuedCompletionStatus。
还有一个能一次批量取出多个数据包的 GetQueuedCompletionStatusEx,在高频 I/O 场景下可以减少调用次数。8
不要只看到 FALSE 就跳出循环
即使 GetQueuedCompletionStatus 的返回值是 FALSE,也可能已经成功取出了一个失败 I/O 的完成数据包。请结合 OVERLAPPED 指针一起判断。7
| 返回值 | OVERLAPPED | 含义与处理 |
|---|---|---|
FALSE |
非 NULL | 取到了一个失败 I/O 的完成通知。需要做错误处理,并回收凭证与缓冲区 |
FALSE |
NULL | 没能取到数据包。例如超时或端口被关闭 |
失败的操作同样需要收尾。如果只写 if (!GetQueuedCompletionStatus(...)) break; 就完事,就会漏掉失败的 I/O 而造成泄漏。第 2 篇讲过的凭证与缓冲区生命周期管理,并不只针对正常完成的情况。
在工作线程循环里看判断顺序
下面这段骨架代码先判断上表的两种情况,之后再处理终止用数据包与常规完成。
/* 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 顺序被唤醒
这一节请把数据包进入队列的顺序与唤醒等待中线程的顺序分开来看。1
| 对象 | 顺序 | 关注点 |
|---|---|---|
| 完成数据包 | FIFO | 完成通知压入队列的顺序 |
| 等待中的线程 | LIFO | 从最后进入等待的线程开始唤醒 |
由于数据包与线程的顺序相反,实际表现就是刚刚还在工作的那条线程会继续接走下一个数据包。
flowchart TB
Q["队列 P1 → P2 → P3(FIFO)"]
subgraph TH["等待中的线程(LIFO 栈)"]
A["线程 A(刚刚还在运行,还热着)"]
B["线程 B(已经睡了一阵)"]
C["线程 C(一直在睡)"]
end
Q -->|"P1、P2、P3 只要 A 空闲<br/>都优先交给线程 A"| A
B -.->|"只有 A 被占满时才轮到"| Q
C -.->|"极少被唤醒"| Q
图4:LIFO 释放。越繁忙,同一条线程就越会持续运转,闲着的线程可以一直睡着
这个设计带来两点好处。
- 不会发生上下文切换。只要队列里还有数据包,处理完毕的线程调用
GetQueuedCompletionStatus时就会不必等待、直接拿到下一个数据包,继续跑下去。文档在并发值为 1 的场景中明确写着“不会发生线程切换”。1 - 缓存能保持热度继续使用。由于是同一条线程持续运转,栈和调度相关的状态留在 CPU 缓存里的概率更高。睡着的线程则作为应对负载峰值的备用力量,被低成本地维持着。
3.4. 并发值——数的是“可运行”的线程
创建端口时的 NumberOfConcurrentThreads 就是并发值。它数的是关联到该端口的可运行(runnable)线程。在达到上限期间,额外的线程无法接收数据包。1
传入 0 时会使用系统的处理器数量。文档给出的“总体上最好的最大值”也是 CPU 数量,所以就以它作为出发点。21
它不是包含等待中线程的总数上限。 请在图 5 中看有线程进入等待状态时会发生什么。
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 让完成处理保持简短是首要原则,这一点在第 5.4 节的 .NET 场景中也以同样的形式起作用。
4. 工具箱——支撑端口的那些 API
4.1. 让派活和终止指示走同一个队列
PostQueuedCompletionStatus——不发出实际的 I/O,也能把自定义的完成数据包压入队列。3 给工作线程派活、发关闭指示(按工作线程的数量压入相应数量的终止用数据包——俗称“毒丸”数据包)、来自其他线程的通知——I/O 完成通知与自定义消息可以用同一个队列、同一个循环来处理,这大大简化了设计。
4.2. 批量接收高频 I/O 的完成通知
GetQueuedCompletionStatusEx——一次取出多个完成数据包。在“一个数据包一次调用”的开销开始显现的高频 I/O 场景下很有效。8
4.3. 省掉同步完成时的通知
SetFileCompletionNotificationModes——对于第 2 篇第 5 章讲过的“按异步发出却同步完成”的情况,可以选择不向端口压入数据包的模式(FILE_SKIP_COMPLETION_PORT_ON_SUCCESS)。既然同步完成的结果当场就已经知道,再绕一趟队列纯属浪费——这是一种提速手段。9
4.4. 把线程的创建与管理交出去
Windows 线程池 API——CreateThreadpoolIo / StartThreadpoolIo 内部使用 IOCP,同时替你承担线程的创建与管理。微软建议新的服务器应用先考虑这套 API,只有在想显式控制并发值或线程管理时才裸用 IOCP。1 而 .NET 的线程池,正是把这套“IOCP + 线程管理自动化”作为 .NET 运行时实现出来的产物。
4.5. 换哪种 API 都不变的三条生命周期规则
- 不要在工作线程里长时间阻塞。 第 3.4 节的补充机制只能缓解劣化。
- 按句柄和按操作分两层识别。 CompletionKey 是句柄级别,
OVERLAPPED是操作级别。第 2 篇的“凭证”在完成之前不能释放。 - 不要在还有未完成 I/O 时关闭句柄。 cleanup 的行为(第 1 篇第 6 章)与取消的规范(第 2 篇第 6 章)在这里原样适用。
5. .NET 线程池——建在 IOCP 之上的两层楼
从这里开始,把 Win32 的完成通知与 .NET 的代码对应起来。顺序是接收完成通知的线程 → await 的一次往返 → 等待时间 → 续体的执行位置。
.NET 线程池提供下面两种角色的线程。4
| 种类 | 本文关注的角色 |
|---|---|
| 工作线程 | 执行 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 一侧的对应机制。5
历史更久的 ThreadPool.BindHandle 也以同样的角色保留着,但新写代码就用 ThreadPoolBoundHandle.BindHandle。当 FileStream 或 Socket 打开异步模式的句柄时,内部就会做这类绑定。5
把它与 Win32 的对应关系按发出和完成分开,就是下面这样。
- 第 2 篇的“异步模式句柄 + OVERLAPPED”是发出请求的机制
- 本文的 IOCP 是接收完成通知的机制
- .NET 线程池的 I/O 完成线程,就是运转
GetQueuedCompletionStatus循环的工作线程团队
按这样的对应关系,Win32 的图景就原样变成了 .NET 的图景。
5.1. await ReadAsync 一次往返的完整版
第 2 篇图 7 中标着“真正的异步 I/O”的那个方框,这一篇要把里面的内容一直追到完成之后的续体。请在图 6 中把发出请求时的线程、接收完成通知的线程,以及执行后续代码的位置分开来看。
sequenceDiagram
participant U as 调用线程<br/>(UI 线程等)
participant K as 内核<br/>(IRP 发出到完成)
participant Q as 线程池的 IOCP
participant IO as I/O 完成线程
participant C as 续体的执行位置
U->>K: ReadAsync 发出异步读取<br/>(附带相当于 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 篇);完成则通过“中断 → 完成数据包”这条事件链送达(本文)。也就是说,维持“等待”这一状态,本身就不需要线程这种昂贵的资源。
要与 CPU 处理和伪异步区分开
所以,正确使用 async/await 的应用可以用十几条线程维持“同时有 10,000 个 I/O 在飞”的状态。反过来说,这个性质只属于 I/O 密集型的 Task。用 Task.Run 包起来的 CPU 处理当然会占用一条工作线程;第 2 篇第 7 章的“伪异步”也在背后让线程睡着。
5.3. 续体在哪里运行
图 6 最后那支箭头讲的是“收到完成通知之后,把续体放在哪里执行”。要把是否回到上下文与把占用 CPU 的处理放到哪里分开考虑。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 实务判断表》。
错误示例:以为 ConfigureAwait(false) 就能换地方运行
用代码确认最后这一点。下面这个例子,是把 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 和繁重处理的执行位置
把 UI 代码和库代码分开,各自把意图写清楚。用于回到 UI 的 await,与把 CPU 处理送到线程池的 Task.Run,是两种不同的角色。
// 正确示例:分别指示“是否回到 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(); // 不依赖调用方的上下文
}
判断可以分成下面 3 种情况。
| 目的 | 在这段代码里的选择 |
|---|---|
| await 之后要操作 UI | 在 UI 代码中保持上下文被捕获 |
| 把繁重的 CPU 处理送到线程池 | 用 Task.Run 显式指定 |
| 库内部不需要回到调用方的上下文 | 用 ConfigureAwait(false) 停止捕获 |
ConfigureAwait(false) 并不用来指定执行线程。请把它当作让库代码不依赖调用方上下文的工具,与 UI 处理和 CPU 处理的安置分开看待。
5.4. 卡顿的真相——线程池饥饿
同步等待会堵住执行续体的线程
在续体或工作线程里同步等待,这条线程就会一直被堵住。同步 I/O、Task.Result/Wait()、长时间的锁等待,都属于这种模式。
IOCP 自身的补充机制(第 3.4 节)只要还有待命的备用线程就会立刻生效。但备用线程耗尽之后,就进入了线程池只会缓慢注入新线程的区域。
一旦在负载压上来的瞬间出现“想执行续体却没有线程可用”的饥饿(starvation),整个应用就会变得迟钝。
把空闲数量与实际的阻塞位置结合起来查
排查的入口有两个。
- 用
ThreadPool.GetAvailableThreads查看工作线程与 I/O 完成线程的空闲数量。4 - 用事件跟踪掌握线程池与阻塞的实际情况。
跟踪的具体步骤整理在《用 PerfView 与 dotnet-trace 定位“变慢”的原因》里。
预防的原则是让 async 的路径一路保持 async 走到底,不要混入 sync-over-async。即使在 I/O 等待期间交出了线程,只要在续体里同步等待,就会在那里重新堵住一条线程。
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 篇《缓存管理器——你的 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 线程
- 认为每次 Send 的单位都能对应一次 Receive 的误解——把 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 ↩6
-
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 ↩5
-
Microsoft Learn,ThreadPoolBoundHandle.BindHandle method。关于 ThreadPoolBoundHandle.BindHandle 会返回一个把操作系统句柄绑定到系统线程池(的 I/O 完成端口)上的 ThreadPoolBoundHandle,对已绑定句柄的低层异步 I/O 要配合 NativeOverlapped 进行,以及异步 I/O 的完成处理会改由线程池负责等内容。 ↩ ↩2 ↩3 ↩4
-
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 的含义、完成通知的 4 种方式、本应异步却同步完成的条件、取消的正确做法,以及与 .NET 的对应关系。
Windows I/O 底层原理(第 4 篇)——缓存管理器:你的 WriteFile 何时才真正写入磁盘
本文是图解讲解 Windows 缓存管理器的系列第 4 篇。梳理以文件映射方式实现的缓存、预读与延迟写入、FlushFileBuffers 与 FILE_FLAG_NO_BUFFERING 的取舍,以及断电导致数据丢失的条件。
Windows I/O 底层原理(第 1 篇)——所有读写最终都会变成 IRP:I/O 系统全貌
本文是从底层讲解 Windows I/O 系统的系列第 1 篇。用图解梳理对象管理器的命名空间,驱动程序、设备、文件这三种对象,IRP 的生命周期,直到 CloseHandle 背后的机制。
正确处理 Windows 的模拟令牌 ── 线程级权限借用与安全的还原方式
本文围绕 Windows 的模拟令牌,梳理访问令牌、主令牌、线程令牌、模拟级别、RevertToSelf,直至 .NET 的 WindowsIdentity.RunImpersonated,整理在实务中安全处理它们的思路。
参数为什么会坏掉 ── Windows 命令行参数的规则
在 Windows 上并不存在参数的数组,传给 CreateProcess 的是一条字符串,切分由接收方完成。本文讲解 CommandLineToArgvW・CRT・.NET 的切分规则,以及 .NET 的 ArgumentList 与 C++ 中正确的组装方法。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
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 当作出发点。这个值是“处于可运行状态的线程数”的上限,而不是等待中线程数的上限。正在运行的线程因为某种原因进入等待状态时,系统会唤醒另一条等待中的线程来填补空缺,因此如果处理中混有耗时较长的计算或阻塞,也可以把并发值设得大一些,以增加同时处理的数据包数量。文档推荐的做法是最终结合性能分析来调整。
- 为什么可以说 async/await 的 I/O 等待不消耗线程?
- 因为从 I/O 发出到完成的这段时间里,任何地方都不存在专门照看这个操作的线程。正如本系列第 1 篇和第 2 篇所看到的,发出的请求会作为 IRP 流入设备栈,调用本身会以 ERROR_IO_PENDING 立即返回。await 在那里只是把续体注册到尚未完成的 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 完成线程的空闲数量,用于排查卡顿。