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