异步 Rust:调度器位于何处?
许多关于异步Rust的抱怨,最终其实是源于棘手的并发问题,而非语法本身。这篇文章探讨调度器究竟位于何处,以及为何这一位置决定了哪些问题可以被解决。
中文
复制

async Rust 的某些问题,说到底只是棘手的并发问题向下传导的结果。
作者:Mond
DnB 音乐税:[🎵 Dom Valentino / I Am The Black Gold Of The Sun
🎵](https://www.youtube.com/watch?v=gnksArfIxiU)
认识论状态:本来是想写成一篇吐槽的,我发誓。相当有把握。
最初的想法是用一份简短的清单开头,列出人们对 async Rust 的各种抱怨。就是那种你在网上经常见到的,无论有没有道理。
然后我意识到,光是拿 async Rust 开涮,素材就足够填满一整张 bingo 卡,而且这个格式会好笑得多。下次再看到有人为 async Rust 吵架,看看你能不能凑成一条 bingo!

一张 5×5 的 bingo 卡,汇集了关于 async Rust 的常见抱怨。切换原图/抖动图
如果我漏了什么,告诉我!!
如果有人聊到 Zig 的 async 方案,额外加分。
既然你在读这篇文章,那你(大概率)已经听过其中一些了。至于其余的,我整理了一份下拉式的推荐阅读材料,应该能帮你补上背景。花一个下午读读挺好,但绝不是读这篇文章的必读内容。
Async Rust Bingo 速查表
注意: 这份清单按网格阅读顺序排列。
-
Scoped task trilemma:大致来说,并发、并行、借用这三者只能选两个。这是 leakpocalypse 的下游后果。tl;dr:内存可能泄漏,所以你无法保证析构函数一定运行。结果就是,最初的 scoped threads API 允许制造数据竞争,非常糟糕。
-
函数着色:最初出自 Bob Nystrom 那篇经典文章。这个话题已经被写烂了,比如 Without Boats 的看法。讨论函数着色的文章多到我都懒得列了,而且大家对函数着色到底指什么也经常各执一词。
-
Async Closures:基本上,async 闭包花了大概 6 年才落地。简短总结是“async 闭包需要是 lending 的,因为它们返回的 future 可能借用闭包捕获的变量”,见这篇文章。如果你在这上面钻研太久,可能会听到有人嘟囔高阶类型,做好心理准备。
-
Tokio 默认主义:认为 Tokio 是唯一重要的 runtime。如果有人没意识到自己说的东西只适用于 Tokio、不适用于整个 async Rust,额外加分。概览见 Corrode.dev 的这篇博客文章。
-
“直接用线程(tm)”:认为“几乎没人需要 async”,大多数人应该“直接用一堆线程”。这种论调最常见的变体是:只有当你写的服务器要同时支撑 1 万以上连接时,async 才有必要,否则你“直接用线程池就行”。
-
复杂的编译器错误/堆栈跟踪:这是关于 async Rust 最常见的抱怨之一。
-
Async Drop:由于 Rust
Droptrait 的性质,所有析构函数都是同步的。问题在于,我们有时必须执行阻塞式的清理操作。在异步上下文中,我们希望能够让出控制权,从而不阻塞任何线程。 -
Future 是惰性的:在 JavaScript 里,创建出来的 Future 会立刻开始执行(那边管它叫 Promise,不过嘘)。在 Rust 里完全不是这样!一个 future“只不过”是个 struct。你必须对它调用
.await(或者 spawn),它才会向前推进,否则它根本不会动。忘了这么做是新手常犯的错误。 -
“Rust 曾经有绿色线程”/有栈协程:绿色线程是一种由 Rust 自带运行时的模型。可以想想 Go 的 goroutine。移除它们的 RFC 在这里。Rust 创造者 Graydon Hoare 对自己早年 Rust 愿景的回顾也很有启发。(存档链接。)
-
Future 并非运行时无关:任务 spawn 依赖于运行时,并且内置了各种假设。有些事情被写死进了 trait 里。做错了,就会得到这样的错误。另见这篇关于编写可跨运行时使用的库的讨论。一般来说,人们谈到“生态分裂”时,指的就是这类问题之一。另一个问题是,async 把整个 Rust 生态分成了异步库和非异步库。
-
select!/取消安全性:见下面这份整理得很好的演讲文字稿,《Cancelling Async Rust》。Tokio 的select!宏尤其臭名昭著,所以它的文档专门有一整节讲取消安全性。在select!分支里不小心把 future drop 掉,大概算是 async Rust 各种坑里的头号典型。 -
Send + Sync + 'staticbound 泛滥:比如 tokio 文档里就讲了Sendbound 之类的东西是怎么一点点渗进程序里的。人们抱怨不得不给所有东西都标注这些 bound。 -
Futurelock:“一种死锁,其中 Future
A持有的资源是另一个 FutureB继续推进所必需的,而负责这两个 Future 的 Task 已经不再 pollA了。”别让我解释这东西! -
effect system/keyword generics/maybe async:见 keyword generics 倡议。这里的想法是,与其分别写同步和异步两个版本的函数,你只写一个“maybe async”函数。然后编译器在底层生成两个版本(类似泛型的工作方式),并根据调用上下文分派正确的那一个。我一直对此持怀疑态度。
-
APIT、RPIT、TAIT、RPITIT:解释见这里。这些术语都指在以前无法使用
impl Trait的各个位置使用它的能力。大部分都容易查到,但也相当技术化。 -
dyn compatibility、
Pin<Box<dyn Future<Output = T> + Send>>:进行动态分派的能力。受限于一些限制,这可能需要进行装箱,有点令人沮丧,也会产生这些冗长到滑稽的类型。 -
io_uring:
io_uring是 Linux 内核在 2019 年引入的异步 I/O 接口。它的性能优于旧的epoll(至少需要的系统调用更少)。在io_uringAPI 中,内核会持有你的缓冲区,直到操作完成,这与 future 可被 drop 取消的机制相冲突。要在 Tokio 中解决这个问题,就得搬移缓冲区,而不是向它们传递&mut引用。这需要重做 Tokio 的 API。tokio-uring的设计见这里。据我所知,Tokio 仍未完全支持io_uring,部分原因是 API 兼容性保证。其他一些运行时已经支持了。 -
“其实,async 对嵌入式领域很重要”:Embassy是嵌入式 async Rust executor 的典型例子。另见 Without Boats 的 Why Async Rust。有一点很重要:Rust 不能内置运行时或绿色线程,因为这与 Rust 用于嵌入式设备的场景相冲突。这个观点极其站得住脚,但人们在讨论 async Rust 时提起嵌入式编程,本身几乎已经成了一个梗。
-
Box::pin(future)撑爆栈:Future 是一个状态机,描述某些 async 函数可能处于的所有状态。如果这些 async 函数嵌套得很深,就可能撑爆栈。如果你试图把它放进Box,然后撞上缺少就地堆分配构造的问题,那就更好笑了。例子见这里。
-
thread per core vs. work stealing:例子见这篇文章。Tokio 采用 work stealing,多年来引发了不少讨论。work stealing = 任务可以从一个 CPU 核移动到另一个核,确保每个线程始终有活干,但要求所有任务都有
Send约束。也有一些替代运行时,比如 Glommio,出于各种原因放弃了这种模型。见 Without Boats 对上文的回应。 -
“标准库应该自带一个 executor”:大致是说 Rust 应该自带某种运行时(比如 Tokio,或者一个超级原始的 executor)。显而易见的问题是,不同的 executor 有真实的需求,往标准库里塞一个会与 Rust 提供精简标准库的目标相冲突。更值得同情的说法是,Rust 标准库应该(以某种方式)标准化生成任务的 API,并朝着确保不同运行时能良好协作的方向发展。要做到这一点很难,因为运行时的预期各不相同:work stealing 的运行时(比如 Tokio)要求
Send约束,因为 Future 可能在线程之间传递,而 thread per core 的运行时没有这个限制。 -
生成器 / yield / AsyncIterator / 协程:参见这里。这些仍然不稳定,也不是 2026 年的优先事项。据我所知,关于应该用哪个确切的 trait 来编码异步迭代器这个概念,存在很大的分歧。而且,你还会碰上 lending iterator 的问题。简而言之,这个范围超出了 Python 那种
yield关键字块。 -
spawn_blocking:大体上,就是“如何在不给程序惹麻烦的前提下派发一个阻塞任务?”这整个话题。具体来说,这个坑在于:如果你不小心阻塞了一个 Tokio worker 线程,你的程序可能还会继续勉强运行(因为有 work-stealing,那个 worker 线程剩下的工作会被其他线程偷走),但你的 worker 池现在少了一整个 worker 线程,只要那次调用还在阻塞就一直如此。只要流量低,这真的很难被注意到。
-
Pin 问题 + Move trait:关于能有一个像样的
Movetrait、而不必去应付Pin该有多好,已经写了太多太多。一如既往,读一读 Without Boats 的文章来理解Pin。
问题出在哪?
async Rust 让我不爽的一点是,它需要一个运行时。
为什么?为什么它非得要一个运行时?Rust 里其他东西都不需要运行时 1。Rust 唯一引以为傲的,就是它能在不需要运行时(比如垃圾回收器)的前提下给你内存安全。退回到基于运行时的模型,感觉像是一种倒退。
一旦有了运行时,你就把自己交到了一个弥散各处(而且可能完全看不懂)的优先级和调度机制手里。你不再直接运行自己的函数,而是把它们交给一个引擎,由它按照自己的规则替你运行——而不是像上帝希望的那样,一步一步走完代码,真是的。
更糟的还在后面!调度算法必然是一个优化问题,你必须在一个权衡边界上挑一个具体的点!根本不存在放之四海而皆准的方案!多让人抓狂!如果我还是得像调 Java 垃圾回收器那样去调 Tokio 运行时,只为了多榨出一点性能,那还怎么谈 Rust 的性能是世界级的?
这正是我想摆脱的东西!
所以。
读到这里还没关掉标签页去给我写一封措辞严厉的邮件的人,恭喜你!
简而言之,上面那一通抱怨全是胡扯。
让我解释一下。
这些问题(感觉像运行时、调度器的优化权衡、复杂性)都是并发代码的普遍问题。想让代码并发运行,你就需要一个运行时来处理调度。这几乎是并发定义的一部分。
如果你完全不用 async Rust,改用线程,唯一的变化就是:现在内核成了你的调度器,而不是 Tokio。你依然有一个调度器,依然有全部(好吧,至少是部分)同样的问题!
换句话说,别因为 Rust 把复杂性暴露给你这个用户就怪它。Rust 是一门底层语言(同时拥有高层抽象,以及追求高性能的野心)。暴露这种复杂性正是它的本职。Rust 没法替你选调度器,因为不存在完美的调度器,而不同的调度器适用于不同的场景。
Go 这类语言就轻松多了:没人指望 Go 有裸金属级的性能。大家都明白这里存在取舍——为了便利牺牲一部分性能,正因如此 Go 才有 goroutines(tm),也就是绿色线程。
这只谦逊的 Gopher 安分守己,不慌不忙,滋润地活着,欣欣向荣,并且永远幸福地不知道 sum types 和模式匹配在召唤它。
和 Go 不同,Rust 的目标要高一些。
题外话:async Rust 的性能
顺带说一句,有一种看法认为 async Rust 已经“好到不能再好了”,说不清具体好在哪里,但就是这么个模糊的印象。事实并非如此。当然,“好到不能再好了”这种说法太含糊、太不明确,谈不上真假,但这里至少有一个真正的误解。(先不提“根本不存在完美调度器”这个误解。)
举个例子:一个常见的说法是,Rust 的 future 会编译成一个“大小最优的状态机,刚好大到能装下所有数据”。我猜这个说法源自 Aaron Turon 的《Zero-cost futures in Rust》和 Without Boats 的《Why Async Rust?》,之后便自行流传开来。想法是这样没错,但存在一堆长期未解决的问题,比如参数如果跨 yield 点持有,开销会翻倍:
async fn test(arg: [u8; 8192]) {
wait().await;
drop(arg);
}
async fn wait() {}
fn main() {
// Expected: 8194 == 2 + 8192
// Actual: 16386 == 2 + 2 * 8192
println!("{}", std::mem::size_of_val(&test([0; 8192])));
}
链接到 Rust Playground。有趣的事实:把 drop(arg); 调用删掉,看看会发生什么。
从外部看,这种开销是“本应如此”的两倍、而且还没被优化掉,实在荒谬。更瘆人的是,如果把 future 作为参数传递,体积膨胀是指数级的。(感谢 Ding,他一直在英勇地战斗,想彻底解决这个问题。见这个讨论串。)
那么,问题出在哪?
第一,并发(无论以何种形式)是必需的。
第二,并发总是需要一个运行时。没有运行时就不可能有并发。运行时住在哪里,栈又由谁管理?
第三,如果你想避开运行时,最好的办法就是自己写一个事件循环(恭喜,现在你得自己维护一套运行时了,干得漂亮),或者“直接用线程”(恭喜,现在你把内核当运行时用了,还得应付内核线程——它们比 Tokio 的专用任务重了不知道多少个数量级,干得漂亮)。
第四,你没法像 Go 那样“自带运行时”,除非让 Rust 更难嵌入,并在 FFI 边界上付出代价。(说真的,去读那篇文章就明白为什么了。关于移除 Rust 最初 green threading 的理由,见 RFC 230。)
于是第五,在所有选项都试过之后,我们落到了今天这个局面:运行时就是一个 crate。你把它拉进来,初始化,也许再到处传一传,然后用它来调度你的 future。
好极了。
我们重新发明了一遍最开始就有的东西。
我花了很多时间梳理这里涉及的设计决策,尽力去挑 async Rust 的毛病,结果每一步都变成“哦。嗯。这挺合理的。说得通。我能理解他们为什么这么做。”
我仍然不知道是否存在某种尚未被发现的抽象,能神奇地让 async Rust 的一半问题凭空消失,但我倾向于认为没有:其中很多都是通用的并发问题。调度器总得有个地方待着,而 Rust 不能拿性能去换便利。
把这些拆成两类是合理的:一类是并发本身的问题,另一类是“调度器住在 crate 里”这个决定带来的问题。
crate 这件事就是 Tokio 默认主义和运行时不可知性缺失的根源。也是你必须应付 Send + 'static bound 的原因。Send bound 会在泛型代码里扩散,因为语言本身无法知道你的运行时会不会在线程之间移动任务。
从很多方面看,让调度器住在库代码里没什么问题。对 Rust 来说,这是正确的取舍。
有一点很重要:放弃 green threads 这个决定,实际上确立了 Rust 的一条硬性设计原则,而当时这条原则还处于摇摆之中:有一段时间,并不清楚 Rust 会不会走上替代 C++ 的路线——能用在嵌入式 no_std 环境里,并且具备“零成本抽象”。
事后看,这个方向选对了。它让 Rust 在 C#、D、Swift 旁边站得住脚:一门真正能替代 C++、又保证内存安全的语言。没有“如果”“但是”,没有趁人不注意从后门偷偷塞进垃圾回收或引用计数的尝试。裸金属,C++ 能做的它都能做,而且内存安全。
我们不知道如果 Rust 当初押注绿色线程,那个平行世界会是什么样,但说实话,我不认为那会是一个 Rust 进入 Linux 内核的世界。无论从技术还是政治层面看,都不太可能。
那还剩下什么?
回到我真正的不满。
用户级线程?
我们在自己的编程语言内部花了多少时间重新发明、反复争论新的运行时(比如 Tokio 或 Go 的),这件事蠢得离谱。
说个有争议的观点:也许我们应该“干脆”想办法修好内核线程和调度,或者引入一种新的内核线程类型,让“直接用线程”突然变得高效,于是我们就能“直接”把代码多线程化?
上面第三步的前提是内核线程很贵,迁到内核线程会损失些什么。在理想世界里,理应不必如此。我不管怎么做,给内核配一个便宜得可笑的、类似 Go 的调度器,配上廉价而简单的线程。
你也许认为这不可能,但我们所在的抽象层级更低,离内核更近。换句话说,束缚我们的限制更少,而不是更多。Goroutine 是跑在内核之上的抽象。如果能让 goroutine 高效、廉价、快速,那内核也应该能支持某种高效、廉价、快速的线程方案。
显然可以,人们已经试过几次了,通常叫“M:N 用户级线程”之类的名字。我今天没精力去研究这个,但搞清楚哪些类型的程序能从中受益、限制又在哪里,是个有意思的问题。
实践中,内核对你代码的了解永远不可能比语言专属的运行时更多。这限制了它调度线程的能力,也基本意味着语言专属运行时几乎总会胜出——除非你能想办法把内核需要的那些额外数据全都交给它。
无论哪种情况,即便在理想状态下,这也不过是让我们回到那个人人都用线程做一切的世界……只是性能更高了。这样更好吗?我不知道。看法可能因人而异。
Tokio
哦,趁我还没忘:这算是另一处不满,Tokio 那种隐式的环境执行器模型有点让人恼火,也损害了 Rust 黄金法则的精神 2。
一个 spawn 出来的 Tokio 任务会魔法般地把自己挂到一个环境里隐现的线程局部上下文/执行器上,如果这样的执行器不存在,就会以运行时 panic 告终。示例:
fn record_metric(_value: u64) {
tokio::spawn(async move {
// do stuff
});
}
fn checksum(data: &[u8]) -> u64 {
let sum = data.iter().map(|&b| b as u64).sum();
record_metric(sum);
sum
}
#[tokio::main]
async fn main() {
let data = vec![1u8; 1024];
checksum(&data); // fine
let (tx, rx) = tokio::sync::oneshot::channel();
rayon::spawn(move || {
let _ = tx.send(checksum(&data)); // panics, process abort
});
println!("{:?}", rx.await);
}
实践中,这就好像有一个“隐藏参数”在你所有的 Tokio 函数之间传来传去。只不过一旦你忘了念那句咒语(也就是试图在 Tokio 运行时之外使用 Tokio),就会得到一个运行时错误。副作用!隐藏的全局式变量!隐藏的动态作用域!糟糕!3
在“理想世界”里(除了我这种变态,很多人会讨厌它),你不把执行器一路穿过代码传到 spawn 任务的地方,就无法 spawn 任务。
如果人们真的想在不传递的情况下拿到 Tokio 执行器,它就应该放在一个全局变量里,并且在调用点从那里取。你要的是这种可 grep 的性质,而不是把它藏起来。
Zig
(这里我要领取我的“讨论 Zig”宾果加分。)
至于这种原则性做法在实践中长什么样,可以看看 Zig:你把一个 I/O 接口传给所有需要它的函数。异步性和 I/O 于是成为你所传入的那个具体实现里固有的属性。
const std = @import("std");
const Io = std.Io;
fn saveData(io: Io, data: []const u8) !void {
const file = try Io.Dir.cwd().createFile(io, "save.txt", .{});
defer file.close(io);
try file.writeAll(io, data);
const out: Io.File = .stdout();
try out.writeAll(io, "save complete");
}
Loris Cro 关于这个话题的文章里有个例子。
I/O 接口为 runtime 定义了契约。
如果所有人都严格地把这个接口作为参数传递,原则上你就能得到三个神奇的好处:
- 你可以精确追踪阻塞操作可能发生的位置。
- 你避开了 function coloring 4。它只是一个函数参数。
- 所有库代码都对 executor 或 scheduler 无感知:你可以把 I/O 接口的不同实现传进代码里。生态不会分裂。
回到标题提出的问题:把 scheduler 放进一个到处传递的参数里,感觉是对的。啰嗦是啰嗦,但它解决了一大堆问题。好家伙,bingo 卡上大概三分之一的问题都能划掉了。
我希望 scheduler 就待在这个位置。不在内核里,不在什么隐式的执行上下文里,就把它做成一个可以当参数传递、按需替换的东西。
话虽如此,Zig 还没稳定,而 async 非常难做对。换句话说,这条路还没有经过实战检验。
如果 Zig 团队能把它做成,那我确信,Rust 朝这个方向演进的实验值得一做。
全面转向意味着要重写 Rust 标准库的 API(比如 std::fs),所以这几乎肯定不在考虑范围内,但 crate 生态不受这个约束。也许有戏。
祝 Zig 团队在这个激动人心的时期取得他们应得的成功。
如果你读到了这里,不妨订阅邮件通知,或者把本博客加入你的 RSS 阅读器。
- 评论区肯定至少会有一个人指出,Rust 的
unwind/ panic 栈也算一种 runtime。他们说得对,但还是该被弹内裤。 ↩︎ - 我知道,我知道。Rust 没有 effect system。它连代码是否会 panic 都追踪不了,更别提追踪副作用了。我这是在要求太多,但我不喜欢它不在编译期追踪这些。 ↩︎
- 没错,Tokio 有
Handle::spawn,但隐式形式才是默认的。 ↩︎ - 更准确地说,function coloring 被编码成了传给函数的参数,而不是某种特殊的语言特性。这算不算 function coloring,人们永远吵不出共识。 ↩︎