19 大量文件的有限并发

1. Promise.all() 的陷阱

await Promise.all(
  markdownPaths.map((filePath) => renderFile(filePath)),
);

如果 ZIP 有 20,000 个文件,这会立即创建 20,000 个 Promise,并可能同时打开大量文件、分配字符串和执行渲染,导致 EMFILE、内存峰值和 CPU 抢占。

2. 顺序处理

for (const filePath of markdownPaths) {
  await renderFile(filePath);
}

最容易理解且资源稳定,但 I/O 等待期间无法利用有限并行度。新人应先实现正确的顺序版本,再增加并发。

3. 使用 p-limit

项目需要时可以安装:

yarn add p-limit

示意:

import pLimit from "p-limit";

const limit = pLimit(4);

await Promise.all(
  markdownPaths.map((filePath) =>
    limit(() => renderFile(filePath)),
  ),
);

注意当前工程编译为 CommonJS,安装前应核对所选 p-limit 版本的 ESM 兼容方式。为了学习,也可以实现工作队列。

4. 简单 Worker Pool

export async function mapWithConcurrency<T>(
  items: readonly T[],
  concurrency: number,
  worker: (item: T, index: number) => Promise<void>,
): Promise<void> {
  if (!Number.isInteger(concurrency) || concurrency <= 0) {
    throw new RangeError("concurrency 必须是正整数");
  }

  let nextIndex = 0;

  async function runWorker(): Promise<void> {
    while (true) {
      const index = nextIndex;
      nextIndex += 1;

      if (index >= items.length) {
        return;
      }

      const item = items[index];
      if (item !== undefined) {
        await worker(item, index);
      }
    }
  }

  const workerCount = Math.min(concurrency, items.length);

  await Promise.all(
    Array.from({ length: workerCount }, () => runWorker()),
  );
}

这仍需设计:一个任务失败后是否停止其他 Worker、如何 Abort 正在运行的 Pipeline、是否收集多个错误。

5. I/O 与 CPU

文件复制主要受磁盘和网络影响,Markdown 渲染、语法高亮、压缩可能占 CPU。增加并发不一定更快:CPU 密集任务在同一事件循环中仍会互相阻塞。

真正较重的 CPU 任务可以考虑 Worker Threads 或独立进程,但不要在基础阶段过早引入;先测量阶段耗时。

6. PM2 放大总并发

假设:

PM2 4 个进程
每进程允许 4 个 ZIP 任务
每任务并发处理 4 个文件

理论上可同时存在 64 个文件工作单元。应用内部常量必须结合进程数量、机器内存、磁盘和压缩 CPU 统一估算。

7. 后台队列

耗时较长的转换更适合:

HTTP 创建任务
  → BullMQ
  → 固定数量 Worker
  → 查询/推送进度
  → 下载结果

队列限制任务级并发,单任务内部再限制文件并发。两层都不可无限。

练习题

  1. 使用顺序、4 并发和无限 Promise.all 转换同一目录并测量。
  2. 一个 Worker 失败时,让其他 Worker 通过 AbortSignal 停止。
  3. 计算 PM2 集群下总文件并发上限。
  4. 区分适合提高 I/O 并发和不适合盲目提高 CPU 并发的阶段。