进程与工作线程:把计算放到合适的边界
通过子进程输出、工作线程计算和 IPC 实验理解隔离与通信,分清并发等待、多核并行和资源所有权。
建议先读:事件循环原理:谁在执行,谁在等待错误与调试:从回调到 async 的传播边界
本页内容
目标与前置#
团队助手有两类工作:等待数据库或网络,以及解析大文档、执行本地计算。上一章已经说明 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。
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。计算规模固定且有上限,不根据不可信输入创建无限线程。
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 确认进程与相关通道关闭;缺少结果也必须失败,不能仅靠退出码或标准输出文本猜测任务完成。
参考答案:完整 IPC 实验
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 内存快照的语言操作。消息数据需要可序列化,发送完成与接收方业务完成仍是不同事件;本例通过返回带任务标识的结果表达后者。
把结果和退出分别等待,也让自动化验证能够发现隐藏的长期定时器与未关闭通信端口。若程序已经打印成功却迟迟不结束,应先检查资源生命周期,而不是直接在末尾加入强制退出掩盖问题。
本章实际验证范围#
三个程序实际通过:独立子进程输出、工作线程求和与 IPC 结果及通道关闭。均只启动当前 Node 的受控文件,未运行任意外部命令、共享内存并发或生产线程池。
验收、自测与官方参考#
验收要求子进程 pid 不同、标准输出完整解析、Worker 返回正确计算结果并退出、IPC 结果与任务标识一致且通道关闭。自测一:async 是否自动让计算使用另一 CPU 核?答案:不会。自测二:Worker 能否直接读写父线程普通对象?答案:默认不能,消息克隆或显式共享是不同机制。自测三:子进程 exit 后能否立刻断定所有输出都已读完?答案:不一定,完整标准 I/O 生命周期应关注 close。
API 见 Child process、Worker threads 与 Process。三个实验只启动当前 Node 的受控程序,没有执行用户提交的系统命令,也未验证跨权限沙箱或生产工作池。