第 18 章 · 多进程:何时需要、进程间通信
解决什么问题:一个进程用不上多核、或者一段代码需要和主程序隔离时,怎样把工作交给另一个进程,再把结果拿回来。
四种语言的态度:Python 和 JS/TS 靠多进程绕开 GIL 和单线程事件循环,有现成的进程池和 IPC 通道;C# 和 Go 的线程本来就能并行,起子进程主要为了隔离和调用外部程序。
主进程起 4 个子进程,分别算 1、2、3、4 的平方,再把结果收回来按编号打印并求和。计算微不足道,要看的是参数怎样送进另一个进程、结果怎样送回来。为了只用一个文件,每种语言都让程序再起一份自己,靠命令行参数(或标准库的约定)区分父子。
第 16 章说过,进程有自己独立的地址空间(address space):子进程看不到父进程的变量,改了自己的变量父进程也看不到。好处是隔离和并行:一个进程崩溃不会弄坏别的进程的内存;GIL 和 JS 的单线程都只管自己那个进程,多个进程能同时占用多个核。代价是一切交流都要经过操作系统:参数和结果先序列化(serialization)成字节,经通道传过去,再在对面反序列化成对象。
看每个箭头两端:父进程把 n 序列化后交给子进程(Python 经管道,其余经命令行参数),子进程反序列化才能计算,结果经管道回来(TS 经 fork 的 IPC 通道)。每个框各有一份内存,中间没有共享的变量。
起进程有两种方式。fork 是 Unix 的系统调用,把当前进程复制一份,子进程从 fork 返回处接着执行,带着父进程全部的内存内容;它用写时复制(copy-on-write)实现:父子先共用物理页,谁写哪页才复制哪页,所以很快。spawn 指从头启动一个新程序(Unix 上 fork 后紧接 exec,Windows 上调用 CreateProcess),子进程从空白开始,需要的数据都要传过去,启动慢但干净。fork 有个大坑:它只复制调用它的那一条线程,别的线程手里的锁在子进程里永远没人释放,多线程程序 fork 之后很容易死锁。
进程间通信(inter-process communication,IPC)有几种常见通道:管道(pipe),包括标准输入输出;套接字(socket);共享内存(shared memory),把同一块物理内存映射进两个进程,不用复制但要自己加锁;消息队列(message queue)。除共享内存外都要序列化,所以多进程适合“参数小、计算重”的任务;数据越大,复制和编解码越可能吃掉并行的收益。
Python 怎么做
Section titled “Python 怎么做”Python 用标准库 multiprocessing:Process 起一个进程,Queue 在进程间传对象,Pool 是现成的进程池。
import multiprocessing as mp
def square(n: int) -> int: return n * n # 在子进程里执行;n 和返回值都经过 pickle 序列化
if __name__ == "__main__": # 子进程会重新导入本文件,这个判断挡住它再起子进程 with mp.Pool(processes=4) as pool: results = pool.map(square, [1, 2, 3, 4]) # 结果按参数顺序返回 print("子进程结果(按编号):" + " ".join(str(r) for r in results)) print(f"合计:{sum(results)}")
# 输出:# 子进程结果(按编号):1 4 9 16# 合计:30运行:python processes.py。Pool.map 把参数分发给 4 个工作进程,结果按参数顺序而不是完成顺序返回,所以输出是确定的。这里“找到自己”由 multiprocessing 代劳:spawn 方式下它用 sys.executable(当前解释器的路径)起一个新解释器,再让新解释器以 __mp_main__ 的名字导入本文件,从中找到 square。
底层怎么实现。 有三种启动方式:fork、spawn、forkserver(先起一个单线程的服务进程,以后每次由它 fork)。Windows 只有 spawn;macOS 从 3.8 起默认 spawn;其他 POSIX 平台(如 Linux)原来默认 fork,3.14 起改为 forkserver,避开多线程程序 fork 之后的死锁,3.12 起在检测到多线程时调用 os.fork() 还会发出 DeprecationWarning。参数、函数(按“模块名加函数名”引用)和返回值都用 pickle 序列化,经管道传输;Pool 在父进程里还有几条后台线程负责派发任务和收集结果。fork 方式下,写时复制在 CPython 里效果有限:读一个对象也会改它的引用计数,所在的内存页就被复制了。
- 不写
if __name__ == "__main__"::spawn 出来的子进程导入本文件时又要起进程,multiprocessing检测到后抛出RuntimeError: An attempt has been made to start a new process before the current process has finished its bootstrapping phase.;用Pool时池子不停补起新的工作进程,实测程序挂住、反复打印这段错误。 - 把 lambda 交给
Pool.map:pickle 按名字引用函数,lambda 没有可查的名字,实测报_pickle.PicklingError: Can't pickle <function <lambda> …>: attribute lookup <lambda> on __main__ failed。 - 代码在 Linux 上靠 fork 继承了全局变量,换到 Windows、macOS 或 3.14 的 forkserver 后,子进程里的全局变量是重新导入得到的初始值。
JS/TS 怎么做
Section titled “JS/TS 怎么做”Node 用内置模块 child_process:spawn 起任意程序并给出标准输入输出的流,exec 起一个 shell 命令并把输出攒成字符串,fork 专门起另一个 Node 进程(不是 Unix 的 fork,子进程从头启动),并自带一条 IPC 通道。cluster 模块建在 fork 之上,让多个进程共用一个监听端口。
import { fork } from "node:child_process";import { once } from "node:events";import { fileURLToPath } from "node:url";
if (process.argv[2] === "child") { const n = Number(process.argv[3]); process.send!(n * n); // 经 IPC 通道发给父进程,消息按 JSON 序列化} else { const self = fileURLToPath(import.meta.url); // fork 默认用 process.execPath 和 process.execArgv 起新的 Node,tsx 的加载器参数也在其中 const results = await Promise.all( [1, 2, 3, 4].map(async (n) => { const child = fork(self, ["child", String(n)]); const [message] = await once(child, "message"); return message as number; }), ); console.log("子进程结果(按编号):" + results.join(" ")); console.log(`合计:${results.reduce((a, b) => a + b, 0)}`);}
// 输出:// 子进程结果(按编号):1 4 9 16// 合计:30运行:tsx processes.ts。fork 默认用 process.execPath(node 可执行文件)加 process.execArgv 起新进程,脚本路径来自 import.meta.url。在 tsx 下,execArgv 里是 tsx 注册加载器的 --require …preflight.cjs --import …loader.mjs,子进程因此也能加载 .ts 文件。本机 Node 22 加 tsx 4 实测:Node 22.18 起默认剥离类型,只传 execArgv: [] 子进程仍能跑;再用 --no-experimental-strip-types 关掉,子进程才报 ERR_UNKNOWN_FILE_EXTENSION。
底层怎么实现。 起进程由 libuv 调用操作系统接口完成。fork 额外建一条通道(Unix 上是 Unix 域套接字,Windows 上是命名管道),供 process.send 使用。消息默认按 JSON 序列化,逐条写进通道;选项 serialization: "advanced" 改用结构化克隆,能传 Map、BigInt 等,但仍不能传函数。
- 只能传可序列化的值:实测 JSON 模式下
Map到对面变成{},Date变成字符串,函数属性直接消失,BigInt抛TypeError: Do not know how to serialize a BigInt。 - 子进程注册了
process.on("message")常驻等活,父进程用完不调用child.disconnect()或child.kill():通道一直开着,子进程不退出,父进程也不退出,实测挂住直到被外部杀掉。
C# 怎么做
Section titled “C# 怎么做”.NET 没有 fork,也没有 multiprocessing 那样的进程池;标准库 System.Diagnostics.Process 起一个可执行文件,用重定向的标准输入输出和它通信。
using System.Diagnostics;
if (args is ["child", var arg]){ int n = int.Parse(arg); Console.WriteLine(n * n); // 子进程把结果写到标准输出 return;}
string self = Environment.ProcessPath!; // dotnet run 下是构建出的可执行文件var children = new[] { 1, 2, 3, 4 }.Select(n =>{ var info = new ProcessStartInfo(self, ["child", n.ToString()]) { RedirectStandardOutput = true }; return Process.Start(info)!; // 四个子进程同时运行}).ToList();
var results = new List<int>();foreach (var child in children){ string output = child.StandardOutput.ReadToEnd(); // 先读完输出 child.WaitForExit(); // 再等它退出 results.Add(int.Parse(output));}Console.WriteLine("子进程结果(按编号):" + string.Join(" ", results));Console.WriteLine($"合计:{results.Sum()}");
// 输出:// 子进程结果(按编号):1 4 9 16// 合计:30运行:dotnet run processes.cs。dotnet run 先把单文件程序构建成一个真正的可执行文件再运行它,Environment.ProcessPath 指向的就是这个文件,本机 Windows 上在临时目录下 dotnet\runfile\processes-<哈希>\bin\debug\processes.exe,子进程直接运行它,不再构建。ToList() 让 4 个进程先全部启动,再按编号读输出。
底层怎么实现。 Windows 上 Process.Start 调用 CreateProcess,Linux 和 macOS 上由运行时的原生代码 fork(Linux 上可能是 vfork)后立即 exec,子进程里不会再执行任何 .NET 代码,所以不受 fork 只复制一条线程的影响。重定向就是创建匿名管道,把一端交给子进程当标准输出。传大块数据可用 MemoryMappedFile 共享内存(Windows 上可建不落盘的命名映射,其他平台要基于文件)或命名管道。
- 先
WaitForExit()再读输出:管道缓冲区有限,子进程写满后阻塞在写上,父进程又在等它退出,双方卡死。实测子进程写 1MB,WaitForExit(5000)等满 5 秒返回“未退出”。 - 同时重定向标准输出和标准错误,再先后同步读完两者:子进程写满了还没被读的那一个,同样卡死;其中一个要用
BeginErrorReadLine之类异步读。
Go 怎么做
Section titled “Go 怎么做”Go 用标准库 os/exec 起子进程:cmd.Output() 运行并收集标准输出,StdoutPipe 等方法给出管道,自己边读边处理。没有单独的 fork。
package main
import ( "fmt" "os" "os/exec" "strconv" "strings" "sync")
func main() { if len(os.Args) == 3 && os.Args[1] == "child" { n, _ := strconv.Atoi(os.Args[2]) fmt.Println(n * n) // 子进程把结果写到标准输出 return } self, err := os.Executable() // go run 下是它构建出的可执行文件 if err != nil { panic(err) } results := make([]int, 4) var wg sync.WaitGroup for i := range 4 { wg.Add(1) go func() { defer wg.Done() out, err := exec.Command(self, "child", strconv.Itoa(i+1)).Output() // 起子进程,读完输出并等它退出 if err != nil { panic(err) } results[i], _ = strconv.Atoi(strings.TrimSpace(string(out))) }() } wg.Wait() parts := make([]string, 4) sum := 0 for i, r := range results { parts[i] = strconv.Itoa(r) sum += r } fmt.Println("子进程结果(按编号):" + strings.Join(parts, " ")) fmt.Printf("合计:%d\n", sum)}
// 输出:// 子进程结果(按编号):1 4 9 16// 合计:30运行:go run processes.go。go run 先编译再运行:缓存里没有时编到临时目录(如 go-build…\b001\exe\processes.exe),运行完删除;Go 1.24 起编出的程序还存进构建缓存,再次 go run 直接运行缓存里那份(本机 Go 1.27 实测)。两种情况下 os.Executable() 都是正在运行的文件。每个 goroutine 等一个子进程,结果写进 results 中自己那格,与完成先后无关。
底层怎么实现。 exec.Cmd.Start 调用 os.StartProcess,Unix 上是 syscall.ForkExec:fork 之后子进程只执行几个系统调用就 exec,Windows 上是 CreateProcess。标准库不提供“fork 之后接着跑 Go 代码”的接口:Go 运行时本身有多条线程(调度、垃圾回收),fork 只复制当前线程,子进程里的运行时状态不完整。Output() 内部开 goroutine 一边把管道读进缓冲区,Wait 等进程退出后再等这个 goroutine 读完。
- 用
StdoutPipe时先Wait再读:Wait等子进程退出,子进程写满管道等人读,死锁。实测子进程写 1MB 时程序挂住;文档也写明所有读取结束前调用Wait是错误的。 go run下os.Executable()的路径不固定:可能是运行完就删的临时文件,也可能是构建缓存里的文件(go clean -cache会清掉),不能存下来以后用。
| 维度 | Python | JS/TS | C# | Go |
|---|---|---|---|---|
| 支持程度 | 标准库 multiprocessing |
内置模块 child_process |
标准库 Process |
标准库 os/exec |
| 实现机制 | 启动解释器 + pickle 经管道 | libuv 起进程 + IPC 通道 | CreateProcess 或 fork 加 exec |
ForkExec 或 CreateProcess |
| 启动方式 | fork、spawn、forkserver | 只有新起进程 | 只有新起进程 | 只有新起进程 |
| 通信方式 | Queue、Pipe、shared_memory |
send 消息、标准输入输出 |
标准输入输出、命名管道、MemoryMappedFile |
标准输入输出管道 |
| 序列化 | pickle,自动 | JSON 或结构化克隆,自动 | 自己约定文本格式 | 自己约定文本格式 |
| 典型用途 | CPU 密集计算用多核 | CPU 密集计算、多进程共用端口 | 隔离、调用外部程序 | 隔离、调用外部程序 |
差别最大的是“序列化”一行:Python 和 JS/TS 自动打包对象,C# 和 Go 只给字节管道,格式自己定。
根源在于进程内能不能并行。CPython 的 GIL 让一个进程同一时刻只有一条线程执行字节码,Node 的 JavaScript 跑在一条线程的事件循环上;要吃满多核,最直接的办法是多开几个各带解释器的进程。所以这两种语言把多进程做得像函数调用:Python 有进程池和自动 pickle,Node 有带 IPC 通道的 fork 和共用端口的 cluster。
C# 和 Go 的线程本来就能分到多个核,进程内并行便宜得多,标准库只需要“运行一个外部程序并和它交换字节”。起子进程是为了隔离:运行可能崩溃的代码、调用 git 这类现成工具。
fork 的地位也随平台而变。Unix 传统上靠 fork 起进程,Python 因此长期默认 fork,省掉重新导入的时间;但现代程序多有后台线程,fork 之后的死锁越来越难忽视,于是 macOS 先改为 spawn,3.14 起其他 POSIX 平台也不再默认 fork。Go 和 .NET 的运行时从一开始就是多线程的,干脆不提供 fork。
后果是:起一个进程从几毫秒(Go 编出的小程序)到几十上百毫秒(要启动运行时的语言;本机 Python 起 4 个 spawn 子进程,整个程序约 0.5 到 1 秒),每次传递都要复制和编解码,只有计算远大于这些开销时才划算;好处是子进程崩溃时父进程只读到错误或退出码,照常运行。
- Python 的 CPU 密集任务用
Pool或concurrent.futures.ProcessPoolExecutor,入口代码一律放进if __name__ == "__main__":,交给子进程的函数定义在模块顶层。 - 传给子进程的数据尽量小:传文件路径或编号,让子进程自己去读,不要把大对象整个序列化过去。
- 读子进程输出时先读完再等退出;C# 同时读标准输出和标准错误要有一个异步读,Go 优先用
Output()或CombinedOutput()。 - Node 里长期存活的子进程,用完要
disconnect()或kill();只为用多核而起子进程时,先看 Worker 线程(第 17 章)是否够用。
用 Python 和 Go 各写一个程序:起一个子进程计算 1 到 100 的和,父进程读回结果,打印 子进程返回:5050。
提示:Python 可以不用 Pool,改用 Process 加 Queue,想一想 queue.get() 和 join() 谁在前更安全;Go 试着不用 Output(),改用 StdoutPipe、Start、Wait 自己组合,注意读管道和 Wait 的先后。
- 进程有独立的地址空间,能绕开 GIL 和单线程事件循环真正并行,也能隔离崩溃;代价是启动开销和每次通信的序列化。
- fork 靠写时复制很快,但只复制一条线程;spawn 从头起新程序,慢但干净。
- Python 的
multiprocessing有 fork、spawn、forkserver 三种方式,Windows 只有 spawn,3.14 起 Linux 等平台默认 forkserver;参数和结果用 pickle。 - Node 的
fork自带 IPC 通道,消息默认按 JSON 序列化;C# 的Process和 Go 的os/exec只给字节管道,读完输出再等退出才不会卡死。 - Python 和 JS/TS 用多进程是为了多核,C# 和 Go 用多进程是为了隔离和调用外部程序。