线程的终结

早年写服务端,逻辑很简单:一个请求一个线程

用户 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 做了三件回调做不到的事:

  1. 把回调压平——.then() 链式调用,不用右斜嵌套了
  2. 错误统一收口——一个 .catch() 管住整条链
  3. 异步结果成了一等公民——你可以把 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 秒
}

ordersrecommendations 互不依赖,完全可以同时发请求。但 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);

异步编程不是技术问题,是认知问题。你在写的代码看起来像什么,取决于你选择了哪个抽象层。选择之前,想清楚你愿意为这个"舒服"付出什么代价。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐