C04 并发控制与取消
C04 并发控制与取消
摘要:批量请求全部
Promise.all会瞬间打满连接数,逐条await又会让总耗时线性增长。可控做法是并发池:固定工作槽位,完成一个补一个。AbortController提供取消信号,fetch、事件监听、定时器均可共享同一信号。本文给出并发池、超时封装、输入防抖与请求去重四组实现。
关键词:并发池,AbortController,取消信号,防抖,请求去重
所属:frontend/js / C-高级 | 上一篇:C03-内存管理与性能分析 | 下一篇:C05-JavaScript工程化
1 引言
一次导入五百行数据、搜索框连续输入、页面切换时取消未完成的请求,三处需求都指向同一问题:同时进行的异步任务需要上限与中止能力。
本文回答三个问题:并发数量如何限制、进行中的任务如何取消、重复请求如何避免。范围见表 1-1。
| 项 | 本文覆盖 | 本文不覆盖 |
|---|---|---|
| 并发 | 池化执行、结果保序、错误隔离 | 服务端限流 |
| 取消 | AbortController、signal、超时合并 |
跨页面导航中断 |
| 交互 | 防抖、竞态丢弃、请求去重 | 缓存策略(D03) |
2 预备知识
| 项 | 要求 | 核对方式 |
|---|---|---|
| 环境 | Node.js 20+(内置 fetch 与 AbortSignal.timeout) |
能运行 ESM 脚本 |
| 前置 | B03 | 理解 Promise 组合 |
| 文件 | js-practice/concurrency.mjs |
编码 UTF-8 |
3 原理与实现
3.1 池化调度
并发池维护一个固定长度的工作槽位数组,每个槽位取走下一个任务,完成后继续取,直到队列为空,如图 3-1 所示。
flowchart TD
A["任务队列 tasks"] --> B["启动 min(limit, 任务数) 个工作者"]
B --> C["工作者取下一个任务"]
C --> D{"队列是否为空"}
D -->|是| E["工作者退出"]
D -->|否| F["执行任务,结果写入对应下标"]
F --> C
E --> G["全部工作者退出后返回结果数组"]
图 3-1 固定槽位的工作者反复取任务
图上可见:任一时刻最多
limit个任务在跑,结果按下标写回,因此返回顺序与入队顺序一致。
3.2 完整实现
// js-practice/concurrency.mjs;运行环境:Node.js 20+
// 1 并发池:结果保序,错误不中断其它任务
export async function pool(tasks, limit = 4) {
const results = new Array(tasks.length);
let cursor = 0;
let active = 0;
return new Promise((resolve) => {
if (tasks.length === 0) return resolve(results);
const launch = () => {
while (active < limit && cursor < tasks.length) {
const index = cursor;
cursor += 1;
active += 1;
Promise.resolve()
.then(() => tasks[index]())
.then(
(value) => { results[index] = { status: 'fulfilled', value }; },
(reason) => { results[index] = { status: 'rejected', reason }; },
)
.finally(() => {
active -= 1;
if (cursor >= tasks.length && active === 0) resolve(results);
else launch();
});
}
};
launch();
});
}
// 2 超时与外部取消合并
export function withTimeout(signal, ms) {
const timeout = AbortSignal.timeout(ms);
return signal ? AbortSignal.any([signal, timeout]) : timeout;
}
async function getJson(url, { signal, timeout = 3000 } = {}) {
const res = await fetch(url, { signal: withTimeout(signal, timeout) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
// 3 防抖 + 竞态丢弃:只保留最后一次输入的结果
export function debounceAsync(fn, wait = 300) {
let timer;
let controller;
return (...args) =>
new Promise((resolve, reject) => {
clearTimeout(timer);
controller?.abort(); // 取消上一次仍在进行的请求
controller = new AbortController();
timer = setTimeout(() => {
fn(...args, controller.signal).then(resolve, reject);
}, wait);
});
}
// 4 去重:相同 key 的进行中请求共用同一个 Promise
const inflight = new Map();
export function dedupe(key, factory) {
if (inflight.has(key)) return inflight.get(key);
const promise = factory().finally(() => inflight.delete(key));
inflight.set(key, promise);
return promise;
}
// 验证
const sleep = (ms, value, fail = false) =>
new Promise((resolve, reject) => setTimeout(() => (fail ? reject(new Error('boom')) : resolve(value)), ms));
const tasks = [1, 2, 3, 4, 5, 6, 7].map((n) => () => sleep(20 * n, n, n === 4));
const t0 = performance.now();
const out = await pool(tasks, 3);
console.log(out.map((r) => (r.status === 'fulfilled' ? r.value : 'ERR'))); // [1,2,3,'ERR',5,6,7]
console.log('elapsed', Math.round(performance.now() - t0)); // 约 120
const search = debounceAsync(async (q, signal) => `result:${q}`, 50);
const p1 = search('a').catch((e) => e.name);
console.log(await search('ab')); // result:ab
console.log(await p1); // AbortError,被取消
const first = dedupe('k', () => sleep(10, 'v1'));
const second = dedupe('k', () => sleep(10, 'v2'));
console.log(first === second, await second); // true v1
并发策略对比见表 3-1。
| 策略 | 峰值并发 | 总耗时 | 适用场景 |
|---|---|---|---|
逐条 await |
1 | 各任务之和 | 强依赖前一步结果 |
Promise.all |
全部 | 最慢任务 | 少量请求、可承受峰值 |
| 并发池 | 固定 limit |
约等于总耗时除以并发 | 批量导入、大批量刷新 |
allSettled |
全部 | 最慢任务 | 需要逐项结果 |
取消能力对照见表 3-2。
| API | 是否支持 signal |
取消后行为 |
|---|---|---|
fetch |
是 | 拒绝,name 为 AbortError |
addEventListener |
是({ signal }) |
自动移除监听器 |
setTimeout |
否 | 需手动 clearTimeout |
AbortSignal.timeout |
— | 到点触发,不可重置 |
AbortSignal.any |
— | 任一信号触发即中止 |
4 验证与讨论
- 执行脚本,确认并发为 3 时总耗时约 120 毫秒,且第 4 项为
ERR。 - 把
limit改为1,确认总耗时接近各任务之和。 - 连续调用
search三次,确认前两次以AbortError失败,只有最后一次返回结果。 - 同时用不同参数调用
dedupe('k', ...),确认拿到同一个 Promise 实例。
| 检查项 | 预期结果 | 说明 |
|---|---|---|
| 并发上限 | 同时最多 limit 个 |
槽位控制 |
| 结果保序 | 输出顺序与入队一致 | 按写下标 |
| 错误隔离 | 单项失败不影响其余 | 逐项记录状态 |
| 竞态丢弃 | 旧请求被中止 | abort() 上一次 |
讨论与局限。 中止只停止客户端的等待,服务端可能仍在处理,需要服务端配合幂等键。另,AbortSignal.any 在较旧运行时不支持,可退化为手动监听两个信号并共用同一个 controller。
5 常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 批量请求打满连接 | 直接 Promise.all |
改用并发池 |
| 取消后仍报未捕获拒绝 | 未处理 AbortError |
在 catch 中判断 err.name |
| 搜索结果闪回旧内容 | 旧请求晚于新请求返回 | 取消旧请求或按请求序号丢弃 |
| 重复提交生成两条记录 | 未去重 | 用 dedupe 共用进行中的 Promise |
| 超时后请求仍在跑 | 只做了 race |
把 signal 传给 fetch |
6 结论
| 序号 | 结论 | 依据 |
|---|---|---|
| 1 | 批量任务用固定槽位并发池 | 图 3-1 |
| 2 | 池化结果按下标写回,天然保序 | 3.2 节 |
| 3 | 取消统一走 AbortController |
表 3-2 |
| 4 | 相同请求共用进行中的 Promise | 3.2 节 |
- 上一篇:C03-内存管理与性能分析
- 下一篇:C05-JavaScript工程化
- 返回:C00-总结 | 科目 README
参考文献
[1] MDN Web Docs · AbortController,https://developer.mozilla.org/zh-CN/docs/Web/API/AbortController(访问于 2026-10-31)
[2] DOM Standard · Aborting ongoing activities,WHATWG,https://dom.spec.whatwg.org/#aborting-ongoing-activities(访问于 2026-10-31)

评论 0
还没有评论,快来抢沙发吧~