# 进程与工作线程：把计算放到合适的边界

## 目标与前置

团队助手有两类工作：等待数据库或网络，以及解析大文档、执行本地计算。上一章已经说明 async 不会自动转移 CPU 工作，本章进一步回答应该使用子进程还是工作线程。前置是进程生命周期、错误通道和模块系统。目标是能够启动一个受控执行单元，接收结果，处理失败，并确认它已经退出。

你不需要先成为操作系统专家，但必须区分三个边界：一个进程中的普通 JavaScript 调用，进程内另一工作线程，以及另一个操作系统进程。边界越强，通信和启动成本通常也越明显。选择不是“越隔离越高级”，而是计算量、故障范围、权限和部署方式是否需要那种边界。

## 普通异步、多线程与多进程

普通异步 I/O 让主线程可以在等待期间处理其他任务，适合大量外部等待。工作线程让另一条 JavaScript 执行线程承担计算，可以利用多核并行。子进程拥有独立进程状态和地址空间，适合调用其他程序、隔离较重任务或独立重启。三者解决的问题不同，不能用一个“并发”词概括后就随意替换。

工作线程属于同一进程，但拥有自己的 JavaScript 执行环境。普通对象不会因为创建 Worker 就自动变成共享对象，消息通常经过结构化克隆；也可以显式转移某些内存，或使用共享内存。子进程之间则通常通过管道、IPC、文件或外部服务通信。跨边界后，函数调用中的隐含引用关系需要变成明确协议。

隔离还有层次。子进程崩溃通常不会直接让父进程的 JavaScript 栈一起崩溃，但它默认可能继承相同系统用户权限。Worker 更不是执行不可信代码的安全沙箱。若业务允许用户提交代码，还需要专门的权限、文件、网络和资源隔离方案，不能仅因为有一个新线程就认为安全问题解决了。

## process 提供当前执行单元的信息

`process.pid` 是当前进程标识，适合日志关联，不适合作为长期业务编号，因为系统可能复用它。`process.execPath` 是当前 Node 可执行文件的绝对路径，启动同版本子程序时比硬编码一个安装目录更可靠。`process.argv` 和 env 表示本次启动输入，cwd 表示相对路径基准，这些在父子进程间需要明确继承或覆盖。

子进程的环境变量默认可能继承父进程。若父进程拥有数据库凭据，启动一个不需要这些凭据的外部工具时，应考虑构造最小必要环境。不要为了方便把完整环境对象打印出来调试，它可能包含秘密。执行目录也应明确，避免工具把输出写到意料之外的工作目录。

进程退出有状态码，线程终止也有退出结果，但收到一条成功消息不等于执行单元已经退出。它可能还持有定时器、连接或未关闭的通信端口。父进程应该分别处理结果与生命周期，特别是在测试中等待最终退出，才能证明没有留下后台资源。

## child_process 的几种启动方式

`spawn(command, args, options)` 启动程序并返回 ChildProcess，标准输出可作为流持续读取，适合较大或持续输出。默认不通过 shell 解释命令，所以参数数组中的业务文本不会自动变成 shell 语法。若主动设置 shell，就重新引入了 shell 解析边界，不应该把未验证文本拼接进去。

`execFile(file, args, options, callback)` 通常直接执行文件并收集输出，`exec(command, options, callback)` 则通过 shell 执行命令字符串。收集输出方式有缓冲上限，输出过大可能失败；流式 spawn 更适合长期输出，但调用方仍需要消费或限制流，否则子进程可能因管道缓冲满而等待。选择 API 前先考虑输出规模和是否真的需要 shell。

ChildProcess 的 error 可以表示启动失败等问题；exit 表示进程结束，close 表示进程结束且标准 I/O 流关闭。若你需要完整输出，应等待 close，而不只是看到 exit。监听器应在启动后立即建立，并保证 Promise 只结算一次。子进程退出非零不一定产生 error 事件，需要单独检查退出结果。

## 实验一：启动同一个 Node 文件的子进程模式

环境 Node 22.22.0，无依赖。保存为 `child-output.mjs`。同一文件通过参数区分父子角色，父进程只启动受控的 Node 文件，不调用系统 shell。

```js child-output.mjs
import process from 'node:process';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import assert from 'node:assert/strict';

if (process.argv[2] === 'child') {
  console.log(JSON.stringify({ pid: process.pid, tasks: 3 }));
} else {
  const child = spawn(process.execPath, [fileURLToPath(import.meta.url), 'child'], {
    stdio: ['ignore', 'pipe', 'pipe']
  });
  let output = '';
  let diagnostic = '';
  child.stdout.setEncoding('utf8').on('data', (text) => { output += text; });
  child.stderr.setEncoding('utf8').on('data', (text) => { diagnostic += text; });
  const code = await new Promise((resolve, reject) => {
    child.once('error', reject);
    child.once('close', resolve);
  });
  assert.equal(code, 0, diagnostic);
  const result = JSON.parse(output);
  assert.equal(result.tasks, 3);
  assert.notEqual(result.pid, process.pid);
  console.log('独立子进程返回 3 项任务并正常退出');
}
```

执行 `node child-output.mjs`，预期通过。`fileURLToPath` 将模块 URL 转成平台文件路径，避免 Windows 路径与 URL 转义混淆。stdio 明确忽略输入并分别读取两个输出流；子进程输出由固定短 JSON 构成，所以本例可以安全累计。处理任意工具时应增加输出上限或把输出持续写入受控目标。

父子进程拥有不同 pid，说明不是普通函数调用。父进程不能直接读取子进程里的变量，只能解析通信内容。JSON 协议需要约定字段、最大长度和错误格式；如果子进程把调试文字也写到标准输出，就会破坏解析，因此常规诊断应放在标准错误。

## 工作线程适合什么计算

Worker 适合 CPU 密集的 JavaScript 工作，例如较重的解析、转换或算法。普通异步文件与网络 I/O 已有运行时机制，创建 Worker 不会自动让这些操作更快，反而增加启动和通信开销。短小任务每次新建线程通常得不偿失，真实服务可以复用一个有容量限制的工作池。

`new Worker(filename, options)` 返回 Worker 实例，文件可以使用指向入口的 URL。`workerData` 在启动时传入可克隆的数据；工作线程通过 `parentPort.postMessage` 返回结果，父线程监听 message。error 表示线程内未处理异常等失败，exit 表示线程已经结束。线程没有自动知道自己属于哪个 HTTP 请求，任务标识需要随消息传递。

`worker.terminate()` 返回表示终止完成的 Promise，但强制终止不等于业务安全取消。线程可能在计算到一半时停止，也可能已经产生外部副作用。可协作取消的任务可以定期检查信号或共享状态，在安全边界退出；对必须强制终止的任务，应确保外部状态可恢复或尚未发布。

## 实验二：把求和放到另一条 JavaScript 线程

保存为 `worker-sum.mjs`。计算规模固定且有上限，不根据不可信输入创建无限线程。

```js worker-sum.mjs
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
import assert from 'node:assert/strict';

if (!isMainThread) {
  let sum = 0;
  for (let index = 0; index < workerData.limit; index += 1) sum += index;
  parentPort.postMessage({ sum });
} else {
  const worker = new Worker(new URL(import.meta.url), { workerData: { limit: 1000000 } });
  let result;
  await new Promise((resolve, reject) => {
    worker.once('message', (value) => { result = value; });
    worker.once('error', reject);
    worker.once('exit', (code) => {
      if (code === 0) resolve();
      else reject(new Error(`工作线程退出码 ${code}`));
    });
  });
  assert.equal(result.sum, 499999500000);
  console.log(`工作线程结果=${result.sum}`);
}
```

执行 `node worker-sum.mjs`，预期 `工作线程结果=499999500000`。相同模块在 Worker 中运行时进入另一分支，只有计算分支执行求和；父线程等待消息和最终退出。这个实验验证通信与生命周期，不宣称创建线程后的总耗时一定比主线程短，因为启动成本在小计算中可能占主导。

workerData 是克隆输入，不是直接共享父线程对象。返回结果也采用消息传递。大块 Buffer 若反复克隆，会产生复制成本；可转移的 ArrayBuffer 可以转移所有权，使发送方原来的缓冲失去可用性；SharedArrayBuffer 则引入真正共享状态，需要 Atomics 等同步机制。基础阶段先用小型不可变消息，避免过早引入共享内存竞争。

## 工作池、排队与失败恢复

一个线程池通常固定工作线程数量，把任务排入有界队列，空闲线程领取下一项。线程数量不应简单等于请求数量，否则流量增长会变成线程与内存增长。队列还需要最大长度、等待超时和取消策略；没有这些限制，所谓线程池可能只是把阻塞从主线程转移到无限排队。

任务协议应带唯一任务标识，结果、错误和取消都引用它。父线程重试失败任务前，要判断任务是否可能已经完成外部副作用。纯计算通常容易重新执行；写数据库或发送通知则需要幂等。工作线程异常退出后，池可以补充新的线程，但不能因此自动宣称丢失的任务已经恢复。

调试时记录任务排队时间、执行时间、输入规模和线程退出原因。只看主线程 CPU 降低可能误判性能提升，因为总计算仍然存在，甚至可能因为复制增加。应同时观察吞吐、用户响应延迟和进程总资源。把任务搬走是调度方案，不是减少算法复杂度。

## 父子生命周期不能只靠一条消息维持

父进程接到退出请求时，需要先停止接受新任务，再处理已经排队和正在执行的任务。排队任务可以明确拒绝或保存，正在计算的任务可以等待有限时间完成，超出预算再进入终止策略。若直接退出父进程，子进程是否随之结束受启动方式和平台影响，不能凭本机一次观察写成通用保证。

`kill` 发出的通常是终止请求或信号语义，不代表所有平台都具有相同的优雅收尾能力。Windows 与类 Unix 系统的信号行为存在差异。跨平台工具应验证目标环境，把正常关闭协议与最终强制终止分开；例如先通过 IPC 发送停止接收任务的消息，等确认完成后断开通道，最后才处理拒绝退出的执行单元。

任务超时也不该只结束父进程等待。若子进程仍在后台运行，它继续消耗 CPU 或写入文件，后续重试可能与旧任务同时执行。超时处理需要关联到具体执行单元，停止或标记其结果失效，并等待退出。特别是文件转换任务，应使用每次任务独立的临时目录，避免旧任务晚到的输出覆盖新结果。

## 消息协议为什么要限制大小和形状

父子之间都是自己的代码，也仍然需要验证消息。版本不一致、旧任务结果晚到或内部错误，都可能产生不符合约定的数据。父进程应检查任务标识是否仍在等待、结果字段是否完整、消息大小是否合理。不要把收到任意 message 当成当前唯一任务的成功结果，真实工作池可能同时管理许多任务。

发送大对象会有序列化或克隆成本，成本可能发生在你以为已经把计算移走的主线程上。把整个请求对象发给 Worker 还可能失败，因为其中含有函数、连接和不可克隆状态。应提取计算真正需要的最小数据，例如文本片段、配置与任务标识，让通信边界同时成为依赖边界。

共享内存可以降低复制，但会改变推理方式。普通消息让双方拥有分离的数据快照，共享数组则允许双方同时观察修改，需要同步协议来防止读取到未完成状态。对多数偏前端的 AI 应用，先使用小消息和明确所有权，比为了理论上的零复制过早引入共享锁更容易保持正确性。

## 如何证明计算确实值得迁移

先测量同步版本处理不同文档规模的耗时，以及它对同进程小请求的延迟影响。如果计算只占很短时间，线程启动与消息复制可能比计算本身更贵；如果持续计算占满主线程，迁移后即使单任务总耗时略高，也可能显著改善其他用户的响应。评估目标应包含交互延迟，而不只看单个函数最快的运行时间。

工作池应在服务启动时逐步准备，并限制同时运行数。机器核数只是一个参考，还要考虑容器实际可用 CPU、其他进程负载和每任务内存。把线程数设得远超可用计算资源，常常增加上下文切换，却不增加完成速度。配置需要从目标环境的测量中得到，而不是照抄开发机参数。

有些文档解析库通过原生扩展自己管理线程，外层再增加 Worker 可能形成多层并行，造成过度竞争。选择隔离方式前应了解依赖本身的执行模型。反过来，调用一个独立命令行转换器时，spawn 可以直接利用它现有的进程边界，不必再包一层工作线程。

## 错误报告与可恢复状态

纯计算任务失败通常可以丢弃本次结果并重新执行，但失败原因仍要分类。输入格式错误不应无限重试，线程内代码缺陷需要修复，资源不足则可能要求降低并发或输入规模。把所有非零退出都归为“临时失败”会制造重试风暴，让本来有限的故障变成系统过载。

父进程应保留任务状态与执行单元状态的对应关系：任务等待、运行、完成或失败，执行单元空闲、繁忙或已退出。这样当一个 Worker 意外退出时，能够明确找出受影响任务，而不是清空整个队列。这个小型状态机也是未来后台任务系统的基础，只是持久化队列还要处理进程重启与多实例领取。

## 练习：用 IPC 返回一个结构化结果

使用 fork 启动本文件的子进程角色，父进程发送 `{id, count}`，子进程校验 count 后返回同一 id 与计算结果，再断开 IPC。要求父进程检查结果消息，并等到 close 确认进程与相关通道关闭；缺少结果也必须失败，不能仅靠退出码或标准输出文本猜测任务完成。

<details><summary>参考答案：完整 IPC 实验</summary>

```js child-ipc.mjs
import process from 'node:process';
import { fork } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import assert from 'node:assert/strict';

if (process.argv[2] === 'child') {
  process.once('message', (task) => {
    const valid = Number.isInteger(task.count) && task.count >= 0 && task.count <= 100;
    const result = valid ? { id: task.id, total: task.count * 2 } : { id: task.id, error: 'INVALID_COUNT' };
    process.send(result, () => process.disconnect());
  });
} else {
  const child = fork(fileURLToPath(import.meta.url), ['child'], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] });
  child.stderr.resume();
  let result;
  const complete = new Promise((resolve, reject) => {
    child.once('message', (value) => { result = value; });
    child.once('error', reject);
    child.once('close', (code) => {
      if (code !== 0) reject(new Error(`退出码 ${code}`));
      else if (!result) reject(new Error('子进程未返回结果'));
      else resolve();
    });
  });
  child.send({ id: 'task-1', count: 7 });
  await complete;
  assert.deepEqual(result, { id: 'task-1', total: 14 });
  console.log('IPC 任务返回并断开连接');
}
```

执行 `node child-ipc.mjs`，预期通过。fork 是面向 Node 子进程的便利启动接口，会建立 IPC 通道，不是复制当前 JavaScript 内存快照的语言操作。消息数据需要可序列化，发送完成与接收方业务完成仍是不同事件；本例通过返回带任务标识的结果表达后者。

</details>

把结果和退出分别等待，也让自动化验证能够发现隐藏的长期定时器与未关闭通信端口。若程序已经打印成功却迟迟不结束，应先检查资源生命周期，而不是直接在末尾加入强制退出掩盖问题。

## 本章实际验证范围

三个程序实际通过：独立子进程输出、工作线程求和与 IPC 结果及通道关闭。均只启动当前 Node 的受控文件，未运行任意外部命令、共享内存并发或生产线程池。

## 验收、自测与官方参考

验收要求子进程 pid 不同、标准输出完整解析、Worker 返回正确计算结果并退出、IPC 结果与任务标识一致且通道关闭。自测一：async 是否自动让计算使用另一 CPU 核？答案：不会。自测二：Worker 能否直接读写父线程普通对象？答案：默认不能，消息克隆或显式共享是不同机制。自测三：子进程 exit 后能否立刻断定所有输出都已读完？答案：不一定，完整标准 I/O 生命周期应关注 close。

API 见 [Child process](https://nodejs.org/docs/latest-v24.x/api/child_process.html)、[Worker threads](https://nodejs.org/docs/latest-v24.x/api/worker_threads.html) 与 [Process](https://nodejs.org/docs/latest-v24.x/api/process.html)。三个实验只启动当前 Node 的受控程序，没有执行用户提交的系统命令，也未验证跨权限沙箱或生产工作池。
