为什么 Rust 的无栈协程是异步 I/O 的技术最优解?

6,602 字#Java #操作系统 #并发 #Go #Rust #协程

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 goroutineRust 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 或“协程栈”这些词,可以先问两个问题:工作停在哪里?谁负责让它继续?答案通常比“有栈还是无栈”这个标签更能说明一个并发模型的真实代价。

参考资料

  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

云服务器推荐

如果你需要部署博客、Docker 服务或个人项目,可以参考:

本文包含推广链接。通过链接购买不会增加你的费用,但我可能获得少量佣金。

评论