异步编程的发展
线程模型下,持有锁的线程被挂起时,操作系统会调度其他线程——但锁还是它的。async/await 里,一个 future 暂停时,别的 future 根本没机会被轮询,所以锁永远拿不到。所有 I/O 操作(读数据库、发 HTTP 请求、读文件)都不阻塞当前线程,而是"我发个请求,等它回来再调你这个函数"。这是 Rust 等语言里暴露出来的问题:一个 async 函数持有锁,但它暂停了(等待 I/O
线程的终结
早年写服务端,逻辑很简单:一个请求一个线程。
用户 A 请求 → 创建线程 A → 查数据库 → 返回结果
用户 B 请求 → 创建线程 B → 查数据库 → 返回结果
代码写起来像同步程序一样自然——因为它本来就是同步的。你不需要关心什么异步、回调、事件循环,写完就走。
问题是:线程很重。
一个线程默认 1MB 栈空间,上下文切换要进内核态。100 个并发没问题,10000 个并发呢?操作系统受不了。这就是 2000 年代初著名的 C10K 问题——同时处理一万个连接,线程模型扛不住。
所以人们抛弃了线程。
回调地狱
Node.js 带来的思路是:不要用线程,用事件循环。
所有 I/O 操作(读数据库、发 HTTP 请求、读文件)都不阻塞当前线程,而是"我发个请求,等它回来再调你这个函数"。同一个线程可以同时挂起几百个请求。
getUser(userId, (err, user) => {
if (err) return handleError(err);
getOrders(user.id, (err, orders) => {
if (err) return handleError(err);
render(user, orders);
});
});
内存问题解决了。一万个连接只用一个线程,因为挂起的连接几乎不占内存。
但代码结构被毁了。
- 本来应该从上往下读的代码,变成了右斜式的嵌套。嵌套深了叫 “回调地狱”(callback hell)
- 每个回调都要单独处理错误,
if (err)写得到处都是 - 正常的函数调用栈断了——错误没法按正常方式传播
- 想取消一个还没回来的请求?没有这个机制
回调解决了并发量的问题,但让代码没法读了。
Promise 的救赎
Promise 的核心思路:异步操作应该返回一个"未来的结果",而不是要求你传一个回调进去。
// 回调风格:嵌套、每个层级都要处理错误
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 => render(orders))
.catch(handleError);
Promise 做了三件回调做不到的事:
- 把回调压平——
.then()链式调用,不用右斜嵌套了 - 错误统一收口——一个
.catch()管住整条链 - 异步结果成了一等公民——你可以把 Promise 存到变量里、传给函数、组合起来
但 Promise 也有自己的坑。
Promise 是一次性的
一个 Promise 只能 resolve 或 reject 一次。它解决不了"持续产生数据"的场景——比如 WebSocket 推送、实时日志流。后面这类场景得用 Stream,Promise 管不了。
组合多个 Promise 很别扭
三个互不依赖的异步操作,你想并行执行:
const [a, b, c] = await Promise.all([
fetchA(),
fetchB(),
fetchC()
]);
如果是条件分支呢?“A 成功了再决定要不要调 B”——.then() 链就开始变得尴尬了。你不得不在 .then() 里套 .then(),又回到了嵌套的老路。
静默失败
早期 Promise 如果被 reject 了但没人 .catch(),错误就消失了。程序看起来运行正常,实际上数据已经丢了。后来浏览器和 Node 加了 unhandledrejection 事件才勉强能监控到。
类型分裂
有了 Promise,API 签名分裂成两套:同步版本和异步版本。
// 同步版
function getConfig() { return { theme: "dark" }; }
// 异步版(从远程加载)
function getConfig() { return Promise.resolve({ theme: "dark" }); }
调用方必须适配异步版本。看起来小事,但当生态里每个库都有自己的 async/sync 两套 API 时,维护成本就上去了。
async/await——看起来完美了
async/await 让异步代码看起来像同步代码。
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);
}
- 变量可以直接绑定,不用在
.then()回调里传值 - 错误用
try/catch处理,跟同步代码一样 - 代码从上往下读,直觉上很舒服
但它引入了三个更隐蔽的结构性问题。
问题一:函数染色(Function Coloring)
async/await 带来了一个根本性的分裂:函数变成了两种颜色。
- 普通函数(白色):可以在任何地方调用
- async 函数(红色):只能在另一个 async 函数里
await,或者用.then()调
function sayHello() {
// 普通函数,白色
console.log("Hello");
}
async function fetchData() {
// async 函数,红色
return fetch("/api/data");
}
// 白色函数里不能直接 await 红色函数
function main() {
const data = await fetchData(); // 报错!await 只能在 async 函数里用
}
当你的程序深处把一个同步操作改成异步(比如从读缓存改成调 HTTP 接口),调用链上所有函数都要改成 async。一个改动,可能传染到几十上百个函数签名。
问题二:并行陷阱
async/await 最大的陷阱:它让你以为代码是并行的,但实际上是串行的。
async function loadDashboard(userId) {
const user = await getUser(userId); // 1 秒
const orders = await getOrders(user.id); // 1 秒,但它在等 getUser 完成
const recommendations = await getRecommendations(user.id); // 1 秒,又在等
return render(user, orders, recommendations); // 总共 3 秒
}
orders 和 recommendations 互不依赖,完全可以同时发请求。但 await 的写法把它们变成了串行。你得主动打破"像同步代码一样"的幻觉,才能恢复并行:
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); // 总共 2 秒
}
你不得不放弃 async/await 的"舒服写法"来恢复性能。 代码写得越舒服,性能越差。
问题三:Futurelocks——暂停的锁
这是 Rust 等语言里暴露出来的问题:一个 async 函数持有锁,但它暂停了(等待 I/O),其他任务就拿不到这个锁。
// 伪代码
async fn process(shared: &Mutex<Data>) {
let guard = shared.lock().await; // 拿到锁
let data = fetch_remote().await; // 暂停等待网络
// guard 还拿着锁!其他任务全卡住了
use(&guard, data);
}
线程模型下,持有锁的线程被挂起时,操作系统会调度其他线程——但锁还是它的。async/await 里,一个 future 暂停时,别的 future 根本没机会被轮询,所以锁永远拿不到。
另一些人的选择:不要 async/await
意识到"函数染色"的负担后,一些语言干脆拒绝了 async/await。
Go:goroutine
go func() {
data, _ := http.Get(url)
// 处理数据
}()
所有函数签名都一样,不需要标记 sync 还是 async。goroutine 是用户态的轻量级线程,调度器自己管理。写代码的时候没有任何"颜色"区分。
Java 21:虚拟线程
Thread.startVirtualThread(() -> {
var data = httpClient.send(request); // 看起来是阻塞调用
process(data);
});
虚拟线程也是用户态线程。代码看起来是阻塞的,但运行时在 I/O 时会自动挂起、切换。函数签名不需要改变。
Zig:显式 I/O 参数
fn fetchData(allocator: Allocator, io: *IOContext) ![]u8 {
return io.fetch("/api/data");
}
Zig 不搞 async/await。异步还是同步,取决于你传进去的 I/O 上下文。函数本身不需要被染色。
回头看
异步编程走了四步,每一步都解决了上一步最严重的问题,同时制造了一个新的:
线程 → 太重,撑不住并发
回调 → 代码没法读
Promise → 组合困难、类型分裂
async/await → 函数染色、并行陷阱、Futurelocks
核心问题是:我们一直在问"怎么管理并发执行?“,而不是"为什么并发执行需要被特殊管理?”
每个抽象层都让写单个异步函数变得更舒服,但让整个系统的结构变得更复杂。团队要管理分裂的生态、重复的库、手动分析哪些操作可以并行、处理全新的死锁类型。
Go、Java 虚拟线程、Zig 的选择说明了一种可能:如果运行时自己解决了并发问题,开发者就不需要被异步语法绑架。
实践建议
如果你正在用 async/await(JavaScript、Python、Rust),这些是实际项目中最容易踩的坑:
1. 用性能测试工具看串行点
// 看着像并行,实际是串行
const user = await getUser(id);
const orders = await getOrders(id);
用 Promise.all 包装独立请求,但别过度——有依赖关系的必须串行。
2. 永远加全局 unhandled rejection 处理
process.on("unhandledRejection", (reason) => {
console.error("Unhandled rejection:", reason);
});
Promise 被 reject 但没人 catch 的时候,你至少能知道。
3. 设计 API 时尽量减少 async/sync 分裂
如果一个函数今天同步、明天可能变异步,直接一开始就返回 Promise。调用方不需要因为你改了实现而改签名。
4. 拿锁时不要 await
// 错误:拿着锁等 I/O
const lock = await mutex.acquire();
const data = await fetch("/api/data"); // 其他任务全卡住
mutex.release(lock);
// 正确:先拿数据,再拿锁
const data = await fetch("/api/data");
const lock = await mutex.acquire();
state.update(data);
mutex.release(lock);
异步编程不是技术问题,是认知问题。你在写的代码看起来像什么,取决于你选择了哪个抽象层。选择之前,想清楚你愿意为这个"舒服"付出什么代价。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)