在软件工程的历史上,很少有一种技术范式能像异步编程这样,在解决一个问题的同时如此系统性地生成下一个问题。从回调函数到 Promise,再到 async/await,每一代方案都声称要让并发代码变得更好写、更易读,但实际上每一次演进都在重新定义 “更好” 的含义 —— 局部体验改善,全球复杂度上升。本文将从 C10K 问题出发,系统梳理异步编程的三波浪潮,分析每一代方案解决了什么、又引入了什么代价,并探讨不同语言从中吸取的教训。
C10K 问题:一切的起点
理解异步编程的演进,必须回到其最初的物理约束。操作系统线程是昂贵的:一个线程通常需要保留约一兆字节的栈空间,创建时间约为毫秒级,上下文切换发生在内核空间并消耗 CPU 周期。当一个服务器需要处理数千个并发连接且为每个连接分配一个专用线程时,数千个线程相互竞争调度资源,系统将大量时间花在管理线程而非执行实际工作上。这就是 Dan Kegel 在 1999 年提出的 C10K 问题:如何构建能够处理一万个并发连接的网络服务器、聊天系统或任何高并发应用?
答案以波浪式涌现,每一波都试图解决上一波最严重的问题,同时不可避免地引入新的问题。这就是异步编程范式演进的真实轨迹。
第一波:回调函数
第一波解决方案非常直接:不要阻塞线程。与其等待 I/O 操作完成,不如注册一个回调函数,在操作完成时被调用,然后继续处理下一项工作。事件循环(select、poll、epoll、kqueue)将数千个连接复用 到少数几个线程上,回调函数成为程序员与这套机制交互的接口。
Node.js 基于这一模型构建了完整的生态系统,单线程就能处理数千个并发连接。Nginx 的事件驱动架构是其取代 Apache 成为高并发工作负载首选的重要原因。回调函数优雅地解决了资源问题,但代价是创造了 ergonomics 问题 —— 代码难以编写、阅读和正确实现。
回调的核心问题在于控制流反转。程序员本想写 “做 A,然后 B,然后 C” 这样的三步顺序操作,却被迫写成 “做 A,当它完成时调用这个函数,该函数做 B,当它完成时调用另一个函数做 C”。程序员的意图被分散在嵌套的闭包中,JavaScript 开发者称之为 “回调地狱”,甚至专门建立了一个网站来倾诉这种痛苦。
回调函数还有更深层的问题。错误处理被彻底碎裂,每个回调都需要自己的错误路径。错误无法沿着调用栈自然向上传播,因为根本没有调用栈 —— 回调运行在与注册时完全不同的上下文中。在回调链中处理部分失败意味着必须在线程局部状态或闭包变量中手动传递错误状态。此外,回调没有取消机制。如果你启动了一个异步操作后又决定不再需要其结果,没有通用的方法来停止它。回调最终仍会触发,而你的代码需要处理 “我已经不在乎这个结果” 的情况。
第二波:Promise 与 Future
第二波始于一个精妙的构想:如果异步操作不是注册一个回调等待后续调用,而是立即返回一个代表其最终结果的对象,会怎样?
这就是 Promise(JavaScript)或 Future(Java、Rust 等)的起源。这一概念可以追溯到 1977 年 Baker 和 Hewitt 的研究,但正是 2010 年代的 C10K 压力才将其推向主流编程。JavaScript 在 ES2015 中标准化了原生 Promise(遵循社区驱动的 Promises/A+ 规范),Java 8 引入了 CompletableFuture。
Promise 确实比回调更符合人体工程学。首先,Promise 是可组合的:promise.then (f).then (g) 看起来像管道而非嵌套金字塔。错误处理也得到整合:链末端的 .catch () 可以处理任何步骤的失败。Promise 是可以存储、传递和返回的值。对 eventual value 的第一手处理将对话从原始线程转向数据依赖。“这个值依赖于一个尚未完成的计算” 这一表达能力是有价值的抽象。
但 Promise 引入了自己的问题。Promise 是单次的:一个 Promise 恰好 resolve 一次。这使其不适合建模流、事件、重复消息或任何持续通信。接收消息流的 WebSocket 无法映射到 “稍后存在的值”。这强制了一种分裂:Promise 用于请求 - 响应模式,其他一切需要事件发射器、Observable 或回归回调。
组合也显得笨拙。获取 user 和 orders 并将它们都传入最终的 .then () 需要嵌套或使用 Promise.all 的尴尬操作。两个独立的异步操作容易处理(Promise.all ([a, b])),但任何更复杂的场景(条件分支、循环中的异步操作、早期退出)都需要日益复杂的组合器模式。这些模式能工作,但它们是植入命令式语言中的函数式编程习惯,用起来并不自然。
错误会静默消失。没有 .catch () 处理器的 JavaScript Promise 最初只是吞掉拒绝。值丢失导致失败不可见。这足够糟糕,以至于 Node.js 最终将未处理的拒绝从警告改为进程崩溃,浏览器添加了 unhandledrejection 事件。一个本意改善错误处理的设计成功创造了一种在回调中不存在的新类别静默失败。
还有类型分裂。每个函数要么返回值要么返回值的 Promise。调用者需要知道他们得到哪个,库需要决定提供哪个。一个本来同步的函数在添加数据库调用后变成异步的,而现在每个调用者都需要处理 Promise 而不是值。这是下一波会变得更糟的着色问题的温和形式。
第三波:async/await
Promise 链看起来仍然不像开发者为其他一切编写的顺序代码。async/await 由 C# 在 2012 年首创,随后被 JavaScript(ES2017)、Python(3.5)、Rust(1.39)、Kotlin、Swift 和 Dart 采用,终于实现了这一目标。async/await 版本读起来像顺序代码。变量自然绑定。可以使用 try/catch 而非 .catch ()。循环可以在其中使用 await。线性异步操作序列的人体工程学得到了真正的改进。
行业采用它很快,JavaScript 框架全力以赴,Python 的 asyncio 成为并发 I/O 的标准方法,Rust 将 async/await 稳定化为高性能网络之路。在几年内,async/await 成为大多数主流语言编写并发 I/O 代码的默认方式。
函数着色税
2015 年,就在 async/await 获得发展势头之际,Bob Nystrom 发表了《你的函数是什么颜色?》,一个思想实验:如果语言中的每个函数要么是 “红色” 要么是 “蓝色”。红色函数可以调用蓝色函数,但蓝色函数不能调用红色函数,除非经过特殊仪式。每个函数必须选择一种颜色,如果从蓝色函数调用红色函数,蓝色函数必须变成红色,并在整个代码库中病毒式传播。
这是对 async/await 的类比:async 函数是红色的,同步函数是蓝色的。async 函数可以调用同步函数而没问题,但从同步函数调用 async 函数需要阻塞线程或重构代码。代码库中的每个函数必须选择一种颜色,而这个选择会传播到每个调用者。
Nystrom 的帖子之所以流行起来,是因为它为开发者一直经历但没有词汇表达的东西起了名字。函数着色在生态系统层面重塑了代码库。
Rust 异步生态系统围绕相互竞争的正则表达式框架(Tokio、async-std、smol)分裂,这些框架提供不兼容的 TCP 流和计时器等基本类型实现。为 Tokio 编写的库不能轻易与 async-std 一起使用。流行的 HTTP 客户端 reqwest 简单要求 Tokio,如果你的项目使用不同的运行时,那就是你的问题。现在库作者要么选择 Tokio(锁定替代方案),要么尝试与运行时无关的抽象(增加复杂性和有时性能开销)。
Tokio 的主导地位是生态系统层面的函数着色。税收在其他层面也体现:
在函数层面,向以前同步的函数添加单个 I/O 调用会更改其签名、返回类型和调用约定。每个调用者必须更新,而他们的调用者必须更新。变化 ripple 直到到达框架入口点或主函数。一行数据库查找可能需要修改数十个文件。
在库层面,作者面临选择:编写同步库并排除异步用户,或者编写异步库并强制同步用户添加运行时依赖(或两者都维护),使 API 表面积翻倍、测试矩阵和维护负担翻倍。在 Python 中,requests 库(同步)和 aiohttp(异步)是不同作者做的不同项目。httpx 最终出现,提供单一的两种接口,这只是因为分裂才需要。
在生态系统层面,上述 Rust 示例是常态,不是例外。每个接触 I/O 的库必须选择一种颜色,而这种选择限制了它可以与之协作的其他库。Rust 异步书本身指出 “同步和异步代码也倾向于推广不同的设计模式,这使得很难组合针对不同环境的代码。”
成本不仅是物流方面的:async/await 引入了线程根本没有的全新错误类别。O'Connor 记录了一类他称之为 “futurelocks” 的异步 Rust 死锁:future 获取锁,然后在另一个 future 尝试获取同一锁时停止被轮询。使用线程时,持有锁的线程总是朝着释放它取得进展(除非做每个人都知道危险的事情,比如 SuspendThread)。使用异步 Rust,select!、缓冲流和 FuturesUnordered 等标准工具常规停止轮询持有资源的 future。Oxide 的原始 futurelock 需要核心转储和反汇编器来诊断。
顺序陷阱
一个得到较少关注的更微妙代价是,async/await 最大的优势 —— 让异步代码看起来像顺序代码 —— 同时也是认知陷阱。这个函数按顺序获取订单和推荐:getRecommendations 要等到 getOrders 完成后才开始。但这两个操作是独立的,因为推荐不依赖于订单。所以它们可以并行运行,但实际上没有。代码看起来干净正确,同时把性能留在桌面上。
并行版本要求程序员明确打破顺序风格。这个模式在小型示例之外扩展不佳。在有数十个异步调用的真实应用中,确定哪些操作是独立的可以并行化需要程序员手动分析依赖并相应重构代码。顺序语法积极隐藏依赖结构 —— 而依赖结构是判断什么可以并行运行的唯一信息。
async/await 的引入是为了让异步代码更容易编写。它使 “什么是可以并发的” 成为程序员必须手动确定的事情,并通过打破顺序流程的组合器模式来表达,而顺序流程是整个要点。
异步做对了什么
公平地说,异步抽象确实改进了事物。async/await 对线性序列的人体工程学比回调或 Promise 链更好。对于本质上顺序但恰好包含 I/O 的代码,async/await 消除了真正的语法噪声。它比基于回调的代码更容易阅读和调试。
一些语言从着色问题中学到了正确的教训。例如,Go 有意选择 goroutines 而非 async/await,以更重的运行时换取根本没有函数着色。Java 的 Project Loom(Java 21 中的虚拟线程)做了相同的赌注:轻量级线程看起来和行为像普通线程,所以不需要代码改变颜色。Loom 团队明确将函数着色作为他们想要避免的问题。
Zig 更进一步:完全移除了编译器级别的 async/await,围绕 I/O 接口参数重建,I/O 操作接受该接口。运行时(线程的、事件循环的、无论用户提供什么)实现接口。函数签名不会根据调度方式改变,async/await 成为库函数而非语言关键字。
研究过其他生态系统中 async/await 经验的设计者得出结论,函数着色的代价超过收益,并选择了不同的路径。
累积的代价
每个解决方案解决了一个问题但引入了新的代价。这些代价是结构性的,影响程序、库和代码库中每个 API 的形状。
回调解决了线程连接资源耗尽但引入了控制流反转、碎裂的错误处理和回调地狱。Promise 解决了嵌套、错误整合和值的回调但引入了单次限制、静默错误吞没和轻微的类型分裂。async/await 解决了线性异步序列的人体工程学但引入了函数着色、生态系统分裂、新的死锁类别和顺序陷阱。
每波都让编写异步代码的局部体验更愉快,同时让维护大量混合同步 / 异步代码、管理跨运行时的依赖兼容性、以及试图在看起来像顺序的 await 链背后寻找并行化机会的全局体验更复杂。
这不是糟糕工程的情况。设计回调、Promise 和 async/await 的人们在解决真正的问题,每一步都是对前一步失败的合理回应。但十五年和几次迭代之后,累积的税收是相当可观的,一个模式可见:每次修复都治疗症状而留下结构完整。
从回调到 Promise 再到 async/await 的弧线可能是本系列中贯穿始终的主题的最清晰例证:从 “如何管理并发执行?” 开始的方法在每个抽象层次不断生成新问题。你可以在一个生态系统内、十年内实时观察这一点。
参考资料
- Bob Nystrom, "What Color is Your Function?", 2015
- Jack O'Connor, "Never Snooze a Future", 2026