async、并发组合与取消:让等待有明确边界
async/await 让 Promise 链能按步骤阅读,但不会消除异步间隙。看到每个 await,都应问:等待期间谁可以继续运行?恢复时之前的状态还可靠吗?
await 暂停当前异步函数
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 它
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 的结果顺序是输入顺序,不是完成顺序。
完成顺序可以反过来,结果顺序仍保持输入位置
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。先说清需求,再选择组合器。
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,只决定外层先得到哪个结果;落败请求仍可能占用连接、更新缓存或执行服务端操作。真正取消要把信号传给支持取消的工作。
下面先用可以独立运行的延时操作理解取消协议:
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 不会复位,再用它意味着立即取消。
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 和原因约定,而不是把所有同名业务错误都吞掉。
共享下载又是另一种所有权:两个页面组件都在等待同一个底层请求,某个组件离开可能只应停止它自己的等待,而不是取消另一个组件仍需要的下载。共享操作的拥有者可以计算剩余订阅者,最后一位离开后再取消;或者从一开始就不共享请求。是否共享与谁有权取消,需要接口先说清楚。
超时作用域也要释放自己的计时器
下面只负责给协作式操作分配超时信号。它不承诺强行终止不理会信号的工作:
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,这个包装不会准时拒绝;若业务还需要准时停止等待,应另加等待竞争,并明确处理仍在运行的底层任务。不能把“外层按时返回”和“内层资源已经停止”写成同一个承诺。
取消之后还要防止旧结果覆盖新结果
搜索联想中,旧请求可能比新请求晚返回。取消减少浪费,版本检查保护结果归属:
let version = 0;
async function latestOnly(load, publish) {
const mine = ++version;
const value = await load();
if (mine === version) publish(value);
}每次调用捕获自己的编号,提交前检查是否仍最新。这个小函数只展示成功提交保护;真实调用方仍要消费返回的 Promise,并对失败提示做相同版本判断。如果对象有多个独立搜索框,应让 version 属于各自实例,不能全局共享一个编号。
同样,跨 await 的读改写不是原子操作:先读余额、等待、再按旧余额写回,可能覆盖别人的更新。返回独立结果后集中合并、串行化同一资源操作或采用服务端并发控制,才是在修复冲突。
单线程也会丢失读改写结果
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。没有两行代码同时执行,仍然因为中途等待打断了“读取—修改—写回”的整体而丢更新。若每个操作可以独立计算,先返回自己的增量,再集中求和即可;若必须修改同一远端资源,则需要串行或远端的并发控制,不能只修改本地变量来解决。
练习
Promise.all某项失败后,其他请求会自动取消吗?为什么 delay 一开始就要检查
signal.aborted?同一 signal 是否能复位再用?两个消费者共享一个请求时,谁应该拥有取消权?既然取消了旧请求,为什么仍保留 version 检查?
把 latestOnly 改成 createLatest(publish, reportError) 工厂,各实例有独立编号。成功和失败都只允许最新调用提交;返回函数内部处理 load 的同步抛错与异步拒绝。用可控 Promise 让旧请求最后失败,验证它不覆盖新成功;另一个工厂的请求不应影响第一个。