14. IPC 与进程间通信
本章解决的问题
多个进程的变量彼此独立。fork() 创建的 Node.js 子进程虽然不能直接读取父进程的 Map、数据库连接或函数,却可以通过 IPC(Inter-Process Communication,进程间通信)交换可序列化消息。
本章学会:
- 使用
fork()、subprocess.send()和process.on("message"); - 为消息设计明确协议,并在运行时校验;
- 区分“消息已发送”和“任务已完成”;
- 知道何时改用 Redis、数据库或消息队列。
工作原理
父进程 Node.js 子进程
fork(module, { stdio: ... })
│ │
├── IPC: { type: "run" } ───────>│ process.on("message")
│ │ 执行任务
│<── IPC: { type: "result" } ─────┤ process.send?.(...)
│ │
└── disconnect() ────────────────>│ IPC 通道关闭
IPC 通道传递的是经过序列化的数据,不是共享内存。函数、Express 的 Request、数据库连接、带私有状态的类实例都不能按原样传过去。大文件和大 Buffer 也不应反复走 IPC,通常传文件路径或对象存储地址更合适。
一个带运行时校验的 TypeScript 示例
父进程 parent.ts:
import * as path from "node:path";
import { randomUUID } from "node:crypto";
import { fork } from "node:child_process";
interface RunMessage {
type: "run";
requestId: string;
inputPath: string;
}
interface ResultMessage {
type: "result";
requestId: string;
total: number;
}
function isResultMessage(value: unknown): value is ResultMessage {
if (typeof value !== "object" || value === null) return false;
const message = value as Record<string, unknown>;
return message.type === "result"
&& typeof message.requestId === "string"
&& typeof message.total === "number";
}
const childPath = path.resolve(__dirname, "child.js");
const child = fork(childPath, [], {
stdio: ["inherit", "inherit", "inherit", "ipc"],
});
const request: RunMessage = {
type: "run",
requestId: randomUUID(),
inputPath: "data/input.csv",
};
child.on("message", (message: unknown) => {
if (!isResultMessage(message)) {
console.error("收到不符合协议的 IPC 消息");
return;
}
if (message.requestId !== request.requestId) return;
console.log("任务结果:", message.total);
child.disconnect();
});
child.on("error", (error) => {
console.error("子进程启动或通信失败:", error.message);
});
child.on("close", (code, signal) => {
console.log("子进程关闭:", { code, signal });
});
child.send(request, (error) => {
if (error) console.error("消息未能写入 IPC 通道:", error.message);
});
子进程 child.ts:
interface RunMessage {
type: "run";
requestId: string;
inputPath: string;
}
function isRunMessage(value: unknown): value is RunMessage {
if (typeof value !== "object" || value === null) return false;
const message = value as Record<string, unknown>;
return message.type === "run"
&& typeof message.requestId === "string"
&& typeof message.inputPath === "string";
}
process.on("message", async (message: unknown) => {
if (!isRunMessage(message)) {
process.stderr.write("忽略非法 IPC 消息\n");
return;
}
const total = await Promise.resolve(42);
process.send?.({
type: "result",
requestId: message.requestId,
total,
});
});
当前项目使用 TypeScript 转 CommonJS。生产中应先编译,再 fork() 编译后的 child.js;不要想当然地 fork("child.ts"),除非明确为子进程配置了 TypeScript 运行器。
为什么 TypeScript 类型还不够
IPC 消息在运行时来自另一个进程。发送方可能版本落后、发生错误,甚至不可信。message as ResultMessage 只会让编译器闭嘴,不会验证真实数据。因此边界必须使用类型守卫,复杂项目可使用 Zod 等运行时 Schema 工具。
协议至少应包含:
type 消息种类
requestId 关联请求与响应
version 协议需要演进时加入
payload 经过校验的业务数据
高频 API 与事件
| API/属性 | 用途 | 注意 |
|---|---|---|
fork() |
启动 Node.js 子模块 | 默认创建 IPC 通道 |
child.send() |
父进程发送消息 | 回调只表示发送结果,不表示业务完成 |
process.send?.() |
子进程发送消息 | 非 IPC 子进程中可能不存在 |
message |
收到 IPC 消息 | 参数应按 unknown 校验 |
child.connected |
IPC 是否仍连接 | 不表示子进程健康 |
disconnect() |
主动关闭 IPC | 关闭后不能继续发送 |
disconnect |
IPC 通道断开 | 不等于 stdio 已关闭 |
child.send(message, callback) 的回调成功,仅说明消息已交给通信通道。真正的任务完成应由带相同 requestId 的结果消息、超时和子进程 close 共同界定。
正常与异常时间线
正常:fork → spawn → send 回调 → child message → result message → disconnect → close
启动失败:fork → error → close
执行中崩溃:fork → spawn → send → 子进程退出 → disconnect/close
└─ 没有 result,父进程必须判失败
响应超时:send → 等待超过期限 → 发送取消消息或 kill → 等待 close
Promise 封装要防止“结果消息、error、close、超时”重复完成同一个 Promise,并在完成后移除监听器和定时器。
其他通信方式怎么选
| 方式 | 适合场景 |
|---|---|
| stdin/stdout | 调用任意语言程序,流式数据或简单协议 |
| Node.js IPC | 同一机器、父子 Node.js 进程、控制消息 |
| TCP/Unix Socket | 无父子关系但位于同一机器或网络内 |
| Redis/数据库 | 多个独立实例共享状态 |
| RabbitMQ/Kafka 等 | 可靠异步任务、削峰、跨机器消费 |
| 文件 | 大结果交接;需要处理原子写入和清理 |
Cluster Primary 与 Worker 也能使用 worker.send() 和 Worker 里的 process.send?.()。但是多台服务器之间不能依赖这条本机 IPC 通道。
跨平台注意事项
- IPC API 在 Windows、Linux 和 macOS 均可用,但传递 Socket/Handle 的能力和行为存在平台差异。
- 路径应使用
path.resolve()、path.join(),不要把 Linux 路径直接发给 Windows 子进程。 - 不要用 Signal 作为业务消息协议;Windows 对 POSIX Signal 的支持不同。
- IPC 断开后的进程是否退出取决于它是否还有 Server、定时器或其他活动句柄。
常见错误
- 把数据库连接、函数或 Express
req直接发送给子进程。 - 只写 TypeScript 接口,不做运行时校验。
- 把
send()回调当成任务完成通知。 - 没有
requestId,并发响应无法对应请求。 - IPC 发送大文件内容,造成序列化和内存压力。
- 只监听
message,子进程崩溃后请求永远等待。
练习
- 编写父子进程,实现
sum请求和结果响应,并用requestId支持三个并发请求。 - 让子进程故意返回错误结构,验证父进程会拒绝非法消息。
- 加入 3 秒超时;超时后终止子进程,并等待
close。 - 思考:一个 500 MB 文件为什么应传路径,而不是通过 IPC 传
Buffer?
验收清单
- [ ] 能解释 IPC 为什么不是共享变量。
- [ ] 能正确使用
fork()、send()、message和disconnect()。 - [ ] 所有外部消息都按
unknown做运行时校验。 - [ ] 能区分“发送成功”“业务成功”“进程关闭”。
- [ ] 能为并发消息设计
requestId、超时和失败处理。 - [ ] 知道跨机器任务应使用共享存储或消息队列。