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
→ 查询/推送进度
→ 下载结果
队列限制任务级并发,单任务内部再限制文件并发。两层都不可无限。
练习题
- 使用顺序、4 并发和无限 Promise.all 转换同一目录并测量。
- 一个 Worker 失败时,让其他 Worker 通过 AbortSignal 停止。
- 计算 PM2 集群下总文件并发上限。
- 区分适合提高 I/O 并发和不适合盲目提高 CPU 并发的阶段。