Skip to content

async、并发组合与取消:让等待有明确边界 ​

async/await 让 Promise 链能按步骤阅读,但不会消除异步间隙。看到每个 await,都应问:等待期间谁可以继续运行?恢复时之前的状态还可靠吗?

await 暂停当前异步函数 ​

js
async function label() {
  console.log('函数开始');
  const n = await 7;
  console.log('恢复', n);
  return n * 2;
}
const result = label();
console.log('调用方继续');
result.then(console.log);
// 函数开始 → 调用方继续 → 恢复 7 → 14

异步函数先执行到第一个暂停点,立即向调用方返回 Promise。即使 await 的是普通值,后续代码也异步恢复。函数 return 决定成功结果,未捕获的 throw 决定拒绝。try { await work(); } catch ... 可以处理 work 的拒绝;仅 return work() 而没有 await,拒绝可能发生在当前 try 已退出之后。

return 一个 Promise 与在 try 里 await 它 ​

js
const fail = () => Promise.reject(new Error('失败'));
async function relay() {
  try { return fail(); }
  catch { return '本地恢复'; }
}
async function recover() {
  try { return await fail(); }
  catch { return '本地恢复'; }
}
console.log(await relay().catch(() => '外层恢复')); // 外层恢复
console.log(await recover()); // 本地恢复

relay 返回的是一个未来会失败的 Promise,当前 try 没有发生同步 throw,因此本地 catch 不执行;异步函数返回的结果随后跟随它拒绝。recover 在 await 点等待,拒绝以异常完成恢复到这个位置,仍处于 try 的语义范围内,所以本地捕获。若完全不需要本地处理或 finally 等待资源释放,直接返回可以表达结果转交;需要处理等待失败时,await 就有明确意义。

按依赖决定串行或并发 ​

第二项需要第一项结果时写连续 await。彼此独立的任务应先启动,再共同等待。先写 await a() 再写 await b() 是串行启动;Promise.all([a(), b()]) 会先求值并启动两项。

组合方法什么时候完成结果
all全成功,或出现首次拒绝按输入顺序排列的值;或拒绝原因
allSettled全部落定每项的 status 与 value/reason
race首项落定首项的成功值或拒绝原因
any首项成功;或全拒绝首个成功值;或 AggregateError

空输入时,all/allSettled 得到空数组,any 拒绝,race 一直 pending。任一组合方法都不会自动取消其余工作。Promise.all 的结果顺序是输入顺序,不是完成顺序。

完成顺序可以反过来,结果顺序仍保持输入位置 ​

js
const pending = new Map();
function start(name) {
  console.log('启动', name);
  return new Promise(resolve => pending.set(name, resolve));
}
const combined = Promise.all([start('甲'), start('乙')]);
pending.get('乙')('乙先完成');
pending.get('甲')('甲后完成');
console.log((await combined).join(',')); // 甲后完成,乙先完成

all 调用前先求值两个参数表达式,两个 start 都已执行。它给每项保留输入索引,因此乙先完成仍放在第二格。若传入 [() => start('甲'), () => start('乙')],all 接到的是两个函数值,不会替我们调用它们。组合器接收结果来源,不是任务执行器。

同一批工作,四种不同的完成条件 ​

两个镜像都能提供同一份内容,第一处失败不应阻止另一处成功,这时需要 any;两个文件缺一不可则需要 all;要生成逐项诊断报告则需要 allSettled;只关心谁先给出结果或错误才用 race。先说清需求,再选择组合器。

js
const failure = new Error('甲不可用');
const a = Promise.reject(failure);
const b = Promise.resolve('乙内容');
const all = Promise.all([a, b]).catch(error => error);
const any = Promise.any([a, b]);
const race = Promise.race([a, b]).catch(error => error);
const report = await Promise.allSettled([a, b]);
console.log(await all === failure, await any, await race === failure);
// true 乙内容 true
console.log(report[0].status, report[1].value); // rejected 乙内容

这里两项在调用组合器时已经落定,所以处理器按注册与微任务规则接续,排在前面的已拒绝项决定这次 race;真实未完成请求则取决于谁先落定,不能套用数组首项获胜。allSettled 的失败信息位于 reason,成功信息位于 value,先看 status 再取相应字段。any 全部失败时抛 AggregateError,其 errors 收集各项失败原因;它仍没有取消其他任务的权力。

超时必须区分“停止等”与“停止做” ​

把请求与计时器 Promise 放进 race,只决定外层先得到哪个结果;落败请求仍可能占用连接、更新缓存或执行服务端操作。真正取消要把信号传给支持取消的工作。

下面先用可以独立运行的延时操作理解取消协议:

js
function delay(ms, signal) {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) { reject(signal.reason); return; }
    const cleanup = () => signal?.removeEventListener('abort', abort);
    const id = setTimeout(() => { cleanup(); resolve(); }, ms);
    function abort() {
      clearTimeout(id);
      cleanup();
      reject(signal.reason);
    }
    signal?.addEventListener('abort', abort, { once: true });
  });
}
const controller = new AbortController();
delay(1000, controller.signal).catch(error => console.log(error.name));
controller.abort(); // AbortError

先检查已取消信号,避免注册在事件发生之后永远等不到;成功与取消都清理监听器;取消时撤掉计时器。AbortSignal 是协作协议,工作必须主动支持它。CPU 密集循环若一直阻塞,就连取消事件也没有处理机会。

网络请求可用 fetch(url, { signal })。信号还会影响响应体读取。HTTP 404/500 本身不会让 fetch 拒绝,应检查 response.ok。支持相应 API 的环境还可用 AbortSignal.timeout(ms) 与 AbortSignal.any([...]) 组合超时、用户取消;目标运行环境必须先确认支持情况。取消请求也不保证撤销服务端已经完成的写入。

谁创建 controller,谁拥有结束这组工作的权力 ​

上层创建控制器,把 signal 分发给需要服从同一寿命的工作;下层一般只收 signal。这样下层可以观察取消,却不能随便中止它的兄弟任务。每次独立操作应有自己的控制器;已经取消的 signal 不会复位,再用它意味着立即取消。

js
const controller = new AbortController();
function observe(name, signal) {
  if (signal.aborted) return `${name}:已取消`;
  signal.addEventListener('abort', () => {
    console.log(name, signal.reason);
  }, { once: true });
}
observe('读取', controller.signal);
observe('解析', controller.signal);
controller.abort('页面已离开'); // 读取 页面已离开;解析 页面已离开
console.log(observe('再次读取', controller.signal)); // 再次读取:已取消

reason 可以是任意 JavaScript 值,本例是字符串,所以不能无条件假设 error.name === 'AbortError'。默认 abort() 才通常得到名为 AbortError 的 DOMException。判断取消最好结合自己掌握的 signal 和原因约定,而不是把所有同名业务错误都吞掉。

共享下载又是另一种所有权:两个页面组件都在等待同一个底层请求,某个组件离开可能只应停止它自己的等待,而不是取消另一个组件仍需要的下载。共享操作的拥有者可以计算剩余订阅者,最后一位离开后再取消;或者从一开始就不共享请求。是否共享与谁有权取消,需要接口先说清楚。

超时作用域也要释放自己的计时器 ​

下面只负责给协作式操作分配超时信号。它不承诺强行终止不理会信号的工作:

js
async function withinTimeout(run, ms) {
  const controller = new AbortController();
  const timer = setTimeout(() => {
    controller.abort(new Error('超过等待时间'));
  }, ms);
  try { return await run(controller.signal); }
  finally { clearTimeout(timer); }
}
const value = await withinTimeout(async signal => {
  signal.throwIfAborted();
  return 9;
}, 1000);
console.log(value); // 9

这里保留 return await,使 finally 在 run 完成后才执行。改成 return run(...),finally 会在交出 Promise 后立即撤掉计时器,超时就失效了。反过来,如果正常完成不清理计时器,它仍会活到到期,保留控制器等引用,在 Node 中还可能延迟进程退出。

如果 run 忽略 signal,这个包装不会准时拒绝;若业务还需要准时停止等待,应另加等待竞争,并明确处理仍在运行的底层任务。不能把“外层按时返回”和“内层资源已经停止”写成同一个承诺。

取消之后还要防止旧结果覆盖新结果 ​

搜索联想中,旧请求可能比新请求晚返回。取消减少浪费,版本检查保护结果归属:

js
let version = 0;
async function latestOnly(load, publish) {
  const mine = ++version;
  const value = await load();
  if (mine === version) publish(value);
}

每次调用捕获自己的编号,提交前检查是否仍最新。这个小函数只展示成功提交保护;真实调用方仍要消费返回的 Promise,并对失败提示做相同版本判断。如果对象有多个独立搜索框,应让 version 属于各自实例,不能全局共享一个编号。

同样,跨 await 的读改写不是原子操作:先读余额、等待、再按旧余额写回,可能覆盖别人的更新。返回独立结果后集中合并、串行化同一资源操作或采用服务端并发控制,才是在修复冲突。

单线程也会丢失读改写结果 ​

js
let total = 0;
async function addLater(amount) {
  const previous = total;
  await Promise.resolve();
  total = previous + amount;
}
await Promise.all([addLater(2), addLater(3)]);
console.log(total); // 3,而不是 5

两次调用都在第一次 await 前读到 0,之后分别写 2 和 3。没有两行代码同时执行,仍然因为中途等待打断了“读取—修改—写回”的整体而丢更新。若每个操作可以独立计算,先返回自己的增量,再集中求和即可;若必须修改同一远端资源,则需要串行或远端的并发控制,不能只修改本地变量来解决。

练习 ​

  1. Promise.all 某项失败后,其他请求会自动取消吗?

  2. 为什么 delay 一开始就要检查 signal.aborted?同一 signal 是否能复位再用?两个消费者共享一个请求时,谁应该拥有取消权?

  3. 既然取消了旧请求,为什么仍保留 version 检查?

  4. 把 latestOnly 改成 createLatest(publish, reportError) 工厂,各实例有独立编号。成功和失败都只允许最新调用提交;返回函数内部处理 load 的同步抛错与异步拒绝。用可控 Promise 让旧请求最后失败,验证它不覆盖新成功;另一个工厂的请求不应影响第一个。

查看答案

用心记录每一步成长