本节目标:读完这一节,你能解释「并行」与「并发」的差别,以及无限制并发为什么会把服务打挂;能手写一个泛型并发池并说清它的类型参数;能正确构造
AbortController并把signal一路传进fetch;能区分AbortSignal.timeout与Promise.race两种超时方案的取舍;能用AbortSignal.any合并多个取消源;能写出带指数退避的重试函数;并且能识别「后发先至」这类竞态 bug。
14.3 并发控制、取消与超时
上一节我们学会了怎么把多个异步任务合到一起。但把几十个请求同时打出去,往往不是「快」,而是「崩」:浏览器对同域名连接数有上限,服务端有限流,数据库连接池会被打满,内存会因为同时挂起太多 Promise 而暴涨。
这一节要补齐异步编程里最容易被忽略的三块:限流、取消、超时。它们共同的目标只有一个——让并发变得可控。
并行不等于无限制
先把两个常被混用的词分开:
| 概念 | 含义 | 典型手段 |
|---|---|---|
| 并行(parallel) | 同一时刻真正同时执行,依赖多核 | worker_threads、多进程 |
| 并发(concurrent) | 多个任务在时间上交错推进,不要求同时 | Promise、事件循环 |
JavaScript 主线程是单线程的,我们说的「并发」几乎都是后者:任务在事件循环里交替推进。所以 Promise.all(一千个请求) 并不会真的并行一千个,而是把一千个请求都排进队列,让它们同时处于 in-flight 状态。问题在于:
- 浏览器对同域名的 HTTP/1.1 连接上限约 6 个,多出来的请求在排队,超时风险累积;
- 服务端通常有速率限制(429)与并发上限;
- 每个 in-flight 请求都占内存,几千个并发足以让 Node 进程 OOM;
- 失败时会触发「惊群」:所有请求同时重试,把服务端再压一遍。
结论很简单:只要有批量任务,就必须有并发上限。
手写一个泛型并发池
并发池的思路是「固定 N 个工人,轮流从任务队列取活」。注意 results 必须按下标写入,而不是 push,否则完成顺序会打乱结果顺序:
async function pool<T, R>(
items: readonly T[],
limit: number,
worker: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const results: R[] = new Array(items.length);
let cursor = 0;
async function run(): Promise<void> {
while (cursor < items.length) {
const index = cursor++;
results[index] = await worker(items[index], index);
}
}
const runnerCount = Math.min(limit, items.length);
await Promise.all(Array.from({ length: runnerCount }, run));
return results;
}
三个类型细节:
readonly T[]而不是T[]。入参只读,调用方传数组字面量或as const元组都不会报错。cursor++是同步的。虽然run是async,但cursor++在await之前执行,所以两个工人不会取到同一个下标——这是并发池最容易写错的地方。
用起来是这样:
const urls = ["/a", "/b", "/c", /* ...一百个 */];
const bodies = await pool(urls, 5, async (url, i) => {
const res = await fetch(url);
return res.text();
});
// 最多同时 5 个请求;bodies 的顺序与 urls 一一对应
如果其中任意一个任务抛错,Promise.all 会立刻 reject,但其余正在跑的任务不会自动停止——这正是下一节要解决的。
AbortController 与 AbortSignal 的类型
取消机制的入口是 AbortController。它只有两样东西:一个 signal 属性和一个 abort 方法。
const controller = new AbortController();
const { signal } = controller; // 类型:AbortSignal
controller.abort(); // 触发取消,reason 默认为 AbortError
controller.abort("用户点了返回"); // 也可以带自定义 reason
signal 有两个关键属性:
signal.aborted; // boolean:是否已取消
signal.reason; // any:取消原因(未指定时是一个 name 为 "AbortError" 的 DOMException)
reason 的类型是 any——这又一次印证了 14.1 的主题:错误/失败原因在类型系统里天然是敞开的。要安全使用,得自己收窄:
function isAbortError(e: unknown): boolean {
return e instanceof Error && e.name === "AbortError";
}
signal 是可以监听的,这让你能在取消时做清理(关闭连接、释放句柄、写日志):
signal.addEventListener("abort", () => {
console.log("被取消了,开始清理");
}, { once: true });
写 { once: true } 能省掉手动 removeEventListener;但如果 signal 长期存活而回调只在某段逻辑里有效,就仍要显式移除,否则监听器会一直持有闭包。
把 signal 传进 fetch
fetch 的第二个参数 RequestInit 上有 signal?: AbortSignal | null。传进去之后,controller.abort() 会让这个请求立刻 reject:
const controller = new AbortController();
async function load(url: string): Promise<string> {
const res = await fetch(url, { signal: controller.signal });
return res.text();
}
const task = load("/api/slow");
controller.abort(); // 请求被中断
try {
await task;
} catch (e) {
if (isAbortError(e)) {
console.log("请求已取消"); // ← 走这里,而不是当成业务错误
} else {
throw e;
}
}
关键点:取消必须显式传播。 signal 不会自动沿调用链传下去,你得把它作为参数一层层传递(例如 fetchUser(id, signal) 内部再 fetch(url, { signal }))。忘记传,是「点了取消但请求还在跑」的第一大原因。建议在项目里约定:凡是可能被取消的异步函数,都把 signal 作为可选的最后一个参数。
超时:两种方案与各自代价
超时本质上是「到点自动取消」。有两种常见实现,各有取舍。
方案一:AbortSignal.timeout(ms)(推荐)。它返回一个到点自动 abort 的 signal,取消原因是一个 name 为 "TimeoutError" 的 DOMException:
const res = await fetch("/api/slow", { signal: AbortSignal.timeout(3000) });
优点是一行搞定、底层连接会被真正中断;缺点是 Node.js 需要 18+,且无法在超时后复用这个 signal。
方案二:Promise.race 包一个定时器。适合给任意 Promise(不限于 fetch)加超时:
async function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`超时:${ms}ms`)), ms);
});
try {
return await Promise.race([p, timeout]);
} finally {
clearTimeout(timer); // 成功时也要清掉,否则定时器泄漏
}
}
两个细节值得强调:
new Promise<never>里的泛型是never。因为这条 Promise 永远不会 resolve,用never才能让它和Promise<T>组成race时,结果类型仍是T。若写成Promise<void>,结果会变成T | void。finally里的clearTimeout必不可少。没有它,即使请求 100ms 就成功了,定时器仍会在 3 秒后触发——在长驻进程里,这会累积成内存与 CPU 的浪费,Node 甚至可能因此迟迟无法退出。
合并多个取消源
真实场景里,一个请求往往同时受「用户操作」和「超时」两个条件约束。AbortSignal.any 可以把多个 signal 合成一个,任一触发即取消:
const combined = AbortSignal.any([
userController.signal,
AbortSignal.timeout(5000),
]);
const res = await fetch("/api/search", { signal: combined });
在没有 AbortSignal.any 的旧环境里,可以自己 new 一个 AbortController,然后给每个源 signal 挂 abort 监听、回调里调用 ctrl.abort(s.reason) 来手动桥接。
重试与指数退避
可重试的失败(超时、502、连接重置)应该自动重试,但重试必须退避,否则等于对服务端做压测。先定义一个结构化的重试选项:
interface RetryOptions {
retries: number;
baseDelayMs: number;
signal?: AbortSignal;
shouldRetry?: (error: unknown) => boolean;
}
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(resolve, ms);
signal?.addEventListener("abort", () => {
clearTimeout(timer);
reject(signal.reason);
}, { once: true });
});
}
async function retry<T>(
fn: (attempt: number) => Promise<T>,
opts: RetryOptions,
): Promise<T> {
const { retries, baseDelayMs, signal } = opts;
const shouldRetry = opts.shouldRetry ?? (() => true);
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn(attempt);
} catch (e) {
lastError = e;
if (attempt === retries || !shouldRetry(e)) throw e;
const delay = baseDelayMs * 2 ** attempt; // 指数退避:100, 200, 400...
await sleep(delay, signal);
}
}
throw lastError; // 逻辑上不可达,但让编译器确认函数必然返回或抛出
}
注意最后那行 throw lastError。虽然循环必然在 attempt === retries 时 throw,但 TypeScript 的控制流分析不会去证明这一点,于是它会认为函数可能「走完循环后返回 undefined」,与声明的 Promise<T> 冲突。末尾补一个 throw 是让编译器闭嘴的标准做法(也可以用 assertNever 式的穷尽技巧)。
使用示例,只重试网络类错误:
const data = await retry((attempt) => request(url, { signal }), {
retries: 3,
baseDelayMs: 100,
signal,
shouldRetry: (e) => e instanceof NetworkError && e.retryable,
});
竞态:后发先至
并发代码最隐蔽的 bug 是「结果错位」。搜索框连续输入时,先发出的请求可能后返回,把新结果覆盖掉:
// ❌ 后发先至:慢的旧请求覆盖了快的新请求
async function search(q: string) {
const res = await fetch(`/api/search?q=${q}`);
render(await res.json());
}
两种修法。用序号打标(适合不方便取消的场景):
let seq = 0;
async function search(q: string) {
const my = ++seq;
const res = await fetch(`/api/search?q=${q}`);
const data: unknown = await res.json();
if (my !== seq) return; // 已经有更新的请求发出,丢弃本次结果
render(data);
}
用取消直接掐掉旧请求(更彻底,省掉无用的网络开销):
let inflight: AbortController | undefined;
async function search(q: string) {
inflight?.abort("被新的搜索取代");
const ctrl = new AbortController();
inflight = ctrl;
try {
const res = await fetch(`/api/search?q=${q}`, { signal: ctrl.signal });
render(await res.json());
} catch (e) {
if (!isAbortError(e)) throw e; // 取消是预期行为,不当作错误上报
}
}
一个真实工程示例
把并发池、超时、取消串起来,做一个「批量拉取 + 限流 + 可中断」的工具:
interface BatchOptions {
concurrency: number;
timeoutMs: number;
signal?: AbortSignal;
}
async function fetchAll(
urls: readonly string[],
opts: BatchOptions,
): Promise<Result<string[], Error>> {
const { concurrency, timeoutMs, signal } = opts;
const gate = signal ?? AbortSignal.timeout(timeoutMs);
try {
const bodies = await pool(urls, concurrency, async (url) => {
const res = await fetch(url, { signal: gate });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
});
return { ok: true, value: bodies };
} catch (e) {
return { ok: false, error: e instanceof Error ? e : new Error(String(e)) };
}
}
失败被收敛成 Result——「批量任务里某几个失败」是预期内的情况,按 14.1 的取舍原则用 Result 而不是抛异常。若希望「部分成功也保留」,把 pool 里每个任务各自包一层 attempt(14.1 的边界包装)即可,返回类型相应变成 Result<string, Error>[]。
延伸阅读:Node 侧的长驻进程取消与优雅退出可参考 /nodejs-graceful-shutdown-health-check/ ;CPU 密集任务的真并行可参考 /typescript-worker-threads-parallelism/ ;更系统的并发模式见 /typescript-async-concurrency-control/ 。
常见坑与报错
坑一:以为 Promise.all 会取消其余任务。 它只是「不再等待」,剩下的 Promise 仍在后台跑完。要真取消,必须配 AbortController。
坑二:把 AbortError 当业务错误上报。 用户切页面导致的取消会变成一堆告警。统一用 isAbortError(e) 过滤掉。
坑三:定时器泄漏。 Promise.race 的超时分支若不在 finally 里 clearTimeout,成功路径上也会留下一个定时器。同理,setTimeout 的返回值类型是 ReturnType<typeof setTimeout>——在 Node 与浏览器下分别是 Timeout 与 number,不要写死成 number。
坑四:忘记传 signal。 取消链路断在中间层,表现是「点了取消但网络面板里请求还在跑」。约定「可取消函数一律接受可选 signal 参数」。
坑五:无上限并发。 直接 Promise.all(bigArray.map(...)) 在数据量上千时会触发 429 或连接重置。用并发池。
坑六:并发写共享状态。 多个任务同时 arr.push 或累加计数器,在 await 切换点前后会产生交错。要么改用下标写入(如并发池的 results),要么把共享状态的更新放在 await 之外。
坑七:AbortSignal.timeout 与 AbortSignal.any 需要较新环境。 Node 18+ / 现代浏览器才有,旧环境要回退到手写 withTimeout 与手动桥接。
小结
- 并行是真正同时执行,并发是交错推进;JavaScript 主线程上的并发必须设上限,否则会打满连接池、触发限流甚至 OOM。
- 并发池用「固定工人数 + 同步游标」实现,结果按下标写入以保证顺序;
Promise.all只负责等待,不会取消剩余任务。 AbortController提供signal与abort;signal.reason的类型是any,需自己收窄;取消必须显式把signal逐层传下去。- 超时两方案:
AbortSignal.timeout简洁且能中断底层连接,Promise.race通用于任意 Promise,但要在finally里clearTimeout防泄漏。 AbortSignal.any可合并多个取消源;重试要配指数退避,并在末尾补throw让编译器满意。- 竞态(后发先至)用序号打标或直接 abort 旧请求来防护。
到这里,第 14 章的三节就闭环了:14.1 定义错误怎么表达,14.2 讲清异步的类型形状,14.3 让并发变得可控。这三件事合起来,构成了「生产可用」与「能跑就行」之间最主要的差距。下一章 15.1 单元测试(Vitest/Jest) 会把这些行为固化成测试,让它们在重构时不会悄悄退化。
阅读导航:上一节:14.2 Promise 与 async/await 的类型 · 下一节:15.1 单元测试(Vitest/Jest) 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。