100 万个任务同时等待,Rust 比 Go 少用 86% 内存

0 阅读7分钟

同样创建 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 线程轻很多,但同时保留大量任务时,这部分成本仍会累积。固定版本源码见:raw.githubusercontent.com/golang/go/g…。

Rust 的写法可以是:

tokio::spawn(async move {
    gate.notified().await;
    // 收到信号后继续工作
});

async 产生一个实现 Future 的状态对象。执行器调用它的 poll;暂时不能继续时,返回 Pending。跨过等待点仍要使用的数据,保存在 Future 中。Rust Reference 用“每个等待点对应一种状态”的形式解释这种表示,具体布局没有稳定承诺:doc.rust-lang.org/reference/e…。

于是,两边都能让线程去执行其他工作,等待现场的保存方式却不同。

任务正在等待时Go goroutineRust async Future
进度主要保存在哪里专属调用栈与运行时上下文Future 的状态、字段与子 Future
下一次怎样继续恢复 goroutine 的执行现场再次调用 poll,根据状态推进
每个等待任务是否要保留专属协程栈需要不需要

无栈省掉的是每份等待计算的专属协程栈。 执行 poll 时仍然会使用工作线程的普通调用栈;Tokio 的任务对象、唤醒信息和等待结构也都需要内存。

Future 与独立 task 还要分开。直接在一个 async 函数里 .await 子 Future,会沿父子调用关系推进,不等于为每个等待点都创建一个独立调度任务。相关语义见:doc.rust-lang.org/reference/e…。本轮仍为每份工作创建独立 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 进程 RSSRust/Tokio 进程 RSSRust 低多少
1 万28.25 MiB6.25 MiB77.9%
10 万264.25 MiB39.25 MiB85.1%
50 万1,312.00 MiB185.50 MiB85.9%
100 万2,619.80 MiB368.50 MiB85.9%

Go 与 Rust 在四档等待任务规模下的进程 RSS,中位数与各次独立运行结果

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。相关约定见:doc.rust-lang.org/std/future/…。

这轮具体怎么跑

环境为 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 和宿主机调度也会影响结果,跨机器需要重新验证。

原始结果:

下载 24 个正式样本及环境记录

源码、锁定依赖与复现脚本:

下载基准源码包

解压后在已有相应 Go、Rust 工具链的 Linux 环境运行 python3 run.py。脚本默认 Go 路径为 /opt/go1.26.1/bin/go,离线 Cargo 构建要求依赖已经缓存;更换机器时需按实际路径调整。报告包含源码与二进制哈希,便于核对。

关于 Future 的调用关系与状态保存,站内详细解释见 《Rust 无栈协程如何暂停和恢复?》。


原文链接:blog.bigfisharc.cn/blog/go-rus…