异步编程的承诺与现实
摘要
深入剖析异步编程模型的演进——从回调到 Promise——揭示每一轮迭代如何解决先前的资源与性能问题,同时带来新的易用性挑战。
暂无内容
查看缓存全文
缓存时间: 2026/04/22 09:04
# Async 的承诺与兑现 —— Causality
来源:https://causality.blog/essays/what-async-promised/
操作系统线程代价高昂:一条线程通常预留 1 MB 栈空间,创建耗时约 1 ms。上下文切换在内核完成,消耗 CPU 周期。若服务器为每条连接开一条线程,几千并发就意味着几千条线程争抢调度、吞噬内存,系统把宝贵时间花在管理线程而非干正事。
这就是 1999 年 Dan Kegel 提出的 C10K 问题。当时要写 Web 服务器、聊天系统或任何高并发长连接服务,都必须找到“不用一条连接一条线程”的并发方案。
答案一波接一波,每一波都解决了上一波最痛的点,却带来新问题。此前我们聊过 Go 的 channel(https://causality.blog/essays/message-passing-is-shared-mutable-state)和 Erlang 的 actor(https://causality.blog/essays/the-isolation-trap)。今天轮到如今遍地开花的 async。
## 回调
第一波思路很直接:别阻塞线程。I/O 操作不等待完成,而是注册一个函数,干完活后调用它,线程继续处理别的任务。事件循环(select、poll、epoll、kqueue)把成千上万连接复用到少数线程,回调就是程序员打交道的接口。
Node.js 凭此模型撑起整个生态,单线程处理数千并发。Nginx 的事件驱动架构也因此取代 Apache,成为高并发负载的首选。
性能问题解决了,代价是控制流被反转。原本写“先做 A,再做 B,再做 C”的三行顺序代码,现在要写成“做 A,做完调这个函数,它做 B,做完再调那个函数,它做 C”。程序员的意图碎成嵌套闭包。JavaScript 社区称之为“回调地狱”,甚至建了网站(http://callbackhell.com/)抱团取暖。
回调的坑不止颜值:错误处理被撕裂。每个回调都要自己处理错误。错误无法沿调用栈自然传播,因为调用栈不存在(回调运行在与注册点不同的上下文)。链式回调想部分容错,就得把错误状态逐层传递。
此外,回调没有取消语义。启动异步操作后又不需要结果了?没有通用办法叫停。回调迟早会执行,代码还得处理“其实我已经不关心”的情况。
回调用“代码难写难读”的代价,换来了“线程太多”的解决方案。
## Promise 与 Future
第二波想到:与其传回调,不如让异步操作立即返回一个代表“未来结果”的对象?
这就是 JavaScript 的 Promise,或 Java/Rust 的 Future。概念可追溯至 1977 年的 Baker & Hewitt,但直到 2010 年代 C10K 压力才把它推入主流。ES2015 将 Promise 标准化,Java 8 引入 CompletableFuture。
Promise 比回调好用:
- 可组合:`promise.then(f).then(g)` 是流水线,而非金字塔。
- 错误归拢:链尾一个 `.catch()` 能捕任意环节错误。
- 值一等:可存变量、当参数、作返回值。把“值尚未算完”表达成数据,对话重心从线程转向数据依赖。
下面用 JS 先回调再 Promise 读取用户资料与订单:
```js
// 回调:层层嵌套,各层都要处理错误
getUser(userId, (err, user) => {
if (err) return handleError(err);
getOrders(user.id, (err, orders) => {
if (err) return handleError(err);
render(user, orders);
});
});
// Promise:链式,错误归拢
getUser(userId)
.then(user => getOrders(user.id).then(orders => [user, orders]))
.then(([user, orders]) => render(user, orders))
.catch(handleError);
```
小例子提升有限,但链长五步时,回调几乎看不懂,而五个 `.then()` 至少还是线性的。
Promise 也带来新麻烦:
**一次性**——Promise 只能决议一次。无法建模流、事件、持续消息。WebSocket 收到消息流,可不是“未来某个值”。于是世界分裂:请求-响应用 Promise,其他用事件发射器、Observable 或回到回调。
**组合笨拙**——上例要把 `user` 和 `orders` 一起传下去,就得嵌套或 `Promise.all` 体操。两个并行容易 (`Promise.all([a, b])`),但再复杂点(条件分支、循环、提前返回)就要越来越花哨的组合子,函数式写法强加给命令式语言,怎么看怎么别扭。
**静默吞错**——没加 `.catch()` 的拒绝 Promise 曾直接把错误吞掉。Node 后来把未处理拒绝改成崩溃,浏览器加 `unhandledrejection` 事件。本想改善错误处理,却整出一种回调时代没有的静默失败。
**类型分裂**——函数要么返回值,要么返回 Promise,调用方得知道是哪种。原本同步的函数,因为加了一次数据库查询就得变异步,调用链全部翻新。这只是“着色问题”的温和版,下一波会更酸爽。
## Async/Await
Promise 链仍不像人写的顺序代码。2012 年 C# 率先落地 async/await,随后 JavaScript(ES2017)、Python 3.5、Rust 1.39、Kotlin、Swift、Dart 纷纷跟进,终于让异步代码“长得”像同步:
```js
// Promise 链
function loadDashboard(userId) {
return getUser(userId)
.then(user => getOrders(user.id)
.then(orders => [user, orders]))
.then(([user, orders]) => render(user, orders));
}
// async/await
async function loadDashboard(userId) {
const user = await getUser(userId);
const orders = await getOrders(user.id);
return render(user, orders);
}
`
行业迅速全面拥抱:JS 框架梭哈,Python 的 asyncio 成标配,Rust 靠它冲刺高性能网络。几年之内,async/await 成大多数语言写并发 I/O 的默认姿势。
## 缴纳“函数着色税”
2015 年,async/await 方兴未艾,Bob Nystrom 发表《你的函数是什么颜色?》(https://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/),设想一种语言:函数分红蓝两色,红可调蓝,蓝调红得加仪式。每函数必须选色,蓝调红则蓝变红,颜色会沿着调用链病毒式扩散。
这就是 async/await 的写照:async 函数是红色,sync 函数是蓝色。红调蓝随便,蓝调红要么阻塞线程要么重构。每段代码都要选色,并传染整个调用图。
Nystrom 的比喻一炮而红,因为大家终于有词形容这种痛——“函数着色”重塑了整个代码库与生态:
Rust 的 async 生态分裂成 Tokio、async-std、smol 等互不兼容的运行时,连基础 TCP 流、定时器都各搞一套。用 Tokio 写的库基本跑不到 async-std 上。reqwest 直接绑死 Tokio,你想换运行时?自己看着办。库作者要么押 Tokio(排斥别家),要么写运行时无关抽象(增加复杂度与性能损耗)。Tokio 的统治就是生态系统层面的“着色税”。
**函数级**:原本同步的函数,因为加一行数据库查询就得改签名、改返回类型、改调用约定,一路改到 main。一行代码牵连几十个文件。
**库级**:作者要么写 sync 版放弃 async 用户,要么写 async 版逼 sync 用户拉运行时,要么两个都写——API 翻倍、测试矩阵翻倍、维护量翻倍。Python 的 requests(同步)与 aiohttp(异步)是两个团队两套代码,httpx 后来才统一双接口,而这麻烦本就不该存在。
**生态级**:Rust 只是缩影。凡碰 I/O 的库都得选色,选色就限制互操作性。Rust 官方 async 书也承认:“同步与异步代码倾向不同设计模式,组合起来很困难。”
成本不止于折腾,async/await 还带来线程时代没有的新 bug 品类。O’Connor 记录过一类 Rust 异步死锁“futurelock”:一 future 拿到锁后停止被轮询,另一 future 却想拿同一把锁。线程世界里,拿锁的线程总会向前推进(除非你作死去 `SuspendThread`);异步里,`select!`、带缓冲的 Stream、`FuturesUnordered` 都会停掉正持有资源的 future。Oxide 遭遇的 original futurelock 得靠 coredump 与反汇编才定位。
## 顺序陷阱
更隐蔽的代价:async/await 的最大卖点——“让异步代码看起来像顺序代码”——也是认知陷阱。
```js
async function loadDashboard(userId) {
const user = await getUser(userId);
const orders = await getOrders(user.id);
const recommendations = await getRecommendations(user.id);
return render(user, orders, recommendations);
}
`
这段代码把订单和推荐串行化:要等订单返回才开始拿推荐。但两者并无依赖,可以并行,却没并行。代码看着清爽正确,实则白白丢性能。
想并行就得手动打破顺序风格:
```js
async function loadDashboard(userId) {
const user = await getUser(userId);
const [orders, recommendations] = await Promise.all([
getOrders(user.id),
getRecommendations(user.id)
]);
return render(user, orders, recommendations);
}
`
规模稍大就头痛:真实应用里几十次异步调用,哪些独立可并行,得程序员手动分析依赖再改结构。顺序语法反而把依赖图藏了起来——而依赖图正是告诉你“谁能一起跑”的关键信息。
async/await 本想让异步代码更好写,却把“谁能并发”变成程序员要手动推断并靠组合子表达的事,而这些组合子又打破了最初追求的顺序流。
## Async 做对了什么
公允地说,async 抽象确实带来了进步。
对线性异步序列,async/await 比回调或 Promise 链都顺手。对天然顺序、只是夹杂 I/O 的代码,它消除了语法噪音,读写调试都更轻松。
也有语言吸取了“着色”教训:Go 直接选 goroutine,宁可运行时重一点,也要消灭函数颜色;Java 的 Loom(Java 21 虚拟线程)同理:轻量线程长得跟普通线程一样,代码无须变色。Loom 团队明确说,就是想避开函数着色。
Zig 更激进:把编译器级 async/await 整个砍掉,转而在 I/O 操作里传一个 `Io` 接口参数,由用户提供的运行时(线程、事件循环……)实现。函数签名不再因调度方式而变色,async/await 退居库函数而非关键字。当然也有人认为 `Io` 参数本身算另一种着色。
看过 async/await 痛苦的语言设计者,纷纷得出“着色代价 > 收益”的结论,选了别的路。
## 成本在累加
每一波都解决了上一波最痛的点,却引入新的结构性成本,影响每一层程序、库、API。
| 浪潮 | 解决 | 引入 |
|---|---|---|
| 回调 | 一线程一连接的资源耗尽 | 控制流反转、错误处理碎片化、回调地狱 |
| Promise | 嵌套、错误归拢、值代替回调 | 一次性限制、静默吞错、轻度类型分裂 |
| Async/await | 线性异步代码好写 | 函数着色、生态碎片化、新死锁、顺序陷阱 |
每一波都让“写单个异步函数”的局部体验更甜,却让“维护大型代码库”的全局体验更苦。写函数的人从未如此舒服,维护系统的人……
相似文章
你的所有智能体都将走向异步
文章指出,AI智能体正从同步聊天界面转向异步后台工作流,并重点介绍了Anthropic、OpenAI和Cursor推出的新功能,这些功能将智能体生命周期与HTTP请求-响应周期解耦。
ClojureScript 迎来 Async/Await
ClojureScript 1.12.145 通过 ^:async 提示引入原生异步函数支持,实现与 JavaScript async/await 的直接互操作,无需额外依赖。
没人要求的 `Sync` 约束
解释了在具有 `Send` future 的异步 trait 方法中使用 `&self` 如何隐式要求实现类型具有 `Sync`,并提供了诸如改用 `&mut self` 或使用 `Sync` 内部可变性等解决方法。
幂等性看似简单,直到第二次请求出现差异
本文探讨了在API中实现幂等性的复杂性,指出处理并发请求和内容不匹配等边缘情况,比简单的重放缓存更为困难。
@djfarrelly: https://x.com/djfarrelly/status/2052779234234380479
本文主张,AI Agent 的开发应基于稳定的执行原语,而非会随新兴编排模式频繁更迭的僵化框架。文章强调,采用持久化步骤、持久状态、并行协调、事件驱动流程以及可观测性设计,可有效避免因最佳实践不断演进而付出的高昂重写代价。