Tokio/Rayon 陷阱:为何 async/await 在并发中失灵

Hacker News Top 新闻

摘要

本文探讨了 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

Hacker News Top

这是一套动手实践教程系列,旨在弥合理解异步 Rust 内部机制(Future、poll、Pin、执行器)与使用 Tokio 部署实际异步代码之间的差距,面向熟悉 JavaScript 异步和基础 Rust 的开发者。

为3DS构建AsyncIO执行器

Lobsters Hottest

本文介绍了在Nintendo 3DS上进行异步编程的必要性,因为其采用协作式多任务处理,并开始解释如何为其构建一个asyncio执行器,重点讨论了Rust中的任务、未来、唤醒器和执行器这些概念。

异步编程的承诺与现实

Hacker News Top

深入剖析异步编程模型的演进——从回调到 Promise——揭示每一轮迭代如何解决先前的资源与性能问题,同时带来新的易用性挑战。