← 返回信息流
AI 资讯Hacker News·3 小时前

Tokio/Rayon陷阱:async/await为何不适合并发

原标题:The Tokio/Rayon Trap and Why Async/Await Fails Concurrency

速览

本文分析了Rust异步编程中Tokio和Rayon库的常见陷阱,解释了async/await在处理CPU密集型并发任务时的局限性,指出其更适合I/O密集型场景,并建议开发者根据任务类型选择合适的并发模型。

AI 深度解读

背景

在过去十年中,async/await 凭借其极高的易用性赢得了并发“战争”——它让开发者写出几乎与同步代码无异的异步代码。然而,这种熟悉语法之下隐藏着巨大的结构复杂性:它掩盖了控制流,模糊了硬件现实,最终将调度负担推回给开发者。Rich Hickey 在演讲《Simple Made Easy》中精准指出:“Easy”是熟悉且触手可及的,而“Simple”是结构上不纠缠的。async/await 写起来“Easy”,但运行起来却异常复杂。Rob Pike 在 2023 年 GopherConAU 上也指出,相比 goroutine、channel 和 select,async/await 对语言实现者来说更简单、更小……但它将部分复杂性推给了程序员,常导致 Bob Nystrom 所称的“有色函数”。Pike 提到“多个并发实现”与 async/await 的结合,正是当今生产环境中失败的关键原因。

核心内容

1. 生产陷阱:混淆异步与并发

async/await 的根本陷阱在于混淆了异步(等待 I/O 时让出)与并发(同时处理多件事)。语法本身是一个陷阱,因为它将交错的有限状态机伪装成孤立的、顺序执行的线程。开发者受此错觉诱导,照搬阻塞式代码的写法编写 async 函数——先通过网络获取数据库记录,然后立即处理数据。但当数据处理涉及解析 10MB JSON 负载、遍历大型集合或执行计算密集的加密证明时,协作式执行器就会停顿。在 Rust 的 Tokio 或 Node.js 这类协作式运行时中,线程直到遇到 await 点才会让出。一个 50 毫秒的 CPU 密集型任务会阻塞整个执行线程,导致数千个不相关的网络请求延迟飙升,系统无响应,而硬件利用率却极低。

2. 破碎的承诺:人类作为循环调度器

当延迟尖峰出现时,标准答案总是:隔离运行时。用 Tokio 处理 I/O,将 CPU 密集型任务发送到专用线程池(如 Rayon)。然而,事件后的复盘报告(如 PostHog 和 Meilisearch 工程团队的文档)揭示了这种做法的灾难性后果。开发者必须仔细分析每个函数,判断它属于“I/O 池”还是“计算池”,然后手动编排它们之间的消息传递边界。如果开发者必须手动划分 I/O 和计算、严格管控边界以防止死锁、并在两个具有不同心智模型的运行时之间搬运数据,那么 async 抽象已经失败。这个语言特性曾承诺隐藏并发复杂性,结果却把应用开发者变成了循环调度器中的“人类”。

3. 无界默认等于 OOM 默认

async/await 运行时的第二个失败模式是它们让无界容量变得毫无阻力。调用 tokio::spawn(...) 非常廉价。当流量高峰导致下游数据库变慢时,入站网络循环仍愉快地接受连接并生成任务。由于在这些生态系统中,异步任务和内存分配默认通常是无界的,系统不会推回压力。正在执行的任务无限排队,应用持续消耗 RAM,直到操作系统 OOM 杀手暴力终止进程。来自主要平台的事后分析一致揭示同一根本原因:队列不解决过载,它们只是延迟崩溃并使崩溃更具灾难性。无限容量是一个谎言,假装不是的默认值很危险。

4. 工作窃取神话

当系统遇到瓶颈时,开发者常要求更智能、抢占式的工作窃取调度器来分发负载。假设是:如果一个核心空闲,它应该从繁忙核心窃取任务以保证公平性。但在大规模下,公平是吞吐量的敌人。工作窃取会破坏 CPU 缓存局部性。当 WhatsApp 将 Erlang BEAM 虚拟机推向 100+ 核心机器极限时,系统陷入困境。如 Robin Morisset 所述,空闲线程试图窃取工作,却将所有 CPU 周期花在争夺全局 runq_lock 上——即使使用优化锁,将状态机移到不同 CPU 核心意味着放弃 L1 和 L2 缓存。如果每个被窃取的任务都要承受 100+ 纳秒的主内存访问惩罚,公平性毫无意义。既然你已经被迫为生产环境手动划分 I/O 与 CPU 线程,那么通用的工作窃取算法已经失败——你比运行时更理解工作负载的拓扑结构。

替代方案:Project Tina

作者厌倦了 async/await 的复杂性和陷阱,希望拥有 BEAM 那样坚固的容错能力,但又不想要垃圾回收和全局工作窃取的模糊操作。受 Leslie Lamport 启发,状态机才是并发的数学基础。与其隐藏状态机,不如暴露它并给用户更好的控制原语。结果就是 Project Tina:一个固执的、无共享、线程每核的并发框架。

Tina 通过严格约束来保证巨大吞吐量和可靠性:

  • 一种原语,一种心智模型:没有 async 或 await,没有 Promise,没有 Future。你编写一个 Isolate(并发工作单元)。处理程序是一个标准的同步函数,它对消息做出反应并返回一个 Effect
  • 线程每核(无共享):Tina 将工作负载分片到 OS 线程上。没有工作窃取。Isolates 从不迁移。所有跨核通信通过消息传递子系统进行。
  • 严格有界:内存在进程启动时预分配。邮箱严格有界。如果流量高峰导致邮箱满,调用者立即得到通知。系统可预测地卸载负载,而不是 OOM 崩溃。
  • 架构确定性:在现代 async 运行时中,任务轮询顺序和线程池调度是不透明的、非确定性的混乱之源。你很少知道任务何时何地会被唤醒。Tina 去除了这一点:每个核心的调度器是一个严格的、可见的单线程循环。由于框架显式控制执行顺序、I/O 和时钟,系统行为极具可预测性。这解锁了 Tina 的终极超能力:确定性模拟测试(DST)。你可以在单线程上模拟网络分区或消息丢失,相同种子将每次产生完全相同的执行顺序。

总结

async/await 让并发易于编写,却使系统难以运行。通过迫使开发者提前显式管理状态转换、严格内存边界和刻意架构拓扑,作者用结构性保证取代了运行时魔法——可预测性胜过简洁性。Tina 是开源的,其架构、设计文档和代码均在 GitHub 上可供审查。

关键要点

  • async/await 最大的陷阱是混淆了异步(I/O 等待时让出)与并发(同时处理多任务),导致开发者写出看似同步的代码,却隐藏了复杂的交错状态机。
  • 协作式运行时(如 Tokio、Node.js)中,一个 CPU 密集型任务会阻塞整个线程,造成延迟尖峰和系统无响应。
  • 解决延迟尖峰的标准做法(将 I/O 和计算分离到不同运行时)迫使开发者手动分区、防范死锁、并在两种心智模型间传输数据,本质上是将调度责任推给人类。
  • 异步运行时默认无界的内存分配和任务生成,导致系统在流量高峰时无法推回压力,最终 OOM 崩溃——队列只是延迟灾难。
  • 大规模场景下,工作窃取调度器因缓存局部性破坏和锁竞争,公平性不如开发者手动分区;开发者比运行时更理解工作负载。
  • 替代方案 Tina 采用线程每核、无共享、严格有界、显式状态机原语,并提供确定性模拟测试(DST)来保证可预测行为。
  • 核心哲学:可预测性胜过简洁性。

意义与影响

本文对 async/await 的批判直指当前主流并发模型的根本矛盾:语法“易用”与运行时“复杂”的脱节。它揭示了生产环境中常见的陷阱——从延迟尖峰到 OOM 崩溃,从手动调度到工作窃取反直觉——并指出这些问题的根源在于抽象泄漏。作者提出的 Tina 框架虽然另类(回归线程每核、放弃工作窃取、严格有界),但其设计思路呼应了 Erlang/Elixir 社区早已验证的“容错与确定性”优先原则。这篇文章并非否定 async/await 的价值(它仍是 I/O 密集型场景的有力工具),而是在提醒开发者:选择并发模型时,要区分“易写”与“易运行”,并且当系统

查看原文 →pmbanugo.me