14. IPC 与进程间通信

本章解决的问题

多个进程的变量彼此独立。fork() 创建的 Node.js 子进程虽然不能直接读取父进程的 Map、数据库连接或函数,却可以通过 IPC(Inter-Process Communication,进程间通信)交换可序列化消息。

本章学会:

工作原理

父进程                         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 封装要防止“结果消息、errorclose、超时”重复完成同一个 Promise,并在完成后移除监听器和定时器。

其他通信方式怎么选

方式 适合场景
stdin/stdout 调用任意语言程序,流式数据或简单协议
Node.js IPC 同一机器、父子 Node.js 进程、控制消息
TCP/Unix Socket 无父子关系但位于同一机器或网络内
Redis/数据库 多个独立实例共享状态
RabbitMQ/Kafka 等 可靠异步任务、削峰、跨机器消费
文件 大结果交接;需要处理原子写入和清理

Cluster Primary 与 Worker 也能使用 worker.send() 和 Worker 里的 process.send?.()。但是多台服务器之间不能依赖这条本机 IPC 通道。

跨平台注意事项

常见错误

练习

  1. 编写父子进程,实现 sum 请求和结果响应,并用 requestId 支持三个并发请求。
  2. 让子进程故意返回错误结构,验证父进程会拒绝非法消息。
  3. 加入 3 秒超时;超时后终止子进程,并等待 close
  4. 思考:一个 500 MB 文件为什么应传路径,而不是通过 IPC 传 Buffer

验收清单