为什么选择 async/await 而不是线程?

概念模型:线程、async/await、纤程

  • 许多人将它们区分为:
    • 线程 = 由操作系统调度、抢占式、并行。
    • async/await = 无栈协程和 future;协作式并发。
    • 纤程/绿色线程 = 有栈的用户态线程;看起来像阻塞代码,但由协作式调度。
  • 有些人认为更好的比较是 async/await 与纤程,因为两者都在少量 OS 线程上多路复用 I/O。
  • 也有人指出,async/await “本质上”只是带语法糖的 CPS/状态机;底层使用的是相同的 I/O 原语。

取消与清理

  • 一个主要主题是取消语义。
    • Rust 中的 async 通常通过丢弃 future 来“取消”,这可能在不让操作有机会响应的情况下中断它们,从而导致微妙的 bug 和资源泄漏。
    • 纤程模型通常使用显式取消信号,并在 yield/I/O 位置进行协作式处理,让任务有机会清理、完成临界区,或忽略取消。
  • Rust 中的 Async Drop 被认为是理想的,但在技术上很难;有些人认为它严格弱于显式取消信号。

可组合性与“函数着色”

  • Async 被称为“病毒式”的:一旦某个函数是 async,调用者也必须是 async,从而把 API 分成同步与异步两套,并进一步分裂为不同运行时风格(Tokio、async-std 等)。
  • 这与纤程/线程形成对比:并发可以只是内部细节,而公共 API 仍保持同步。
  • 有些人认为 await 标记是有帮助的显式挂起点;另一些人则认为它们只是噪音,并不太对应“哪里发生 I/O”或真实的干扰点。

性能、内存与可扩展性

  • 支持 async 的观点:
    • 单个任务的内存占用远低于 OS 线程(没有大栈),因此可以支持数百万个并发任务。
    • 更少的内核上下文切换开销;适合高并发服务器和没有线程或操作系统的嵌入式目标。
    • 编译器生成的状态机可以避免动态分配,并在某些工作负载下提供极低延迟。
  • 怀疑者的看法:
    • 对许多真实世界的服务器来说,缓存未命中和 I/O 才是主导因素;async 的微优化未必重要。
    • “每客户端一个线程”很糟,但用户态绿色线程或进程加事件驱动 I/O 也能带来类似收益,而且语义更简单。
    • Async 可能会加剧对背压和取消的推理难度,而调试“卡住”的任务更困难。

FFI、运行时与生态碎片化

  • 纤程和 M:N 线程会使 FFI 变复杂:TLS、栈增长以及对 OS 线程的假设常常会破坏 C 库。
  • Rust 的 async 由 executor 驱动的 future 避免了其中一部分问题,但也造成了碎片化:
    • 同步 crate 与异步 crate;而在异步 crate 之中,又依赖特定的 executor。
    • 很难编写既与 executor 无关、又能与同步和异步世界兼容组合的库。

单线程 vs 多线程 async

  • 单线程事件循环(Node、许多嵌入式运行时)可以避免数据竞争,但仍然存在逻辑竞争;共享状态仍然需要同步。
  • Rust 默认的 async 生态(例如 Tokio)是多线程的;许多人认为这相当于把 async 的复杂性和完整多线程的复杂性叠加在一起。
  • 有些人希望默认应该是单线程,并显式选择是否使用多线程 executor。

使用场景与易用性

  • Async/await 受到赞赏的地方:
    • 高并发 I/O 服务(Web 服务器、代理)。
    • 没有 OS 线程的嵌入式和裸机系统。
    • GUI 以及“单线程但需要响应性”的场景。
  • 其他人更偏好:
    • 用线程或绿色线程处理 CPU 密集型工作和 actor 风格架构。
    • 使用进程加消息传递,以获得更高的鲁棒性和更简单的心智模型。
  • 几条评论抱怨说,在 Rust 中,async/await 主导了 I/O 库,实际上即使线程或进程就足够,也会强迫使用它。

总体情绪

  • 分歧很大:
    • 支持者认为 async/await 是强大而富有表达力的并发工具,比线程更可扩展,并且适用于没有操作系统的环境。
    • 批评者认为它在易用性上笨拙、分裂生态、难以取消和调试,而且经常是在过度优化;更简单的基于线程或进程的设计本可以胜任。