从并发模型的演进看:为什么 Rust 的无栈协程是最优解?

5,250 字#Java #操作系统 #并发 #Go #Rust #协程

Go 可以在普通调用链深处挂起 goroutine,让线程去执行其他工作。Rust 为什么没有采用同样的方式,却要求显式的 async/await,还引入 Future、Pin 和执行器?

答案不只是“无栈更省内存”。Rust 要在支持大量等待的同时,保留可组合的抽象,不强制所有程序依赖统一的调度与 I/O 运行时。这组目标,决定了它怎样保存未完成的工作。

这里的“最优解”指契合 Rust 的设计目标,不是所有负载下的性能冠军。先看线程怎样保存执行现场,再沿 Go 和 Rust 的暂停、恢复过程比较两种选择。

Java 部分以常见 OpenJDK/HotSpot 平台线程为起点;Go 路径对照 1.26.1;Rust 编译示例使用 1.94.0。下面按问题组织方案,不代表它们严格依次被发明。

一、线程保存的,不只是正在执行的方法

处理请求时,程序会一层层调用:接收数据、解析、访问下游、写回结果。方法还没结束,就需要记住参数、局部值、执行位置,以及返回后继续哪里。

线程为这条调用链提供独立的执行现场。在 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.98 CPU 秒的计算,机器即使还有空闲核,新请求也只能排队:工作名额都在等。

继续加线程能推迟瓶颈,却会增加下面三类成本。

内存:等待中的线程仍要保留现场

线程存活时,需要用户栈、每线程管理状态和内核线程资源。调用现场引用的连接、缓冲区或 ThreadLocal 数据,也可能继续存活。

例如,假设每条线程为用户栈预留 1 MiB 地址空间,平均驻留栈页为 128 KiB,10,000 条线程对应:

地址空间预留:10,000 × 1 MiB   ≈ 9.77 GiB
实际驻留栈页:10,000 × 128 KiB ≈ 1.22 GiB

这是数量级示例,不是默认配置或实测;两行不能相加,驻留页面位于预留范围内。缩小线程栈可以减少部分成本,但会压缩深层调用的余量,还要面对原生资源和系统线程数量限制。

连接、缓冲区等业务数据并非线程独有的成本。换成 goroutine 或 Future,未完成任务仍然需要的数据也不能丢。

创建与回收:不只是分配一个 Thread 对象

创建平台线程,要准备语言执行环境的管理状态、栈和线程局部存储,并建立 OS 执行实体;退出时则要清理关联状态、交还原生资源。这些工作需要 JVM 与 OS 配合,不能等同于普通 Java 对象的分配和 GC。

短任务越多,反复建立执行环境越不划算。线程池解决了这部分重复劳动,但没有省掉等待中的线程资源,也不会替业务代码自动关闭连接。

调度与切换:更多线程仍在争同一批 CPU

8 个核面对 800 条可运行线程,不会变成 800 路并行。切换时,系统需要保存必要的指令位置、栈指针和寄存器,选择下一条线程,再恢复上下文。工作集变化与跨核迁移还可能损害缓存局部性。

切换不需要复制整份线程栈,同一进程内也不意味着每次都完整切换地址空间。但调度、恢复和重新访问数据仍有成本,不能给出脱离平台与负载的固定耗时。

还要区分:大量睡眠线程主要留下资源占用;大量可运行线程或集中唤醒,才更容易形成调度竞争。

所以,后续方案要把两个数量分开:有多少份工作尚未完成,以及需要多少条线程执行它们。

四、事件驱动:把执行进度从调用栈搬成状态

以 Java NIO/Selector 的非阻塞通道为例,读取暂时无法推进,就保存连接、阶段、缓冲区和读写偏移,登记事件后返回。少量线程据此管理许多连接,不再一条线程陪一个连接等到底。

flowchart TB
    A["本次读取无法继续<br/>请求尚未完成"]
    B["保存阶段、缓冲区和进度<br/>登记感兴趣的事件"]
    C["退出本次处理调用<br/>事件循环处理其他工作"]
    D["Selector 报告就绪<br/>找到对应请求状态"]
    E["根据进度继续读取<br/>满足条件后进入下一阶段"]
    A -->|把后续工作表示成数据| B
    B -->|释放处理线程| C
    C -->|连接出现事件| D
    D -->|分派并恢复处理| E

图 3:普通调用栈可以退出,请求进度由状态对象承载。就绪不保证操作完整完成,仍需处理部分读写、EOF 和错误。

收益是等待不再独占处理线程。代价是原来由调用栈维护的局部变量、循环、返回和清理关系,现在要拆成状态与回调。事件循环里直接调用阻塞库,仍会堵住它。

能否保留这种资源利用方式,又让开发者按顺序写代码?

五、Go:保留任务的调用栈,让线程执行另一个 G

Go 让每条 goroutine 保存自己的调用链,再由 runtime 安排到 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 没数据时,局部状态在哪里?线程怎样离开,又怎样回来?

G 保存现场,M 提供线程,P 提供执行资源

对象职责
Ggoroutine 的描述对象,关联自己的用户栈、恢复上下文与运行状态
M真正的 OS 线程
P执行用户 Go 代码所需的调度资源等

M 持有 P 才能执行用户 Go 代码。GOMAXPROCS 为 2 时,最多两条 G 同时执行用户 Go 代码,但 M 可以多于两条;有些 M 可能被系统调用占住。P 也不是物理核:OS 调度 M,Go 调度器在 M/P 上选择 G。

执行 go 语句,runtime 创建或复用 G,准备入口、用户栈和初始现场,再安排为可运行工作,不要求每条 G 新建一条 M。

G 的用户栈保存尚未结束的 work、Read 等调用关系,描述对象中的 sched 等状态保存恢复所需的 sp、pc 等信息。buf 是切片值,它与底层数组具体位于栈还是堆,由逃逸分析和编译结果决定;关键是恢复后的调用现场仍能访问它们。

从 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 可以改变。

这里没有重新进入 work 的函数入口,也没有重新创建 buf。业务看起来能停在 Read 再继续,是因为未结束的调用链一直保留着。保存的 pc 是机器恢复位置,不是源代码行号。

这份便利需要 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、调度与嵌入环境的要求不同,强行放进一套接口,会让普通系统代码也承担运行时耦合。这是对当时 Rust 实现的取舍,不是对所有有栈方案的性能判决。

大量并发 I/O 的需求仍在。2016 年的 Future 设计选择用可组合的状态对象表示异步计算,尽量接近手写状态机,不强制每层操作都单独分配、入队和调度。2019 年 Rust 1.39 稳定 async/await,再把控制流转换交给编译器。

下面用一次 HTTP 请求展开这个转换:先取得响应,再读取正文。Rust 编译器负责生成可恢复的计算,Tokio 负责安排执行,reqwest 提供 HTTP 操作。

七、Rust:让调用返回,把进度留在 Future 中

Rust 的做法是让编译器接手事件驱动中的状态拆分。开发者按顺序写 async/await,编译器生成保存阶段和数据的 Future,再由执行器推进它。以一次 HTTP 请求为例:

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,不是每层异步调用都独立调度。

等待时,留下的是状态,不是专属调用栈

这份计算可以概括成三个阶段:

阶段需要保留什么?
尚未开始传入的 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 goroutineRust Future
未完成的调用现场G 的专属调用栈仍保留本轮 poll 的调用帧已退出
进度保存在哪里?调用栈与恢复上下文阶段、字段和当前子 Future
再次执行时做什么?恢复上下文,继续原调用新调用 poll,按阶段进入对应路径

两者都能让线程去处理其他工作。无栈的收益是等待中的计算不必各自准备一份专属调用栈,子计算也能直接组合进父状态对象,不强制增加调度任务。状态、连接和正文缓冲仍要保存,不能把它理解成没有内存成本。

省下专属栈,也把边界交给开发者

Rust 没有让所有普通函数都能透明暂停:异步边界要显式表达,poll 要及时返回。如果其中执行同步阻塞或长计算,工作线程仍会被占住。内部状态还可能包含借用,因此需要 Pin 提供地址稳定约束;取消则按所有权清理状态,不会撤回已经发出的请求。

Future 的角色与 poll 的调用方向、变量如何保存、Waker 怎样通知,以及 Pin 和取消的边界,另见详细篇:《Rust 无栈协程如何暂停和恢复?》。

八、为什么这组取舍契合 Rust?

回到标题,真正的收益可以对应到前面的机制:

选择获得什么?付出什么?
以 Future 字段保存跨暂停状态不必为每任务准备可挂起的专属调用栈,所有权与析构继续覆盖等待期间大状态仍占内存,内部借用需要地址稳定
父层直接调用子 Future.poll增加一层异步抽象,不强制增加一份调度任务或堆分配暂停能力沿调用关系显式传播
用 poll 与 Waker 划分推进、通知应用选择执行器,普通同步接口不依赖统一绿色线程运行时异步驱动需要配套,poll 必须遵守调度纪律

Go 优先保留普通调用链的透明暂停;Java 21 的虚拟线程也选择保留 Thread API 与可恢复调用栈,在适合的阻塞路径上卸载、再挂载。它们并非 Rust 无栈方案之前的过渡版本,而是优先目标不同。

Rust 接受显式异步边界,让编译器生成可恢复计算,让库提供通知与 I/O,让应用选择调度。用了 Tokio,异步程序仍然需要运行时;区别是 Rust 提供 Future 的推进协议,不把某一套统一调度与 I/O 运行时强制绑定到所有程序。无栈并非唯一可能的实现路线,但这组取舍与 Rust 对可嵌入性、抽象成本和资源边界的要求更一致。

线程池复用执行环境,事件驱动手写进度,Go 保留每 G 调用栈,Rust 保存 Future 的字段和阶段。理解了“工作停在哪里、谁让它继续”,也就理解了无栈方案为什么适合 Rust,以及这份控制力需要付出的代价。

参考资料

  1. Java 线程池的工作机制:https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/ThreadPoolExecutor.html
  2. Go runtime 的 G/M/P 与系统栈:https://go.dev/src/runtime/HACKING
  3. go1.26.1 的挂起、调度与回收:https://github.com/golang/go/blob/go1.26.1/src/runtime/proc.go
  4. Rust RFC 0230:移除统一运行时的提案:https://rust-lang.github.io/rfcs/0230-remove-runtime.html
  5. 2016 年的零成本 Future 设计:https://aturon.github.io/blog/2016/08/11/futures/
  6. Rust await 的推进与恢复语义:https://doc.rust-lang.org/reference/expressions/await-expr.html
  7. Java 虚拟线程的设计目标:https://openjdk.org/jeps/444