Tokio/Rayon 陷阱:为何 async/await 在并发中失灵
摘要
本文探讨了 async/await 语法虽然易于编写,但在生产环境中因将异步与并发混为一谈而引入了巨大复杂性,常需手动将 I/O 与计算任务分配到 Tokio 和 Rayon 等不同运行时,导致延迟飙升与系统不稳定。
暂无内容
查看缓存全文
缓存时间: 2026/07/16 04:47
# Tokio/Rayon陷阱:为什么async/await无法应对并发
来源:https://pmbanugo.me/blog/why-async-await-complect-concurrency
过去十年中,`async/await` 之所以在并发战争中胜出,是因为它异常*易用*。它让开发者编写的异步代码看起来几乎和同步代码一模一样。
但在这种熟悉语法之下,隐藏着巨大的结构性复杂。它隐藏了控制流,掩盖了硬件现实,最终将调度的负担重新推给了开发者。
Rich Hickey 在他的演讲 *Simple Made Easy* 中完美地阐述了这一点:“易用”是指熟悉且触手可及的东西,而“简单”是指结构上没有纠缠不清的东西\[1\]。`async/await` 写起来易用,但跑起来却极其复杂。
Rob Pike 在 2023 年 GopherConAU 的演讲中谈到了这种架构转变:
> 与 goroutine、channel 和 select 相比,async/await 对语言实现者来说更简单、更轻量……但它把一部分复杂性推回给了程序员,往往导致 Bob Nystrom 所说的“有色函数”问题。[……] 但有一点很重要,无论你提供哪种并发模型,只提供一种就好,因为一个环境中有多个并发实现可能会带来麻烦。\[2\]
Pike 关于“*多个并发实现*”和 `async/await` 的评论,恰恰正是今天在生产环境中失败的原因。
### 生产陷阱:混淆异步与并发
`async/await` 的根本陷阱在于它把**异步**(等待 I/O 时让出)与**并发**(同时处理多件事情)混为一谈。
这个语法是一个陷阱,因为它把交织的状态机伪装成了孤立的顺序线程。在这种幻象的引诱下,开发者会写出一个 `async` 函数,就像写阻塞代码一样——通过网络获取数据库记录,然后立即处理数据。但是,当数据处理涉及解析 10MB 的 JSON 载荷、遍历一个巨大的集合,或者执行计算密集型的密码学证明时,会发生什么?
**协作式执行器会停转。**
```
期望:快速 I/O
任务快速让出。高吞吐量。
传入网络流量 → 任务队列(稳定/低) → 执行器(1个OS线程) → I/O 让出 → I/O 让出
现实:计算陷阱
一个CPU任务阻塞了执行器。队列爆炸。
传入网络流量 → 任务队列(无界) → OOM → 崩溃
执行器(1个OS线程) → 重计算任务(bcrypt,解析等) → 线程停转
```
在 Rust 的 Tokio 或 Node.js 这样的协作式运行时中,线程只有遇到 `await` 点才会让出。一个 50 毫秒的 CPU 密集型任务会阻塞整个执行线程。突然,成千上万个无关网络请求的延迟飙升,系统变得无响应。而硬件根本就没有被充分利用。
### 破灭的承诺:人肉调度器
当这些延迟尖峰出现时,答案总是一样的:分开运行时。用 Tokio 处理 I/O,把 CPU 密集型工作发送到像 Rayon 这样的专用线程池。
最近的故障报告凸显了由此引发的灾难。PostHog\[3\] 和 Meilisearch\[4\] 的工程团队都记录了在生产环境中解开这些复杂性的痛苦现实。开发者必须仔细分析每一个函数,以决定它属于“I/O 池”还是“计算池”,然后手动编排它们之间的消息传递边界。
如果一个开发者必须手动划分 I/O 和计算,严格监控边界以防止死锁,并在两种具有不同思维模型的运行时之间搬运数据,那么 **`async` 抽象已经失败了**。这个语言特性承诺隐藏并发的复杂性,反而把应用开发者变成了*人肉调度器*。
### 默认无界 = 默认 OOM
`async/await` 运行时的第二个失效模式是它们让无界容量变得毫无阻力。
调用 `tokio::spawn(...)` 很廉价。当下游数据库在流量高峰时变慢,入口网络循环会高兴地继续接受连接并生成任务。由于在这些生态系统中,异步任务和内存分配通常默认是无界的,系统不会施加反压。
正在处理中的任务会无限排队。应用消耗 RAM,直到操作系统的 OOM(内存不足)杀手暴力终止进程。来自各大平台的故障报告反复揭示相同的根本原因:**队列并不能解决过载,它们只是把崩溃推迟,同时让崩溃变得更具灾难性**。无限容量是一个谎言,而默认假装不是这样的默认值非常危险。
### 工作窃取的神话
当系统遇到这些瓶颈时,开发者常常要求更智能的、抢占式的工作窃取调度器来分配负载。假设是,如果一个核心空闲,它应该从忙碌的核心*窃取*任务以保证公平。但在大规模场景下,**公平是吞吐量的敌人**。工作窃取会破坏 CPU 缓存局部性。
当 WhatsApp 将 Erlang BEAM 虚拟机的性能推到 100 核以上机器的极限时,系统开始卡顿。正如 Robin Morisset 所详述的那样,空闲线程试图窃取工作,结果把所有 CPU 周期都花在了争夺全局 `runq_lock` 上\[5\]——这是用于同步调度器运行队列访问的锁。
```
工作窃取的代价:缓存局部性损失
CPU Core 1 过载
L1/L2 缓存(热)
Task A 状态数据
Task B 状态数据
CPU Core 2 空闲
L1/L2 缓存(冷)
这里没有 Task B 的状态
主内存(DRAM)所有核心共享
Task B
1. Core 2 “窃取” Task B
2. 缓存未命中!
3. 约100ns 的RAM读取惩罚
```
即使有了优化后的锁,将状态机移动到不同的 CPU 核心也意味着放弃 L1 和 L2 缓存。如果每个被窃取的任务都要承受 100 纳秒以上的主内存读取惩罚,那么公平性就毫无意义。如果你为了在生产中存活,已经被迫手动划分 I/O 与 CPU 任务的线程,那么通用的工作窃取算法已经让你失望了。你比你运行的运行时更了解你的工作负载拓扑。
### 替代方案
我厌倦了 `async/await` 的复杂性和陷阱。我想要 BEAM 那样坚固耐错的容错性,但又不想要垃圾回收和全局工作窃取这些不透明的操作。正如 Leslie Lamport 长久以来所主张的,状态机是并发编程的数学上合理的基础。`async/await` 只是试图隐藏状态机的编译器魔法,而且隐藏得很糟糕。
与其隐藏状态机,为什么不将它暴露出来并给用户更好的控制原语?结果是 **Project Tina**:一个固执己见的、无共享、每个核心一个线程的并发框架。
```
Tina:每个核心一个线程(无共享)
无工作窃取 • 无互斥锁 • 严格的缓存局部性
分片 0 (Core 0)
1 个 OS 线程
调度循环
隔离区 A (TCP 连接)
隔离区 B (HTTP)
隔离区 C (Worker)
↻ 严格的时间片序列
分片 1 (Core 1)
1 个 OS 线程
调度循环
隔离区 X (路由器)
隔离区 Y (TCP 连接)
隔离区 Z (Worker)
↻ 严格的时间片序列
邮箱(无锁)
邮箱(无锁)
无共享内存
```
Tina 采用严格的约束以保证巨大的吞吐量和可靠性:
1. **一个原语。一种思维模型。**没有 `async` 或 `await`,没有 Promise,没有 Future。你编写一个 Isolate —— 一个并发工作单元。处理函数是一个标准的同步函数,响应消息并返回一个 *Effect*。
2. **每个核心一个线程(无共享)。**Tina 将工作负载分片到 OS 线程上。没有工作窃取。Isolate 从不迁移。所有跨核心通信都通过消息传递子系统进行。
3. **严格有界。**内存在进程启动时预分配。邮箱严格有界。如果流量高峰到来而邮箱已满,调用者会立即收到通知。系统可预测地卸载负载,而不是导致进程 OOM 崩溃。
4. **架构确定性。**在现代化的异步运行时中,任务轮询顺序和线程池调度是不透明的、非确定性的混乱根源。你很少确切知道你的任务*何时*或*何处*会被唤醒。Tina 去掉了这些。调度器是每个核心上一个严格的、可见的、单线程循环。由于框架显式控制执行顺序、I/O 和时钟,系统的行为极其可预测。这开启了 Tina 的终极超能力:**确定性模拟测试**。你可以在单线程上模拟网络分区或消息丢失,相同的种子将每次产生完全相同的执行顺序。
### 总结
`async/await` 让并发*易于*编写,但却让系统*难以*运维。通过强制开发者显式管理状态转换、严格的内存边界以及深思熟虑的架构拓扑,我用结构性保证取代了运行时魔法。因为:
**可预测性胜过简洁性。**
Tina 是开源的。你可以在 GitHub 上查看架构、阅读设计文档以及审阅代码。
注释与参考文献
1. Rich Hickey, “Simple Made Easy”, *Strange Loop 2011*. [↩](https://youtu.be/SxdOUGdseq4?si=8O_xbRkZDMQNTIld)
2. Rob Pike, “What We Got Right, What We Got Wrong”, *GopherConAU 2023*. [↩](https://youtu.be/yE5Tpp2BSGw?si=ZSUBaS_IskXuPTKJ&t=690)
3. PostHog Engineering, “Untangling Rayon and Tokio”, *posthog.com/blog*. [↩](https://posthog.com/blog/untangling-rayon-and-tokio)
4. Louis Dureuill, “Don’t mix Rayon and Tokio”, *blog.dureuill.net*. [↩](https://blog.dureuill.net/articles/dont-mix-rayon-tokio/)
5. Robin Morisset, “Optimizing the BEAM’s Scheduler for Many-Core Machines”, *Code BEAM Europe*. [↩](https://youtu.be/tC435RGwRCI?si=cIJHtxV7eRDeXuxs)
相似文章
谁在运行你的 Rust Future?动手实践入门异步 Rust
这是一套动手实践教程系列,旨在弥合理解异步 Rust 内部机制(Future、poll、Pin、执行器)与使用 Tokio 部署实际异步代码之间的差距,面向熟悉 JavaScript 异步和基础 Rust 的开发者。
如果C#和JavaScript允许我多次等待Windows Runtime异步操作,为什么C++/WinRT不行?
Raymond Chen解释了为什么C++/WinRT不像C#、JavaScript和Python那样允许多次等待异步操作,其原因是没有标准库的task类型,以及不为你未使用的功能付费的原则。
为3DS构建AsyncIO执行器
本文介绍了在Nintendo 3DS上进行异步编程的必要性,因为其采用协作式多任务处理,并开始解释如何为其构建一个asyncio执行器,重点讨论了Rust中的任务、未来、唤醒器和执行器这些概念。
在多个协程之间共享单个Windows Runtime IAsyncOperation的结果,第3部分
本文讨论了一个C++/WinRT模式,用于缓存Windows Runtime IAsyncOperation的结果,包括处理失败的情况,以便多个协程可以共享缓存的结果或异常。
异步编程的承诺与现实
深入剖析异步编程模型的演进——从回调到 Promise——揭示每一轮迭代如何解决先前的资源与性能问题,同时带来新的易用性挑战。