100 万个任务同时等待,Rust 比 Go 少用 86% 内存
同样创建 100 万个任务,让它们都停下来等待一个信号,需要多少内存?
这次本地测试中,Go 的进程 RSS 约为 2.56 GiB,Rust/Tokio 约为 0.36 GiB。按未舍入的中位数计算,Rust 低约 85.9%。
这里的 Rust 任务已经交给 Tokio、实际执行并进入等待。测量后,两端都广播唤醒信号,核对了全部 100 万个任务的完成数。
这组差异,适合用来理解 Rust 无栈协程的价值:一个任务暂时没事可做时,程序需要为它保留多少东西?
同样等待,Go 保留调用栈,Rust 保存状态
先看一个动作:等到信号,再继续工作。
Go 可以直接创建 goroutine,在里面接收 channel:
go func() {
<-gate
// 收到信号后继续工作
}()goroutine 有自己的调用栈。等待 channel 时,运行时可以让执行它的线程去处理其他 goroutine,但这条 goroutine 的调用现场仍要保留,以便以后恢复。
Go 的栈起步较小,能按需要增长。本次使用的 Go 实现中,stackMin 为 2,048 字节;真实分配还要结合平台、调用深度和运行时规则。它已经比一任务一条 OS 线程轻很多,但同时保留大量任务时,这部分成本仍会累积。固定版本源码见:https://raw.githubusercontent.com/golang/go/go1.26.1/src/runtime/stack.go。
Rust 的写法可以是:
tokio::spawn(async move {
gate.notified().await;
// 收到信号后继续工作
});async 产生一个实现 Future 的状态对象。执行器调用它的 poll;暂时不能继续时,返回 Pending。跨过等待点仍要使用的数据,保存在 Future 中。Rust Reference 用“每个等待点对应一种状态”的形式解释这种表示,具体布局没有稳定承诺:https://doc.rust-lang.org/reference/expressions/block-expr.html#async-blocks。
于是,两边都能让线程去执行其他工作,等待现场的保存方式却不同。
| 任务正在等待时 | Go goroutine | Rust async Future |
|---|---|---|
| 进度主要保存在哪里 | 专属调用栈与运行时上下文 | Future 的状态、字段与子 Future |
| 下一次怎样继续 | 恢复 goroutine 的执行现场 | 再次调用 poll,根据状态推进 |
| 每个等待任务是否要保留专属协程栈 | 需要 | 不需要 |
无栈省掉的是每份等待计算的专属协程栈。 执行 poll 时仍然会使用工作线程的普通调用栈;Tokio 的任务对象、唤醒信息和等待结构也都需要内存。
Future 与独立 task 还要分开。直接在一个 async 函数里 .await 子 Future,会沿父子调用关系推进,不等于为每个等待点都创建一个独立调度任务。相关语义见:https://doc.rust-lang.org/reference/expressions/await-expr.html。本轮仍为每份工作创建独立 Tokio task,避免利用不同的任务粒度放大优势。
把任务数从 1 万增加到 100 万
为了观察这部分等待成本,测试没有接 HTTP、数据库或文件 I/O。每个任务只做三件事:进入等待、被广播唤醒、增加完成计数。
Go 使用独立 goroutine 等待共享 channel;Rust 使用 tokio::spawn 创建独立 task,等待共享 Notify。Rust 端先登记通知,再报告就绪,避免广播时遗漏还没登记的任务。
每个组合运行 3 个独立进程,两种实现交替顺序。全部任务报告就绪后,再等 500 ms,让尾部调度趋于稳定;随后连续读取 5 次进程 RSS,间隔 50 ms。先取每个进程这 5 次的中位数,再对 3 个进程取中位数。
| 同时等待的任务数 | Go 进程 RSS | Rust/Tokio 进程 RSS | Rust 低多少 |
|---|---|---|---|
| 1 万 | 28.25 MiB | 6.25 MiB | 77.9% |
| 10 万 | 264.25 MiB | 39.25 MiB | 85.1% |
| 50 万 | 1,312.00 MiB | 185.50 MiB | 85.9% |
| 100 万 | 2,619.80 MiB | 368.50 MiB | 85.9% |

RSS 是整个进程驻留在物理内存中的大小,不是虚拟地址空间,也不是单独的栈或堆。点表示各次独立进程的结果,条形表示中位数;四个面板刻度独立,均从零开始,越低越好。1 GiB = 1,024 MiB。
100 万任务时,两端的三次结果分别落在约 2,619.10~2,620.82 MiB,以及 368.50~368.75 MiB。差距在本轮重复中方向一致。
原始记录还保留了创建任务前的基线。将每次等待 RSS 减去对应基线,再按任务数平均,100 万档约为 Go 2,745 字节/任务、Rust 384 字节/任务。
这里先对每个进程减去基线,再取三个独立进程的中位数,最后除以任务数。这个平均数包含分配器、调度、等待结构等开销,不能当成语言保证的固定任务大小。
无栈的优势,落在大量等待任务的常驻成本上
这组程序刻意让每个任务保持很少的业务状态。Rust 不需要给每个任务留下一份专属协程栈,Future 加上运行时存储和通知结构,就能表示“我在等什么、接下来做什么”。
Go 端的运行时指标提供了一个补充观察:100 万任务时,StackInuse 中位数约为 1,953.47 MiB,接近 100 万份 2 KiB 栈的量级。它与栈成本有关,但不是 Linux RSS 的可直接相减分项,不能据此做精确的跨运行时内存归因。
从 1 万到 100 万任务,Rust 的等待态 RSS 一直更低。对大量空闲连接、等待通知的工作项、许多异步状态同时存在的应用,这是一项值得在真实负载里继续验证的优势。
不过,100 万个等待任务还不是 100 万个网络连接。连接的 socket、协议、TLS、读写缓冲和内核开销,这次没有测。程序也没有业务对象,不能把这张表直接当成生产服务容量。
Future 可以很小,也能被跨等待的数据撑大
无栈给了编译器按状态保存数据的空间,但不会消除必须保留的数据。
测试程序附带了一个布局观察:一个持有数组、等待通知、醒来后仍使用数组的异步函数,数组为 64 字节时,Future 大小为 152 字节;数组换成 64 KiB 时,Future 大小变为 65,624 字节。
这只说明该示例在本次编译条件下的 Future 布局,不是完整 Tokio task 大小。它也提示了一个实际优化方向:减少跨 await 保留的业务数据,或者缩短大对象的存活范围。需要大缓冲时,仍要支付相应存储成本。
另外,Rust async 的调度需要代码配合。poll 应快速返回;直接执行长时间阻塞操作或长计算,会占住执行它的工作线程。Pending 后通常由 Waker 通知再推进,不是不断忙等所有 Future。相关约定见:https://doc.rust-lang.org/std/future/trait.Future.html。
这轮具体怎么跑
环境为 Ryzen 9 7950X、WSL2 Linux 6.6.87.2、x86-64;Go 1.26.1、rustc 1.97.1、Tokio 1.53.2。Go 为普通优化构建,Rust 为 release 构建。
两端都绑定逻辑 CPU 0~3。Go 设置 GOMAXPROCS=4,Tokio 设置 4 个 worker;Go 使用 GOGC=100、不设置内存软上限,没有强制 GC 或手动归还内存,也没有人为设置进程内存上限。
共 4 档规模、2 种实现、每组 3 次,得到 24 个正式样本。它们都核对了开始与完成计数。Go 在采样时的 goroutine 数均为任务数加主 goroutine;Rust 所有任务均登记等待,并在广播后完成。
Go channel 与 Tokio Notify 的具体实现不同。分配器、GC、任务元数据与等待队列都会影响进程 RSS;这份测试比较的是两套可运行实现,不是只测语言语法或协程栈。WSL2 和宿主机调度也会影响结果,跨机器需要重新验证。
原始结果:
源码、锁定依赖与复现脚本:
解压后在已有相应 Go、Rust 工具链的 Linux 环境运行 python3 run.py。脚本默认 Go 路径为 /opt/go1.26.1/bin/go,离线 Cargo 构建要求依赖已经缓存;更换机器时需按实际路径调整。报告包含源码与二进制哈希,便于核对。
关于 Future 的调用关系与状态保存,站内详细解释见 《Rust 无栈协程如何暂停和恢复?》。
云服务器推荐
如果你需要部署博客、Docker 服务或个人项目,可以参考:
本文包含推广链接。通过链接购买不会增加你的费用,但我可能获得少量佣金。
评论