为什么 Rust 的无栈协程是异步 I/O 的技术最优解?
Go 可以在普通调用链深处挂起 goroutine,让线程去处理其他工作。Rust 为什么没有采用同样的方式,却要求开发者显式写出 async/await,还要面对 Future、Pin 和执行器?
本文要论证的是:在大量任务都等待网络的异步 I/O 场景中,Rust 的无栈协程可以称为技术最优解。 这里的“最优”不是跑分意义上的全面胜出,而是指它同时解决了三个核心问题:等待中的任务不必各自占用一份可挂起的协程栈,异步操作可以组合,执行路径又能由编译器直接生成状态机。
这里的“技术最优解”有明确范围:比较的是等待任务的内存占用、可组合性、执行效率、运行时耦合和资源边界。无栈协程省掉的是每个等待任务的专属协程栈,不是 Future 状态、连接和缓冲区本身;它也不是宣称所有负载下都最快。CPU 密集计算、长时间阻塞调用和需要透明暂停的场景,仍然可能适合线程或有栈协程。后文会先解释这些方案分别保存什么,再说明为什么这组取舍在异步 I/O 中占优。
版本边界:Java 部分以常见 OpenJDK/HotSpot 平台线程为起点;Go 路径对照 1.26.1;Rust 编译示例使用 1.94.0。文章按“工作如何暂停、如何恢复”组织,不是在排列这些方案的发明先后。
一、线程替我们记住了什么?
处理请求时,程序会一层层调用:接收数据、解析、访问下游、写回结果。方法还没结束,就需要记住参数、局部值、执行位置,以及返回后继续哪里。
先从最直观的阻塞模型开始:调用栈怎样替线程保存一份未完成的工作,为什么这会让线程池逐渐被等待中的请求占满。后面再看事件驱动、Go goroutine 和 Rust Future 如何把这份工作交给别的存储。
线程为这条调用链提供独立的执行现场。在 Java 平台线程中,Thread 对象提供身份与控制接口,JVM 管理方法执行状态,底层 OS 线程承载机器代码并接受系统调度。要注意,调用现场和业务对象不是一回事:局部变量可能只是保存堆对象的引用,并不意味着对象本身全部位于栈上。
假设 handle 调用 read,网络数据暂时没到。read 没有返回,上层调用也就仍然存在:
flowchart TB
R["JDK 读取内部<br/>等待数据就绪"]
Q["read 调用现场<br/>连接、缓冲区与读取进度"]
H["handle 调用现场<br/>参数、局部状态与后续处理"]
E["线程池工作循环<br/>等待当前任务结束"]
R -->|读取推进后返回| Q
Q -->|交回读取结果| H
H -->|任务完成后返回| E图 1:逻辑调用链。实际方法可能被 JIT 内联,但等待结束后仍要有足够的信息继续执行。
线程的便利也很具体:开发者按普通调用顺序写,执行环境替你记住“已经做到哪一步”。
二、CPU 换了线程,任务为什么还没有换手?
操作系统决定 CPU 执行哪条线程;线程池决定工作线程领取哪个任务。这是两层不同的安排。
只看一个 CPU 核,T1 等待网络前后可以这样理解。左边表示当前由谁占用 CPU,右边表示 T1 的任务状态:
%%{init: {"flowchart": {"padding": 8, "rankSpacing": 25, "nodeSpacing": 20}}}%%
flowchart TB
subgraph S1["① T1 发起读取"]
direction LR
C1["CPU 核 0<br/>执行 T1"]
W1["T1:运行<br/>执行 handle"]
C1 ~~~ W1
end
subgraph S2["② T1 等待网络"]
direction LR
C2["CPU 核 0<br/>执行 T2"]
W2["T1:等待<br/>调用栈保留"]
C2 ~~~ W2
end
subgraph S3["③ T1 获得调度"]
direction LR
C3["CPU 核 0<br/>再次执行 T1"]
W3["T1:恢复<br/>继续原调用"]
C3 ~~~ W3
end
S1 -->|等待网络,切出 T1| S2
S2 -->|T1 就绪并获调度| S3图 2:CPU 执行了 T1 → T2 → T1,T1 却始终承担同一件未完成的工作。T2 不一定来自这个线程池。
第二阶段,T1 不占 CPU,却仍占着线程池的一个工作名额:read 没返回,handle 没结束,它不能回到取任务循环。第三阶段,T1 获得调度,继续原来的读取,而不是领取新任务。
CPU 能切换线程,不等于一条线程能切换尚未完成的任务。 要让线程离开当前任务,必须先找到另一种保存执行进度、以后继续的办法。
三、线程池解决了创建成本,却解决不了等待占用
线程池能复用执行环境,也能限制同时运行的任务数;但只要阻塞调用没有返回,工作线程就仍被当前任务占着。
CPU 空闲,不代表有线程能接新任务
假设每秒到达 10,000 个请求,每个平均计算 1 毫秒、等待 100 毫秒。忽略排队与管理开销,在稳定条件下:
平均在途请求 ≈ 10,000 × 0.101 ≈ 1,010
每秒计算需求 = 10,000 × 0.001 = 10 CPU 秒
若只有 200 条阻塞工作线程:
服务速率上限 ≈ 200 / 0.101 ≈ 1,980 请求/秒约十个核的计算需求,却要保存约一千份未完成工作。按这个简化模型,200 条线程每秒只能完成约 1,980 个请求;即使机器还有空闲核,新请求也可能因为工作名额都在等待而排队。
前面的数字是一个排队模型,用来说明“等待时间会占住工作名额”,不是某个平台的性能测试。实际吞吐量还会受到连接建立、调度、内存、I/O 驱动和请求分布等因素影响。
因此,继续加线程只能推迟瓶颈,同时会引入几类新的成本。
内存:等待中的线程仍要保留现场
线程存活时,需要用户栈、每线程管理状态和内核线程资源。调用现场引用的连接、缓冲区或 ThreadLocal 数据,也可能继续存活。
例如,假设每条线程为用户栈预留 1 MiB 地址空间,平均驻留栈页为 128 KiB,10,000 条线程对应:
地址空间预留:10,000 × 1 MiB ≈ 9.77 GiB
实际驻留栈页:10,000 × 128 KiB ≈ 1.22 GiB这是数量级示例,不是默认配置或实测;两行不能相加,驻留页面位于预留范围内。缩小线程栈可以减少每条线程预留的地址空间,也可能降低实际提交的栈页数量,但它不是免费的优化。栈空间过小,会压缩深层调用、较大的局部变量、递归以及 JNI/原生代码调用所需的余量;一旦超过边界,可能触发栈溢出或让线程直接失败。即使只看内存,线程还要承担线程控制块、线程局部存储、栈保护页和内核线程对象等原生资源,这些成本不会随着 Java 局部变量变少而消失。与此同时,操作系统对可创建线程数、进程地址空间、提交内存和调度对象也有上限。因此,调小栈只能缓解单线程成本,不能把平台线程变成适合承载数万份等待任务的轻量任务。
连接、缓冲区等业务数据并非线程独有的成本。换成 goroutine 或 Future,未完成任务仍然需要的数据也不能丢。
创建与回收:不只是分配一个 Thread 对象
创建平台线程,要准备语言执行环境的管理状态、栈和线程局部存储,并建立 OS 执行实体;退出时则要清理关联状态、交还原生资源。这些工作需要 JVM 与 OS 配合,不能等同于普通 Java 对象的分配和 GC。
短任务越多,反复建立执行环境越不划算。线程池解决了这部分重复劳动,但没有省掉等待中的线程资源,也不会替业务代码自动关闭连接。
调度与切换:更多线程仍在争同一批 CPU
8 个核面对 800 条可运行线程,不会变成 800 路并行。切换时,系统需要保存必要的指令位置、栈指针和寄存器,选择下一条线程,再恢复上下文。工作集变化与跨核迁移还可能损害缓存局部性。
切换不需要复制整份线程栈,同一进程内也不意味着每次都完整切换地址空间。但调度、恢复和重新访问数据仍有成本,不能给出脱离平台与负载的固定耗时。
还要区分:大量睡眠线程主要留下资源占用;大量可运行线程或集中唤醒,才更容易形成调度竞争。
所以,后续方案要把两个数量分开:有多少份工作尚未完成,以及需要多少条线程执行它们。
四、事件驱动先把调用栈改写成状态
阻塞版本通常这样写:调用 read,等数据到齐后继续解析,最后返回结果。等待期间,连接、缓冲区、读取位置和“读完以后要做什么”都藏在调用栈里。
非阻塞 I/O 不能继续沿用这条调用链。读取暂时没有数据时,代码必须先返回,把原来藏在调用栈里的信息放进一个状态对象;数据就绪后,事件循环再拿着这个对象继续处理。
下面是一个简化的 Java NIO 示意。它不是完整的 HTTP 实现,重点只看状态从哪里来、怎样被 Selector 找回来:
final class ReadState {
final SocketChannel channel;
final ByteBuffer buffer = ByteBuffer.allocate(8192);
Phase phase = Phase.READING_HEADERS;
ReadState(SocketChannel channel) {
this.channel = channel;
}
enum Phase {
READING_HEADERS,
READING_BODY,
DONE
}
}
void onReadable(SelectionKey key) throws IOException {
ReadState state = (ReadState) key.attachment();
int n = state.channel.read(state.buffer);
if (n == -1) {
state.phase = ReadState.Phase.DONE;
key.cancel();
return;
}
if (n == 0) {
// 数据还不够,本次处理结束;状态留给下一次事件。
key.interestOps(SelectionKey.OP_READ);
return;
}
state.buffer.flip();
if (state.phase == ReadState.Phase.READING_HEADERS
&& headersAreComplete(state.buffer)) {
state.phase = ReadState.Phase.READING_BODY;
}
parseAvailableBytes(state);
state.buffer.compact();
}
while (running) {
selector.select();
for (SelectionKey key : selector.selectedKeys()) {
if (key.isReadable()) {
onReadable(key);
}
}
selector.selectedKeys().clear();
}这段代码把原来的“暂停现场”拆成了几个字段:channel 保存连接,buffer 保存已经收到但尚未处理完的数据,phase 保存当前阶段,SelectionKey 则把这份状态挂回 Selector。onReadable 处理一小段数据后就返回;下一次事件到来时,再从同一个 ReadState 继续,而不是恢复原来的 Java 方法调用栈。
flowchart TB
A["本次读取无法继续<br/>请求尚未完成"]
B["保存阶段、缓冲区和进度<br/>把状态挂到 SelectionKey"]
C["退出本次处理调用<br/>事件循环处理其他连接"]
D["Selector 报告就绪<br/>取回对应请求状态"]
E["根据状态继续读取<br/>部分读写后进入下一阶段"]
A -->|把后续工作表示成数据| B
B -->|释放处理线程| C
C -->|连接出现事件| D
D -->|分派并恢复处理| E图 3:普通调用栈可以退出,请求进度由状态对象承载。就绪只表示“现在值得再试”,不保证一次读取就完成;实际实现还要处理部分读写、EOF、错误、超时和写入背压。
事件驱动解决了等待占用线程的问题,但也把原本由调用栈自然维护的局部变量、循环、返回和清理关系,拆成了状态字段、事件分派和回调。事件循环里如果直接调用阻塞库,照样会被堵住。
问题就变成了:能不能保留事件驱动的资源利用率,同时让开发者继续按顺序写代码?
五、Go 的办法:保留调用栈,让线程去执行别的 G
Go 给出的答案是:保留每条 goroutine 的调用栈,再让 runtime 把这些 G 安排到 OS 线程上。先看一段读取,process 代表收到数据后的同步处理:
func work(conn net.Conn) error {
buf := make([]byte, 1024)
n, err := conn.Read(buf)
if n > 0 {
process(buf[:n])
}
return err
}用 go work(conn) 启动后,Read 没数据时,局部状态在哪里?线程怎样离开,又怎样回来?
等网络的是 goroutine,不必是执行它的线程
执行 go work(conn) 时,Go 运行时会创建或复用一条 goroutine,把它交给调度器。它有自己的调用栈,但不需要一条专门的 OS 线程一直陪着它。
假设这条 goroutine 正在执行 conn.Read(buf),而网络数据还没到。Go 网络库会让它暂停等待:work 和 Read 尚未结束的调用栈仍然保留,运行时也记住以后从哪里继续。原来执行它的线程则可以转去执行另一条已经准备好的 goroutine。
数据到来后,调度器再给它一次执行机会。运行时恢复保存的执行位置,网络库继续尝试读取;读到数据后,Read 返回,work 才接着调用 process。这不是重新执行一次 work,也不需要重新创建 buf。
接下来的图用三个名字描述这个过程:G 是 goroutine,M 是 OS 线程,P 是线程执行用户 Go 代码所需的一组运行时资源。M 持有 P,才能执行某个 G。这里最值得关注的是:G1 等网络时可以保留自己的栈,而 M 可以带着 P 转去执行 G2。
GOMAXPROCS 控制 P 的数量。例如设为 2,就最多有两条 G 同时执行用户 Go 代码;这不意味着程序只有两条 goroutine 或两条 OS 线程,也不意味着 P 就是物理 CPU 核。
从 G1 等待,到 M 改为执行 G2
以 Go 1.26.1 的 Unix socket 路径为例,internal/poll 先尝试非阻塞读取。无数据时,系统调用可返回 EAGAIN,网络库再进入 runtime 等待路径。数据已可用则直接读取;Windows 的底层事件实现不同。
需要挂起 G1 时,runtime 协调事件与等待者的关系,保存必要的恢复上下文。M 转到自己的 g0 系统栈安排挂起和调度,再恢复 G2:
flowchart TB
A["M1/P0 在 G1 栈上运行<br/>读取暂时无法推进"]
B["记录 G1 等待关系<br/>保存必要的 sp、pc 等"]
C["转到 M1 的 g0 系统栈<br/>runtime 将 G1 挂起"]
D["调度器选中可运行 G2<br/>装入 G2 的执行上下文"]
E["M1/P0 改在 G2 栈上执行<br/>G1 的用户栈仍保留"]
A -->|进入 runtime 等待路径| B
B -->|交给 runtime 调度| C
C -->|寻找下一份工作| D
D -->|恢复另一个 G| E图 4:M 不陪 G1 等网络,而是执行 G2。调度器使用 g0,避免依赖正在被挂起和切换的业务栈;就绪竞争也可能使等待取消。
网络事件到来,轮询器找到 G1,使它从 waiting 变为 runnable。某条持有 P 的 M 选中它后,恢复上下文,沿网络库的等待返回链重新检查并推进读取,最终回到 work 执行 process。承载它的 M 可以改变。
恢复执行的线程不一定是原来那条线程,但 G 保留的调用栈仍属于同一份未完成的工作。
这份便利需要 runtime 管理什么?
G 的栈可以从小空间开始,按需增长。增长时可能分配更大的栈,复制使用中的部分并调整指针;普通暂停切换通常不复制栈。存活栈及其中可达的引用也参与 GC 相关管理。
函数正常返回,defer 执行后,runtime 清理 G,按规则复用管理对象和起始栈,或释放不适合保留的栈。G 结束不要求销毁 M,相关堆对象仍由 GC 按可达性处理。一直没有退出条件的 G 也不会因“轻量”就自动消失。
网络等待不代表所有阻塞都能只停 G。某些系统调用或 cgo 会占住 M,runtime 需要移交或回收 P,让其他 M 继续;现代 Go 还支持有边界的异步抢占,应对长时间运行的 Go 计算。
Go 用每个 G 的调用栈和 runtime 管理,换来了接近普通同步代码的暂停体验。Rust 走的是另一条路:不保存整条调用栈,而是把未完成的计算拆成状态,让调度与计算彼此分开。
六、Rust 把运行时边界交给应用
Rust 早期也支持绿色线程。2014 年的 RFC 0230 提议移除标准库中的统一运行时,理由不是有栈方案“性能不行”,而是原生线程与绿色线程对 I/O、调度和嵌入环境的要求不同;把它们强行塞进一套标准接口,会让普通系统代码承担额外的运行时耦合。
大量并发 I/O 的需求仍在。2016 年的 Future 设计选择用可组合的状态对象表示异步计算,尽量接近手写状态机,不强制每层操作都单独分配、入队和调度。2019 年 Rust 1.39 稳定 async/await,再把控制流转换交给编译器。
接下来用一次 HTTP 请求把这个转换具体化:先取得响应,再读取正文。编译器负责生成可恢复的计算,Tokio 负责安排推进,reqwest 负责 HTTP 操作。
七、Rust Future:让调用返回,把进度留下来
这段代码看起来仍是顺序执行,但它的等待方式已经变了:
async fn fetch_data(url: &str) -> Result<String, reqwest::Error> {
let resp = reqwest::get(url).await?;
let text = resp.text().await?;
Ok(text)
}第一个 await 等到响应,第二个 await 收集完整正文。调用 fetch_data(url) 只会构造 Future,不会立刻执行函数体;真正的推进要等调用者调用它的 poll。在 Tokio 中,执行器推进顶层 Future,父 Future 在 await 处推进子 Future,并不是每层异步调用都各自成为一个调度任务。
等待时,Future 留下的是状态,不是一份专属调用栈
这份计算可以概括成三个阶段:
| 阶段 | 需要保留什么? |
|---|---|
| 尚未开始 | 传入的 url |
| 等待响应 | 请求子 Future 及其进度 |
| 读取正文 | 正文子 Future 及其进度;Response 已移入该子操作 |
子操作返回 Ready,父状态机取得结果继续执行;返回 Pending,父状态机保存阶段并返回,本轮调用帧随之退出。网络事件触发通知后,执行器再次调用 poll,状态机进入原来的阶段,而不是恢复一份旧调用栈。
flowchart TB
A["执行器调用 Future.poll<br/>按当前阶段推进子操作"]
B["子操作暂时不能完成<br/>父层保存阶段并返回 Pending"]
C["本轮调用帧退出<br/>Future 仍持有进度与数据"]
D["事件通过 Waker 通知 task<br/>执行器安排再次 poll"]
A -->|等待条件未满足| B
B -->|交还执行权| C
C -->|等待路径已安排通知| D
D -->|沿保存阶段继续| A图 5:Rust 的正常等待路径。运行时安排顶层推进,父子 Future 逐层调用;图中合并了 HTTP 库内部的等待与通知转接。
把观察点停在让出执行权的瞬间,两种保存方式的区别是:
| 此刻观察什么? | Go goroutine | Rust Future |
|---|---|---|
| 未完成的调用现场 | G 的专属调用栈仍保留 | 本轮 poll 的调用帧已退出 |
| 进度保存在哪里? | 调用栈与恢复上下文 | 阶段、字段和当前子 Future |
| 再次执行时做什么? | 恢复上下文,继续原调用 | 新调用 poll,按阶段进入对应路径 |
这两种方案都能让线程去处理其他工作,区别在于“暂停现场”放在哪里。Go 为每个 G 保留可恢复的调用栈;Rust 让本轮 poll 返回,把阶段、数据和子 Future 留在状态对象里。这样子计算可以直接组合进父 Future,不必因为调用层级增加而自动增加调度任务。
当然,状态、连接和正文缓冲仍要占内存。省掉专属栈,不等于消除等待中的资源占用。
省下专属栈,也把边界交给开发者
Rust 不会让所有普通函数都能透明暂停。异步边界要显式表达,poll 要及时返回;如果其中执行同步阻塞或长计算,工作线程仍会被占住。内部状态还可能包含借用,因此需要 Pin 提供地址稳定约束;任务取消则按所有权清理状态,不会撤回已经发出的请求。
Future 的角色与 poll 的调用方向、变量如何保存、Waker 怎样通知,以及 Pin 和取消的边界,另见详细篇:《Rust 无栈协程如何暂停和恢复?》。
八、为什么无栈协程是异步 I/O 的技术最优解?
把前面的机制放在一起,可以用五个标准检验这个判断:等待时占多少内存,恢复时要付出什么执行成本,异步操作能否组合,是否绑定统一运行时,以及资源生命周期能否交给类型系统管理。
| 选择 | 对异步 I/O 的收益 | 付出什么 |
|---|---|---|
| 以 Future 字段保存跨暂停状态 | 不必为每个等待任务准备可挂起的专属调用栈;状态大小由实际字段决定 | Future 状态、连接和缓冲区仍占内存,复杂状态也可能变大 |
| 父层直接调用子 Future.poll | 异步操作可以组合,调用层级增加不必自动增加调度任务 | 暂停能力沿调用关系显式传播,代码需要遵守异步边界 |
| 编译成状态机而不是保存可挂起协程栈 | 等待时只保留跨暂停所需的状态;正常路径可被编译器内联和优化,不需要通用的栈切换机制 | 编译时间、代码体积和状态机分支可能增加;不能据此保证每个场景都更快 |
| 用 poll 与 Waker 划分推进、通知 | 线程等待网络时可以去处理其他任务,不依赖忙等待 | 需要运行时和 I/O 驱动配合,poll 必须及时返回 |
| 用所有权管理 Future 生命周期 | 取消、错误和资源回收有清晰的析构边界 | drop 不会撤回已经发出的 I/O;阻塞调用也不会自动变成异步 |
Go 优先保留普通调用链的透明暂停;Java 21 的虚拟线程也选择保留 Thread API 与可恢复调用栈,在适合的阻塞路径上卸载、再挂载。它们并非 Rust 无栈方案之前的过渡版本,而是优先目标不同。
Rust 接受显式异步边界,让编译器生成可恢复的计算,让库提供通知与 I/O,让应用选择调度方式。使用 Tokio 时,程序当然仍依赖运行时;区别在于 Rust 规定了 Future 的推进协议,却没有把某一套统一的调度和 I/O 运行时绑定到所有程序。
Future 状态通常只保留跨暂停所需的字段,poll 沿编译器生成的状态机推进,也不需要恢复一条透明的跨层调用栈。实际内存和速度仍取决于状态大小、编译优化、运行时实现、I/O 库和负载。
无栈协程不是所有并发问题的答案。它之所以适合异步 I/O,是因为网络请求的大部分时间都在等待,真正需要 CPU 执行的部分相对短;把等待中的工作表示成状态,正好能让有限的线程反复处理更多可运行任务。
线程池复用执行环境,事件驱动把进度写成状态,Go 为每个 G 保留调用栈,Rust 则把 Future 编译成可推进的状态机。以后再看到 await、poll 或“协程栈”这些词,可以先问两个问题:工作停在哪里?谁负责让它继续?答案通常比“有栈还是无栈”这个标签更能说明一个并发模型的真实代价。
参考资料
- Java 线程池的工作机制:https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/ThreadPoolExecutor.html
- Go runtime 的 G/M/P 与系统栈:https://go.dev/src/runtime/HACKING
- go1.26.1 的挂起、调度与回收:https://github.com/golang/go/blob/go1.26.1/src/runtime/proc.go
- Rust RFC 0230:移除统一运行时的提案:https://rust-lang.github.io/rfcs/0230-remove-runtime.html
- 2016 年的零成本 Future 设计:https://aturon.github.io/blog/2016/08/11/futures/
- Rust await 的推进与恢复语义:https://doc.rust-lang.org/reference/expressions/await-expr.html
- Java 虚拟线程的设计目标:https://openjdk.org/jeps/444
云服务器推荐
如果你需要部署博客、Docker 服务或个人项目,可以参考:
本文包含推广链接。通过链接购买不会增加你的费用,但我可能获得少量佣金。
评论