《TypeScript编程入门》14.3 并发控制、取消与超时

本节解决异步编程里最容易被忽略的三个问题:并发数不受控、任务无法取消、请求没有超时。我们手写一个泛型并发池,把 AbortController 与 AbortSignal 的取消链路讲透,用 AbortSignal.timeout 与 Promise.race 实现两种超时方案,再给出指数退避重试与竞态防护的写法,读完你能写出可控、可中断、可观测的异步批处理代码。

本节目标:读完这一节,你能解释「并行」与「并发」的差别,以及无限制并发为什么会把服务打挂;能手写一个泛型并发池并说清它的类型参数;能正确构造 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;
}

三个类型细节:

  1. readonly T[] 而不是 T[]。入参只读,调用方传数组字面量或 as const 元组都不会报错。
  2. 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) 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes