异步编程的承诺与现实

Hacker News Top 工具

摘要

深入剖析异步编程模型的演进——从回调到 Promise——揭示每一轮迭代如何解决先前的资源与性能问题,同时带来新的易用性挑战。

暂无内容
查看原文 导出为 Word 导出为 PDF
查看缓存全文

缓存时间: 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 | 线性异步代码好写 | 函数着色、生态碎片化、新死锁、顺序陷阱 | 每一波都让“写单个异步函数”的局部体验更甜,却让“维护大型代码库”的全局体验更苦。写函数的人从未如此舒服,维护系统的人……

相似文章

你的所有智能体都将走向异步

Hacker News Top

文章指出,AI智能体正从同步聊天界面转向异步后台工作流,并重点介绍了Anthropic、OpenAI和Cursor推出的新功能,这些功能将智能体生命周期与HTTP请求-响应周期解耦。

ClojureScript 迎来 Async/Await

Hacker News Top

ClojureScript 1.12.145 通过 ^:async 提示引入原生异步函数支持,实现与 JavaScript async/await 的直接互操作,无需额外依赖。

没人要求的 `Sync` 约束

Lobsters Hottest

解释了在具有 `Send` future 的异步 trait 方法中使用 `&self` 如何隐式要求实现类型具有 `Sync`,并提供了诸如改用 `&mut self` 或使用 `Sync` 内部可变性等解决方法。

@djfarrelly: https://x.com/djfarrelly/status/2052779234234380479

X AI KOLs Timeline

本文主张,AI Agent 的开发应基于稳定的执行原语,而非会随新兴编排模式频繁更迭的僵化框架。文章强调,采用持久化步骤、持久状态、并行协调、事件驱动流程以及可观测性设计,可有效避免因最佳实践不断演进而付出的高昂重写代价。