异步编程演进史:从回调到Promise再到Async/Await

导读

在早期,操作系统线程是极为昂贵的:一个线程通常需要预留 1MB 的虚拟栈空间,创建和销毁都需要消耗不小的内核开销。

如果一个服务器要处理数千个并发连接,并为每个连接分配一个线程:

  • 数千个线程会疯狂消耗物理内存。
  • 内核空间上下文切换(Context Switch)会榨干 CPU 周期。
  • 传统的就绪轮询模型(如 select, poll)在规模化后面临 的性能瓶颈。

系统大把的时间都浪费在了"管理线程"上,而不是做有用功。

这就是丹·凯格尔(Dan Kegel)在 1999 年提出的著名的 C10K 问题。为了解决它,技术演进的大幕正式拉开。

而答案,是以"浪潮"的形式出现的------每一波浪潮都解决了前者的痛点,却也引入了全新的问题。


第一波浪潮:Callback

第一波异步实现的目标极其直接:

  • 不要阻塞线程。
  • 不要一个连接一个线程。
  • 用事件循环(Event Loop)复用少量线程处理大量连接。

与其等待一个 I/O 操作完成,不如注册一个回调函数(Callback),然后线程立刻去处理下一项工作。通过事件循环(如 epoll, kqueue),数千个连接被复用到极少数的线程上。

典型的成功案例: Node.js 凭借单线程处理数千并发构建了庞大的生态;Nginx 的事件驱动架构也代替 Apache成为了主流。

Callback 成为了第一种广泛使用的方案,它解决了底层的资源饥饿问题,但把排山倒海般的复杂度,转移给了程序结构与开发者。

1. 控制流被反转(回调地狱)

同步代码的业务逻辑通常是线性的:查用户 -> 查订单 -> 查推荐 -> 渲染页面。但在 Callback 写法下,代码会变成一尊"向右生长的金字塔":

scss 复制代码
function loadDashboard(userId) {
  getUser(userId, function (err, user) {
    if (err) return handleError(err);

    getOrders(user.id, function (err, orders) {
      if (err) return handleError(err);

      getRecommendations(user.id, function (err, recommendations) {
        if (err) return handleError(err);

        render(user, orders, recommendations);
      });
    });
  });
}

读代码时不再是从上到下理解"做什么",而是不断迫使大脑进入"完成后再做什么"的嵌套逻辑中。

2. 错误处理重复且易遗漏

每一层回调都要手动、重复地处理错误。一旦某一层漏掉了判断,后续代码拿到 undefined 数据继续执行,就会在更远的地方爆出一个莫名其妙的 Bug:

scss 复制代码
getOrders(user.id, function (err, orders) {
  // 如果忘了判断 err,orders 为 undefined,render 就会引发次生灾难
  render(user, orders); 
});

3. 丢失调用栈排障困难

异步回调真正执行时,已经是未来某个事件循环的 Tick 了,早就脱离了原本函数的同步调用栈。此时内部抛出的异常,外部的 try/catch 根本接不住:

javascript 复制代码
function controller(req, res) {
  getUser(req.userId, function (err, user) {
    // 这里的 throw 已经无法被外部的 controller try/catch 捕获了
    throw new Error("render failed");
  });
}

4. 取消困难

当用户打开页面后马上离开,需要取消掉底层正在运行的数据库或网络请求?Callback 无法原生支持。

你只能写出类似下面这种打补丁的代码,但这只是笨拙地在应用层"忽略"结果,仍然浪费了底层资源:

javascript 复制代码
let pageActive = true;
function loadPage() {
  getUser(userId, function (err, user) {
    if (!pageActive) return; // 只是手动忽略,底层请求早已跑完
    renderUser(user);
  });
}

5. 并发组合别扭

如果页面需要同时加载用户、订单、推荐,三个都完成后再渲染,Callback 写法下你不得不手写一个脆弱的计数状态机:

ini 复制代码
let user, orders, recommendations;
let done = 0; 
let failed = false;

// 需要为 getUser/getOrders/getRecommendations 各写一个回调
// 并小心翼翼地维护 failed 状态、处理重复调用、防止 render 被多次触发......
getUser(userId, function (err, result) {
  if (err) { failed = true; return; }
  user = result;
  finish();
});

getOrders(userId, function (err, result) {
  if (err) { failed = true; return; }
  orders = result;
  finish();
});

getRecommendations(userId, function (err, result) {
  if (err) { failed = true; return; }
  recommendations = result;
  finish();
});

function finish() {
  if (failed) return handleError();
  if (++done === 3) render(user, orders, recommendations);
}

阶段总结:Callback 的得与失

  • 解决了: 摆脱了"一连接一线程"的资源枯竭危机。
  • 代价是: 控制流反转、错误处理碎片化、嵌套无底洞、取消困难、并发组合复杂,调试和维护成本极高。

性能问题解决了,但程序结构的复杂度被转移给了开发者。


第二波浪潮:Promises

为了修复 Callback 最痛的几个体验,2010 年代迎来了 Promises / Futures 。它把嵌套回调拉平成链式调用,把错误处理集中到 .catch(),并把"未来才会有的结果"包装成了一个可传递、可组合的一等公民值(Value)

scss 复制代码
getUser(userId)
  .then(user => getOrders(user.id).then(orders => [user, orders]))
  .then(([user, orders]) => render(user, orders))
  .catch(handleError);

这确实是一个巨大的进步,但又引出了新的问题:

1. 语义是"一次性"的,无法表达持续事件

Promise 的核心语义是:未来某个时间点,得到一个结果或错误,且只完成一次

这完美契合一次性的 HTTP 请求,但在面对 WebSocket、消息队列、文件流、UI 点击事件这种需要持续产生、处理消息的场景时,Promise 熄火了。

javascript 复制代码
// 强行用 Promise 表达 WebSocket,代码会变得无比别扭
function receiveNextMessage(socket) {
  return new Promise((resolve, reject) => {
    socket.onmessage = (event) => resolve(event.data);
    socket.onerror = (err) => reject(err);
  });
}

// 为了持续监听,纯 Promise 时代必须展开"无限递归"
function startListening(socket) {
  receiveNextMessage(socket)
    .then((msg) => {
      console.log("收到消息:", msg);
      // 别扭核心:为了听下一条,必须在 .then() 里面递归调用自己
      // 这会创建一条无限延伸的 Promise 隐式链条
      return startListening(socket); 
    })
    .catch((err) => {
      console.error("连接出错,监听终止:", err);
    });
}

这导致异步世界发生了生态分裂:请求响应派用 Promise,持续流派依然只能求助于 EventEmitter、Observable 或传统的 Callback。

2. 混合复杂业务场景时,组合依然别扭

一旦遇到稍微复杂的现实业务(如:先查用户 -> VIP 则同时查订单和推荐,非 VIP 只查订单 -> 推荐失败可降级,订单失败则全盘失败),Promise 链就会重新退化成难以直观阅读的函数式组合怪物:

scss 复制代码
getUser(userId)
  .then(user => {
    if (user.isVip) {
      return Promise.all([
        getOrders(user.id),
        getRecommendations(user.id).catch(err => { logWarn(err); return []; }),
      ]).then(([orders, recommendations]) => ({ user, orders, recommendations }));
    }

    // 为了向下传递数据,需要不断在每个 .then() 里手动包装和转发对象...
    return getOrders(user.id).then(orders => ({ user, orders, recommendations: []}));
  })
  .then(({ user, orders, recommendations }) => { render(user, orders, recommendations);})
  .catch(err => { logError(err); renderErrorPage(err);});

3. 断链带来的"吞错"与延迟暴露

Promise 的错误处理高度依赖链路的完整性。如果在链条的某个闭包中漏掉了 return,整条链条就会悄然断裂:

scss 复制代码
doA().then(() => {
  doB(); // 致命错误:忘了写 return 开启新 Promise 
}).then(() => {
  doC(); // 这里不会等待 doB 完成,直接与 doB 并发执行了!
}).catch(handleError); // 此时 doB 内部如果发生异步失败,catch 根本接不住!

4. 返回值类型的分裂( vs PromisePromise Promise)

一个原本同步的函数,一旦调整成了异步,它的返回值就从普通的 T 变成了 Promise<T>

scss 复制代码
// 同步函数
function getUserName(user) { return user.name; }
const name = getUserName(user);

// 异步函数
function getUserName(userId) {
  return fetchUser(userId).then(user => user.name);
}
const namePromise = getUserName(userId);

这种类型的变化就像病毒一样:调用方必须明确知道自己拿到的是普通值还是 Promise。一旦底层函数由同步变异步,会顺着调用链向上引发全链重写。

5. Promise.all 的失败语义不总是符合业务

Promise.all 的语义是:全部成功才成功,任意一个失败就整体失败

这很适合"缺一不可"的强绑定场景,但在真实的电商首页等聚合业务中,通常是:订单必须成功,推荐/广告/优惠券可以容忍失败或降级。如果直接套用 Promise.all,任何一个非核心微服务抖动,都会导致整张页面死锁崩溃。

为了让它合规,开发者不得不为每一个子项手动编写 .catch() 防御:

scss 复制代码
Promise.all([
  getOrders(userId),
  getRecommendations(userId).catch(() => []), // 失败容降级
  getAds(userId).catch(() => null),
  getCoupons(userId).catch(err => {
    logWarn("coupon failed", err);
    return [];
  }),
]).then(([orders, recommendations, ads, coupons]) => {
  render({ orders, recommendations, ads, coupons });
});

这意味着,控制并发与处理局部失败的底层复杂度,依然完完整整地留给了开发者。


第三波浪潮:Async/Await

为了消灭 Promise 链式调用的语法噪音,async/await 横空出世。它最大的功绩,是让异步代码重新长得像同步代码一样

javascript 复制代码
// 重新找回了人类最习惯的顺序感
async function loadDashboard(userId) {
  const user = await getUser(userId);
  const orders = await getOrders(user.id);
  return render(user, orders);
}

异常能用标准 try/catch 了,变量绑定也自然了。然而,这种"看起来像同步"的优点,恰恰成为了开发中危险的隐式陷阱。

1. 陷阱:将并发悄然降级为串行

请紧盯下面这段看起来非常干净、漂亮的代码:

ini 复制代码
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);
}

看出来问题了吗? ordersrecommendations 之间明明没有任何依赖关系,它们本可以并行运行!

但因为连续写了两个 await,执行流被强行变成了阻塞串行 。如果订单接口耗时 300ms,推荐接口耗时 400ms,该函数的总耗时会从并行的 400ms 飙升到 700ms

在服务端高并发场景下,这种隐藏的串行会被无限放大,最终表现为系统的吞吐雪崩。async/await 绝不会自动分析并发关系,为了压榨性能,你必须手动打破顺序风格,重新缝合 Promise:

python 复制代码
const [orders, recommendations] = await Promise.all([
  getOrders(user.id),
  getRecommendations(user.id),
]);

代码一旦变复杂,优雅的顺序流就会再次被割裂。

2. 异步会沿调用链"着色扩散"

在 Async/await 的世界里,函数被无形地分成了两种颜色:同步是蓝色,异步是红色。

  • 红色(async)函数可以调用蓝色函数。
  • 蓝色(sync)函数绝不能直接调用红色函数并拿到结果。

这导致了极其恐怖的病毒式扩散 :底层引入的一行小小的 I/O 修改,会顺着调用图一路上溯,迫使上游几十个文件的函数签名全部被迫加上 async/await

这种分裂甚至绑架了整个语言生态:

  • Python: 生态直接分裂成同步客户端 requests 和异步客户端 aiohttp
  • Rust: 围绕 Tokio、async-std 等互不兼容的运行时发生碎片化,库作者不得不为了两套生态维护两套完全一样的 API 矩阵。

3. 误以为 await 能让阻塞变非阻塞

很多人存在一个致命误解:只要函数前面写了 async,里面的代码就不会阻塞执行。 大错特错!

javascript 复制代码
async function handleRequest(req) {
  const user = await getUser(req.userId);
  // CPU 密集计算,仍然会死死锁住单线程的事件循环
  const report = heavyCpuReport(user); 
  return report;
}

await 解决的是 I/O 等待 的表达问题,它对 CPU 密集型调度毫无帮助。在单线程模型里跑高密度计算,依然会引发全盘卡顿,你依然需要求助于 Worker Thread 或独立的进程池。

4. await 会制造交错点,共享状态可能在中途改变

同步代码天然具有原子性的表象,但在 async/await 中,每一个 await 关键字都是一个让出 CPU 执行权的"出让点"

scss 复制代码
async function transfer(account, amount) {
  if (account.balance >= amount) {
    await audit(account.id, amount); // 让出点!在等待期间,其他任务可能进来把余额扣光了
    account.balance -= amount;      // 恢复执行:此时余额已处于非预期的透支状态
  }
}

每个 await 之后,状态可能已经变了。这要求开发者在处理共享状态时,必须打起十二分精神去处理并发锁或本地状态快照。

5. 持锁 await 会带来死锁风险

如果在持有并发锁的期间 await 了一个耗时极长的 I/O,整个系统的进度可能会直接陷入死锁(Futurelocks)。

csharp 复制代码
async function updateUser(userId) {
  await lock.acquire(userId);
  try {
    const user = await getUser(userId); // 任务 A 拿着锁等待 I/O,任务 B 想拿同一把锁 => 任务 B 被完全阻塞
    user.name = "new name";
    await saveUser(user);
  } finally {
    lock.release(userId); // 如果异步挂起、取消、异常处理不当,锁可能长期无法释放
  }
}

6. 取消和超时仍然不是自然的

async/await 让等待的写法好看了,但它并没有在底层自动解决"请求取消"的逻辑。以常见的前端连续搜索为例:

scss 复制代码
async function search(keyword) {
  const result = await fetch(`/api/search?q=${keyword}`);
  render(await result.json());
}
search("a"); search("ab"); search("abc");

如果三个请求同时发出,由于网络波动,返回顺序可能是:abc(最新) 先返回并渲染,而 a(最旧) 后返回。旧的数据会无情地覆盖掉最新的正确结果。

为了修复这个竞态 Bug,你必须显式引入极为繁琐的 AbortController 链条:

ini 复制代码
let currentController;

async function search(keyword) {
  if (currentController) currentController.abort(); // 取消上一次未完成的请求

  const controller = new AbortController();
  currentController = controller;

  try {
    const result = await fetch(`/api/search?q=${keyword}`, { signal: controller.signal });
    const data = await result.json();
    if (currentController === controller) { render(data); } // 确保是最新的才渲染
  } catch(err) {
    // 还需要额外处理由 abort 触发的异常...
  }
}

只要中间任何一层的协同调用忘了传递 signal,整条取消链就会瞬间断裂。

7. 并发错误语义仍然复杂

错误处理看似回到了 try/catch ,但当你面对由 Promise.all 聚合的并发任务时,try/catch 依然无法替你做决定:哪些子项错误是致命的?哪些是可以降级或重试的? 复杂的异步边界处理,依旧逃不掉。


尾声:未来的第四波浪潮在哪?

回顾这二十年的技术演进,会发现一个清晰却有些无奈的规律:

异步浪潮 解决的核心痛点 引入的新成本
Callback 摆脱"一连接一线程"的资源枯竭 控制流反转、调用栈丢失、回调地狱
Promises 拉平嵌套、集中错误处理、值化数据依赖 一次性语义限制流场景、断链吞错、类型分裂
Async/Await 恢复线性可读性、异常处理自然化 顺序陷阱(并发劣化)、函数着色税、交错状态风暴、死锁与取消断链风险

每一波浪潮都让"编写单个异步函数"的局部体验变得完美,却让"维护大型代码库、保障并发性能"的全局体验变得复杂。正因为看清了 Async/await 带来的高昂代价,许多现代语言设计者开始坚定地拒绝这一路线,选择在运行时或编译器层面绕道而行:

  • Go 语言: 坚守 goroutine,将复杂度收拢进更重的运行时,换取完全无函数着色、天生支持顺序写法的并发世界。
  • Java (Project Loom): 在 Java 21 中引入虚拟线程(Virtual Threads) ,让轻量级线程具有和常规传统线程一模一样的行为,代码不需要做任何红蓝颜色的改变。
  • Zig 语言: 从编译器层面切掉 async/await 关键字,转而围绕 I/O 接口参数进行重建,使其降级为普通的库函数。

从回调到 Promise 再到 Async/await,那些从问 "我们如何管理并发执行?" 开始的方法,似乎不断在抽象层的每个级别产生新问题。高并发这场战争依然漫长,永远不要把并发的复杂性,寄托在某一个简单的关键字糖衣之上。

本文使用 markdown.com.cn 排版

相关推荐
要阿尔卑斯吗3 小时前
企业级 RAG 系统的文件标签管理:三层架构与层级优化实战
后端
要阿尔卑斯吗3 小时前
Agent开发之为什么有了LangChain4j框架,我们却不能直接使用它?——桥接层设计详解
后端
用户7713970207063 小时前
从CMD到PowerShell:一个.NET开发者的命令行进化之路
后端
祎雪双十Gy3 小时前
从 DataX 的配置加载说起:我用 FastJson2 做了一个轻量级动态配置管理库
java·后端
Csvn5 小时前
Nginx 配置与运维管理 — 从安装到 SSL 反向代理
后端
AskHarries5 小时前
网页自动化助手:从需求到 Browser 执行链路
程序员
mqcode6 小时前
若依框架做大了怎么办?多模块 Maven 拆分的完整指南
后端
用户40269244819086 小时前
CRMEB Pro 新增后台接口全链路:路由、权限、验证器、返回格式一次讲清
前端·后端
考虑考虑6 小时前
Java实现hmacsha1加密算法
java·后端·java ee