异步编程团队标准文档(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 有三种状态:pending、fulfilled、rejected。一旦从 pending 变为 fulfilled 或 rejected,状态不可变(immutable)。
状态迁移只发生一次,这一点是 Promise 的重要语义(保证可预测性)。
核心字段(概念):
- internal state
- internal result (value or reason)
- reactions list(待执行的
.then回调队列)
当 resolve 或 reject 被调用时,会:
- 将状态设置为 fulfilled / rejected,并设置结果值。
- 遍历 reactions 列表,把每个回调放入微任务队列(不直接同步调用)。
- 清空 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)
- 例子:
setTimeout、setInterval回调、I/O 回调、UI 渲染回调(在浏览器里)等。 - 每次事件循环迭代会执行一个或多个宏任务,然后处理微任务队列,再渲染(浏览器)等。
微任务(microtask)
- 例子:
Promise回调(.then)、queueMicrotask、MutationObserver回调、Node 的process.nextTick(特殊)等。 - 特点:在当前宏任务结束后立即执行,且会一口气把所有微任务执行完(执行过程中新增微任务也会被继续执行),然后才会开始下一宏任务或渲染。
浏览器示例顺序(简化):
- 执行一个宏任务(例如脚本或事件回调)
- 执行所有微任务(直到队列为空)
- 渲染(如果需要)
- 下一个宏任务
Node.js 事件循环(libuv)——6 个常见阶段(简化)
Node 的事件循环比浏览器更复杂,libuv 定义了多个阶段(每次 loop 迭代会按顺序经过这些阶段):
- timers:到期的
setTimeout、setInterval回调 - pending callbacks:一些系统操作的回调(如 TCP 错误回调)
- idle, prepare:内部使用
- poll:检索新的 I/O 事件,执行几乎所有 I/O 回调
- check:
setImmediate回调运行在这里 - close callbacks:
socket.on('close')等
另外:
process.nextTick有独立队列,会在每个阶段之后立即运行(优先于微任务)。- 微任务(Promise callbacks)在每个阶段的结尾处运行(但在 Node 的实现中要注意
process.nextTick的特别优先级)。
关键影响(为什么要关心)
- 任务的调度时机影响顺序、性能与体验(比如 UI 渲染、动画流畅性)。
- 微任务滥用会导致“主线程延迟渲染”或“事件循环饥饿”(因为微任务会一直执行直到清空,阻止渲染和下一宏任务)。
- 了解 Node 阶段有助于正确使用
setImmediate、setTimeout(...,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,取决实现)。
MessageChannel的port.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)。
现有模式(实践)
-
AbortController / AbortSignal(现代 Web + Node 支持逐步完善)
- API:
const controller = new AbortController(); fetch(url, { signal: controller.signal }); controller.abort(); - 优点:标准化、与 fetch、stream 等良好集成。
- API:
-
自定义取消 token(早期)
- 传入一个可观察的
token(回调/事件/Promise),内部检查 token 是否已取消。
- 传入一个可观察的
-
race 方式
Promise.race([originalPromise, cancelPromise]),cancelPromise reject 时整体 rejected。但底层不会停止实际工作,只是外部不再等待结果(可能造成额外资源浪费)。
-
可取消封装(需要底层支持)
- 比如对 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 练习题、速查表与常见陷阱
练习题(自行完成)
- 实现一个
promisePool,限制并发为 3,处理任务列表并返回结果数组(见上文示例)。 - 用
AbortController封装一个可取消的 fetch + retry(支持超时)。 - 实现
Promise.allSettled的 polyfill。 - 写一个 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.nextTick与setImmediate的差异,导致 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 语义(取消、重试、超时)、监控/日志、限流与防护,才能让异步代码既快速又可靠。