异步编程深度 - 不仅知道“怎么用”,而且知道“为什么要这样设计 / 这是怎么实现的 / 有哪些隐患 / 怎么优化

47 阅读18分钟

异步编程团队标准文档(Team Async Coding Standard)


1 为什么需要异步?问题与历史动力

问题

  • 现代应用与 I/O 密集:网络请求、读写文件、数据库查询、UI 渲染等都是耗时的操作。同步阻塞会“卡死”执行线程(单线程环境里尤为致命)。
  • 响应性需求:UI 需要在不阻塞主线程的前提下响应用户交互。
  • 并发与吞吐:需要在单位时间内发起并处理大量 I/O 操作,效率要求更高。

如果没有异步

  • 单线程应用会被 I/O 阻塞,造成 UI 卡顿或服务阻塞。
  • 无法高效利用等待 I/O 的时间来处理其他工作(CPU 闲置变浪费)。
  • 只能串行执行,会极大降低吞吐。

异步解决了什么

  • 将 I/O 等待时间切换出去,让线程去做其他事情,从而提高吞吐与响应性。
  • 用更丰富的控制流模型(回调 / Promise / async)表达“未来的值”。

2 异步演进历程:回调 → Promise → Generator → Async/Await

回调(Callback)

  • 最早的异步风格。API 接收回调函数,在操作完成时调用。

  • 优点:简单直接,几乎所有语言都支持。

  • 缺点:

    • 回调地狱(callback hell):嵌套深,难以读写和维护。
    • 错误传播困难:需要在每层手动传错误。
    • 控制流表达力差:并行/串行/取消/超时等模式难以组合。

示例:

fs.readFile('a.txt', (err, a) => {
  if (err) throw err;
  fs.readFile('b.txt', (err, b) => {
    if (err) throw err;
    // 继续...
  });
});

Promise(ES2015)

  • 一个表示“将来某个时间点结果”的对象。核心是状态机(pending → fulfilled/rejected)和链式调用 .then()
  • 解决了回调嵌套;提供了链式错误传播和更好的组合(Promise.all 等)。
  • 内部使用“微任务(microtask)”调度回调,保证 .then 回调在当前宏任务之后立即执行(更可预测)。

优点:

  • 链式、统一的错误处理(.catch()),更易组合和抽象。
  • 对 thenable 的通用适配,使库间互操作更容易。

缺点:

  • 默认不可取消(后面讨论)。
  • 复杂并发控制仍需工具(池、限流)。

Generator + 协程(co 等)

  • Generator 提供 yield,可以暂停函数执行,把控制权返回给调度器。
  • 库(如 co)能把 yield + Promise 结合,写出“同步风格”的异步代码。
  • 是 async/await 的前身思想(手工调度器 → 自动化变成 async/await)。

优点:能写出线性看起来的异步代码。

缺点:需要运行时/库支持;语法不够直观(相比 async/await)。

Async/Await(ES2017)

  • 语法糖:在 Promise 上提供 async 函数与 await,使异步代码看起来像同步代码。
  • await 实质上是暂停函数(返回的仍是 Promise),自动把拒绝转为抛出异常。
  • 易读、易维护,但仍基于 Promise(同样存在并发控制、取消、背压等问题需要额外处理)。

总体评价:从回调→Promise→async,演进方向是提高可读性、可组合性与错误传播的便利性,同时把“事件循环的复杂性”对开发者屏蔽得越来越好——但也让人更容易忽视底层行为(microtask、顺序、竞态)。


3 Promise 的核心原理(状态机、链式调用、微任务机制)

Promise 的状态机

Promise 有三种状态:pendingfulfilledrejected。一旦从 pending 变为 fulfilledrejected,状态不可变(immutable)。
状态迁移只发生一次,这一点是 Promise 的重要语义(保证可预测性)。

核心字段(概念):

  • internal state
  • internal result (value or reason)
  • reactions list(待执行的 .then 回调队列)

resolvereject 被调用时,会:

  1. 将状态设置为 fulfilled / rejected,并设置结果值。
  2. 遍历 reactions 列表,把每个回调放入微任务队列(不直接同步调用)。
  3. 清空 reactions。

thenable / Promise Resolution Procedure(简化)

  • 当 Promise 解析一个值 x 时,如果 x 是 thenable(有 .then 函数),Promise 会尝试取 then 并把自己以微任务方式与 x 的状态关联(避免同步递归导致栈溢出)。
  • 规范要求:如果 then 在读取或调用过程中抛错,必须相应地 reject。

这一层适配使 Promise 能与各种“类 Promise”互操作。

链式调用与返回新 Promise

.then(onFulfilled, onRejected) 返回一个新的 Promise(称为 promise2),并把 onFulfilled/onRejected 的返回值用 promiseResolutionProcedure 解析到 promise2
这就是 why 链式调用可以“把返回值继续传下去”的机制。

微任务(microtasks)与调度

  • Promise 的回调是用微任务队列调度(在 ECMAScript 规范里通常称为 Job Queue / microtask queue)。
  • 微任务在当前宏任务结束时(或者在某些浏览器/环境的任务阶段切换时)立即执行,且会在执行下一个宏任务前把队列清空。
  • 结果:Promise.resolve().then(...) 的回调会在当前同步代码结束后、setTimeout 之前执行。

示例(说明顺序):

console.log('script start');

setTimeout(() => console.log('timeout'), 0);

Promise.resolve().then(() => console.log('promise'));

console.log('script end');
// 输出顺序: script start -> script end -> promise -> timeout

实现(简化伪代码,帮助理解)

class SimplePromise {
  constructor(executor) {
    this.state = 'pending';
    this.value = undefined;
    this.reactions = [];
    const resolve = (v) => { /* run resolution procedure, then schedule reactions as microtasks */ };
    const reject = (r) => { /* set rejected, schedule reactions */ };
    try { executor(resolve, reject); } catch (e) { reject(e); }
  }
  then(onFulfilled, onRejected) {
    return new SimplePromise((resolve, reject) => {
      this.reactions.push({ onFulfilled, onRejected, resolve, reject });
      // if already settled, schedule to run microtask immediately
    });
  }
}

4 Promise 静态方法详解与实现思路

Promise.all(iterable)

  • 语义:所有 Promise 都成功时返回一个包含所有结果数组的新 Promise;只要有一个 reject 就立即 reject(短路)。
  • 常见问题:当其中某些 Promise 永远不 resolve(挂起)时,all 会等待直到超时或外部取消(因此要注意超时/取消策略)。

Promise.allSettled(iterable)

  • 语义:等待所有 Promise 完成(成功或失败),返回每个项的状态描述 {status: 'fulfilled', value}{status: 'rejected', reason}
  • 场景:需要收集每个异步操作的结果,不想因为一项失败而中断整个集合。

Promise.race(iterable)

  • 语义:第一个完成(fulfilled 或 rejected)的 Promise 决定结果。
  • 场景:超时机制(race 一个网络请求和一个 setTimeout Promise)。

Promise.any(iterable)

  • 语义:第一个 fulfilled 的 Promise 返回其值;如果全部都 rejected,则返回一个 AggregateError
  • 场景:多个备份源(CDN)中选择第一个成功返回的。

实现要点

  • 关键都是保证“短路/收集/等待”的正确性,以及处理空 iterable 的边界情况。
  • 实现时需要记录计数,并在每个子 Promise settled 时做相应处理;使用微任务队列保证行为与规范一致。

5 事件循环机制(浏览器 vs Node.js、宏任务 vs 微任务)

异步行为的“最终执行时机”由事件循环(Event Loop)决定。不同环境实现细节不同,但核心思想类似:有一系列任务队列与阶段,主线程不断从中取任务执行。

宏任务(macrotask / task)

  • 例子:setTimeoutsetInterval 回调、I/O 回调、UI 渲染回调(在浏览器里)等。
  • 每次事件循环迭代会执行一个或多个宏任务,然后处理微任务队列,再渲染(浏览器)等。

微任务(microtask)

  • 例子:Promise 回调(.then)、queueMicrotaskMutationObserver 回调、Node 的 process.nextTick(特殊)等。
  • 特点:在当前宏任务结束后立即执行,且会一口气把所有微任务执行完(执行过程中新增微任务也会被继续执行),然后才会开始下一宏任务或渲染。

浏览器示例顺序(简化):

  1. 执行一个宏任务(例如脚本或事件回调)
  2. 执行所有微任务(直到队列为空)
  3. 渲染(如果需要)
  4. 下一个宏任务

Node.js 事件循环(libuv)——6 个常见阶段(简化)

Node 的事件循环比浏览器更复杂,libuv 定义了多个阶段(每次 loop 迭代会按顺序经过这些阶段):

  1. timers:到期的 setTimeoutsetInterval 回调
  2. pending callbacks:一些系统操作的回调(如 TCP 错误回调)
  3. idle, prepare:内部使用
  4. poll:检索新的 I/O 事件,执行几乎所有 I/O 回调
  5. checksetImmediate 回调运行在这里
  6. close callbackssocket.on('close')

另外:

  • process.nextTick 有独立队列,会在每个阶段之后立即运行(优先于微任务)。
  • 微任务(Promise callbacks)在每个阶段的结尾处运行(但在 Node 的实现中要注意 process.nextTick 的特别优先级)。

关键影响(为什么要关心)

  • 任务的调度时机影响顺序、性能与体验(比如 UI 渲染、动画流畅性)。
  • 微任务滥用会导致“主线程延迟渲染”或“事件循环饥饿”(因为微任务会一直执行直到清空,阻止渲染和下一宏任务)。
  • 了解 Node 阶段有助于正确使用 setImmediatesetTimeout(...,0)process.nextTick 以达到期望的执行顺序。

小实验(浏览器)

console.log('start');

setTimeout(() => console.log('timeout'), 0);

queueMicrotask(() => console.log('microtask'));

Promise.resolve().then(() => console.log('promise'));

console.log('end');

// 典型输出: start -> end -> microtask -> promise -> timeout
// (microtask 和 promise 都是微任务,顺序取决于加入队列的时机)

6 常见计时与回调 API 的行为差异

setTimeout / setInterval

  • 不是精确的定时器,最小延迟受事件循环和环境限制(浏览器后台标签页最小 1000ms 限制等)。
  • setInterval 需要注意:如果一个回调运行时间超过间隔,会导致回调堆积或重叠(需使用递归 setTimeout 或 guard)。

requestAnimationFrame (浏览器)

  • 在浏览器的渲染循环之前回调,适合做动画更新(保证在渲染前计算、减少抖动)。
  • rAF 的回调频率受显示器刷新率(通常 60Hz)和是否后台标签页影响。

requestIdleCallback(浏览器)

  • 用于尽可能在空闲时间执行低优先级工作(非所有浏览器都有)。
  • 注意:不能依赖精确时间,任务可能被截断以防耗时过长。

MessageChannel / postMessage

  • 可用于跨任务队列调度(例如把工作推到 macrotask 或微task,取决实现)。
  • MessageChannelport.postMessage 在某些环境下可以用来减少 setTimeout 的延迟(更精确的宏任务排队)。

MutationObserver

  • 在 DOM 变更后作为微任务回调触发,可用于实现微任务(早期 polyfill 技巧)。
  • 常被用作观察 DOM 变化并在当前宏任务后立即处理。

Node 特殊

  • process.nextTick:优先级高于 Promise 微任务(在 Node 中的特殊微任务队列),会在当前操作完成后立即执行,甚至在 Promise 回调之前。
  • setImmediate:在 event loop 的 check 阶段执行,常用于 I/O 回调之后。

7 高级异步模式(并行、串行、限流、重试、退避、竞态处理)

并行 vs 串行

  • 并行:同时发起 N 个 Promise(Promise.all 或自己保有数组)。优点:最快完成时间;缺点:资源占用高(并发数过大可能导致资源枯竭)。
  • 串行:一个接一个执行(for (const p of list) await p())。优点:控制资源;缺点:总时长是各项之和。

限流(并发池 / promise pool)

  • 当需要发起大量异步请求(比如爬虫或批量 API 调用),使用并发池限制并发数。
  • 简洁实现思路:维护一个活跃计数,发起初始 N 个任务,每有任务完成就从队列取下一个。

示例:简单 Promise 池实现

async function promisePool(tasks, concurrency = 5) {
  const results = [];
  let i = 0;
  async function worker() {
    while (i < tasks.length) {
      const idx = i++;
      try {
        results[idx] = await tasks[idx]();
      } catch (e) {
        results[idx] = { error: e };
      }
    }
  }
  // 启动 concurrency 个 worker
  await Promise.all(new Array(concurrency).fill(0).map(worker));
  return results;
}

重试与退避(backoff)

  • 网络请求等短暂性错误可通过重试策略改善成功率。
  • 常见:固定延迟、指数退避(exponential backoff)、抖动(jitter)避免“雪崩”。

示例:指数退避 + 抖动

function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

async function retry(fn, retries = 5, baseDelay = 100) {
  for (let i = 0; i < retries; i++) {
    try { return await fn(); }
    catch (err) {
      if (i === retries - 1) throw err;
      const delay = baseDelay * (2 ** i) + Math.random() * baseDelay;
      await sleep(delay);
    }
  }
}

任务取消与竞态(race conditions)

  • 竞态:多个异步并发时,顺序依赖或共享状态更新导致的不可预测问题。

  • 竞态处理策略:

    • 使用乐观锁 / 版本号(每次请求带版本,较旧响应忽略)。
    • AbortController、标志位或可取消结构避免对撤销操作无意义的处理。
    • 在 UI 上禁用重复提交。

8 可取消的 Promise:为什么困难 & 现代实践

为什么 Promise 本身不可取消?

  • Promise 代表“未来值”,一旦创建,底层执行逻辑(executor)就开始运行,规范没有提供内置取消 API(设计时选择避免复杂性)。
  • 如果外部取消一个 Promise,但内部仍在做 I/O(例如网络请求),就需要底层 API 支持真正中断(例如 XHR.abort 或 fetch 的 AbortSignal)。

现有模式(实践)

  1. AbortController / AbortSignal(现代 Web + Node 支持逐步完善)

    • API:const controller = new AbortController(); fetch(url, { signal: controller.signal }); controller.abort();
    • 优点:标准化、与 fetch、stream 等良好集成。
  2. 自定义取消 token(早期)

    • 传入一个可观察的 token(回调/事件/Promise),内部检查 token 是否已取消。
  3. race 方式

    • Promise.race([originalPromise, cancelPromise]),cancelPromise reject 时整体 rejected。但底层不会停止实际工作,只是外部不再等待结果(可能造成额外资源浪费)。
  4. 可取消封装(需要底层支持)

    • 比如对 WebSocket、XHR、child process 等有 cancel/kill API 的操作,可以在外部把 cancel 请求映射到底层 API。

示例:使用 AbortController

const controller = new AbortController();
fetch('/api/long', { signal: controller.signal })
  .then(res => res.json())
  .catch(err => {
    if (err.name === 'AbortError') console.log('请求被取消');
    else throw err;
  });

// 取消
controller.abort();

Promise 可取消的最佳实践

  • 优先使用底层可中断的 API(fetch + AbortSignal、ReadableStream.cancel、worker.terminate 等)。

  • 对于无法中断的操作,通过“忽略结果 + 释放引用”来避免内存泄露,而不是尝试强行停止:

    • 在取消时把 handler、引用和回调置空;避免继续处理过期结果。
  • 任何取消都应当有明确语义:是“逻辑上不再关心”还是“强制停止底层工作”?


9 异步迭代器 / for-await-of / 流式异步处理与背压

异步迭代器(AsyncIterator)

  • 形如 { next(): Promise<{value, done}> }。使得流式数据(例如从网络读分块或从数据库拉取大数据)可以逐步消费。
  • for await (const item of asyncIterable) { ... } 语法用于消费异步迭代器。

场景:

  • 逐行读取大文件
  • 处理流式响应(如 fetch 的 response.body.getReader()
  • 消息队列逐条处理

背压(Backpressure)

  • 问题:生产者速度 > 消费者速度,会导致内存增长或资源耗尽。

  • 解决策略:

    • 控制生产速率(限速、节流)。
    • 使用流(streams)和协议支持底层暂停/恢复(例如 Node stream 的 pause()/resume()、ReadableStream 的 controller)。
    • 使用固定大小缓冲区 + 队列策略(丢弃、覆盖、阻塞生产)。

示例:异步生成器与消费

async function* lines(reader) {
  let buffer = '';
  while (true) {
    const { value, done } = await reader.read();
    if (done) {
      if (buffer) yield buffer;
      break;
    }
    buffer += new TextDecoder().decode(value);
    let parts = buffer.split('\n');
    buffer = parts.pop();
    for (const part of parts) yield part;
  }
}

for await (const line of lines(reader)) {
  // 逐行处理:如果处理慢,读取可以暂停(取决于 reader 实现)
}

10 Web Workers / Worker Threads / Service Worker

Web Worker(浏览器)

  • 把脚本运行在独立线程,适合 CPU 密集型任务(计算、加密、图像处理)。
  • 通信通过 postMessage(序列化/克隆成本)。
  • SharedArrayBuffer + Atomics 提供共享内存方案(更复杂,但性能更优)。

类型:

  • DedicatedWorker(页面专用)
  • SharedWorker(可被多个页面共享)
  • Service Worker(拦截网络请求、实现离线缓存、后台同步)

Node.js Worker Threads

  • Node 提供 Worker Threads(worker_threads)用于在多线程中运行 JS 以应对 CPU 密集任务。
  • 与进程相比,线程通信成本更低(SharedArrayBuffer、MessagePort)。

使用场景建议

  • CPU 密集:使用 Worker / Worker Thread;避免阻塞主线程。
  • I/O 密集:不一定需要 Worker(事件循环能很好处理),除非需要预处理大数据。

11 调试、测试与性能优化实践

调试技巧

  • 未处理的 rejection:添加 window.onunhandledrejection / process.on('unhandledRejection'),在开发环境中把它们变成错误抛出,避免静默失败。
  • 异步堆栈:某些平台支持 async stack traces(Node >= 某版本),能更容易定位源头。Chrome DevTools 的 Performance/堆快照也有帮助。
  • 日志与追踪 ID:跨多个异步调用传递 traceId / requestId,便于聚合日志。
  • 时间测量:用 performance.now()console.time 测量关键路径。

性能上的常见优化

  • 减少微任务堆积:避免在微任务中做大量计算或无限循环生成微任务。
  • 批量操作:合并多个小任务(例如 DOM 更新)到单次宏任务内(requestAnimationFrame 或 debounce)。
  • 限制并发:使用池或限流控制并发,避免触发过多连接或内存占用。
  • 避免内存泄露:解除不必要的回调引用(例如事件监听器、持续 pending Promise 的引用)。
  • 使用流:对于大数据,使用流和逐块处理,而不是一次性加载到内存。

测试建议

  • 测试异步逻辑要确保:

    • 明确等待(return Promise 或 await)。
    • 模拟超时与取消情形。
    • 测试并发边界(N=0, N=1, N=large)。
  • 在单元测试中 mock 时间(如使用 sinon.useFakeTimers())以测试超时与节流逻辑。


12 设计模式与工程化建议

API 设计

  • 对外 API 提供明确的取消语义(例如接受 AbortSignal 或返回带 .cancel() 的对象)。
  • 将“重试/超时/限流”作为可插拔中间件(类似于 HTTP 客户端中间件),方便复用。
  • 错误语义清晰:不要返回 null 表示错误,使用 Error 类或 Result 对象({ok: true, value} / {ok:false, reason})。

资源管理

  • 所有启动的资源(定时器、监听器、workers)都应在对应生命周期结束时释放。
  • 在组件或服务销毁时确保调用取消逻辑(例如 controller.abort()worker.terminate())。

监控与可观测性

  • 对长时间运行的异步任务打点(start/end/fail/timeout)。
  • 监控未处理 rejection、超时率、重试次数、平均响应时间。

13 高级扩展话题

Reactive Programming(RxJS)

  • Reactive 编程把异步看成“流”,提供丰富的操作符(map, filter, merge, concat, switchMap)。
  • 优点:表达复杂时间序列变换、合并与取消策略(switchMap 自动取消旧流)非常方便。
  • 缺点:学习曲线较陡,过度使用会使代码难以理解。

协程与轻量线程

  • Generator + scheduler 可以实现协程;在 JS 世界,async/await 已是更主流的协程形式。
  • 在分布式系统或多进程场景下,需要考虑消息语义、幂等性与一次性处理(exactly-once vs at-least-once)。

分布式异步(消息队列)

  • 在微服务间,异步通常通过消息队列(Kafka、RabbitMQ)实现。
  • 需要重点考虑:幂等性、重放、消费位点(offset)、事务边界、延迟与可观测性。

14 练习题、速查表与常见陷阱

练习题(自行完成)

  1. 实现一个 promisePool,限制并发为 3,处理任务列表并返回结果数组(见上文示例)。
  2. AbortController 封装一个可取消的 fetch + retry(支持超时)。
  3. 实现 Promise.allSettled 的 polyfill。
  4. 写一个 demo 展示微任务 vs 宏任务的执行顺序,并解释输出顺序。

常见陷阱(清单)

  • 在微任务中做长计算导致渲染阻塞或事件循环饥饿。
  • 忽略 Promise 的返回值导致未处理 rejection(链断了)。
  • 误用 await 把并行变成串行:for (const item of items) await do(item); → 改为 await Promise.all(items.map(do))(注意资源限制)。
  • 通过 Promise.race 取消但不停止实际工作,造成资源泄露。
  • 使用 setInterval 导致回调堆叠,优先使用递归 setTimeout 或在回调末尾再 schedule。
  • 忽视 Node 的 process.nextTicksetImmediate 的差异,导致 callback 顺序与预期不一致。

15 实用代码片段集(速查)

并发限流(p-limit 风格)

function pLimit(concurrency = 5) {
  const queue = [];
  let active = 0;
  const next = () => {
    if (queue.length === 0 || active >= concurrency) return;
    active++;
    const { fn, resolve, reject } = queue.shift();
    fn().then(resolve, reject).finally(() => { active--; next(); });
  };
  return (fn) => new Promise((resolve, reject) => {
    queue.push({ fn, resolve, reject });
    next();
  });
}

超时包装(Promise.race)

function withTimeout(promise, ms, message = 'Timeout') {
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error(message)), ms)
  );
  return Promise.race([promise, timeout]);
}

取消示例(AbortController + fetch)

function cancellableFetch(url, { signal } = {}) {
  return fetch(url, { signal }).then(res => {
    if (!res.ok) throw new Error(res.statusText);
    return res.json();
  });
}

// 使用
const controller = new AbortController();
cancellableFetch('/api', { signal: controller.signal })
  .then(console.log)
  .catch(err => {
    if (err.name === 'AbortError') console.log('cancelled');
    else console.error(err);
  });
// 取消
controller.abort();

异步迭代器示例(生成器)

async function* asyncRange(n) {
  for (let i = 0; i < n; i++) {
    await sleep(10); // 假设异步获取
    yield i;
  }
}

(async () => {
  for await (const v of asyncRange(5)) {
    console.log(v);
  }
})();

16 何时选择哪个工具(决策树)

  • 只是简单的单次异步:Promise / async-await
  • 需要并行但不想阻塞:Promise.all / Promise.race(结合超时)。
  • 需要限流并发:Promise 池 / p-limit。
  • CPU 密集任务:Worker / WorkerThreads。
  • 需要取消:使用支持 AbortSignal 的底层 API;或设计可取消协议。
  • 流式大数据:ReadableStream / async iterator / Node streams(并处理背压)。
  • 复杂事件流转换:考虑 RxJS(如果团队接受其范式)。
  • 跨组件或跨服务跟踪:增加 traceId 并在日志/错误中传递。

17 总结(要点回顾)

  • 异步的核心是把等待变成可组合的“未来” ,让主线程可以继续做别的事。
  • Promise/async/await 是对回调的显式改进,但不会自动解决所有并发、取消和背压问题——需要工程层面的设计。
  • 事件循环与微任务/宏任务的细节直接影响顺序与性能:务必理解它们以避免时序 bug。
  • 关注资源管理(取消、释放、回收)比只关心“让它工作”更重要,特别在长期运行的服务中。
  • 工程实践:明确 API 语义(取消、重试、超时)、监控/日志、限流与防护,才能让异步代码既快速又可靠。