Python multiprocessing 的 fork、spawn、forkserver 有什么区别?
简化版
multiprocessing 创建子进程有三种常见启动方式:fork 复制当前进程,速度快但容易继承不安全状态;spawn 启动全新解释器,再导入主模块,跨平台更稳但开销更大;forkserver 由一个干净的服务进程负责 fork,折中减少多线程 fork 的风险。跨平台和生产代码里要特别注意 if __name__ == "__main__"。
详细版
fork 常见于 Unix:子进程几乎从父进程当前状态复制出来,利用写时复制,创建快。但如果父进程已经启动线程、持有锁、打开网络连接或初始化复杂运行时,fork 后子进程可能继承到不一致状态。
spawn 是 Windows 默认方式,也是 macOS 新版本常见默认方式。它会启动新的 Python 解释器,重新导入主模块,然后执行目标函数。它更干净、更跨平台,但要求目标函数可 pickle,且必须用 if __name__ == "__main__" 保护入口,避免子进程导入主模块时递归创建进程。
forkserver 会先启动一个单线程、状态干净的 server,后续子进程从这个 server fork 出来。它比 spawn 快一些,又比从复杂父进程直接 fork 更安全。面试回答重点是:启动方式影响性能、可移植性、内存继承、线程安全和代码写法。
完整版教学
一、为什么多进程启动方式会影响程序正确性
多进程不是简单“开一个新 Python”。子进程从哪里来,决定了它继承哪些内存、锁、文件描述符、模块状态和线程状态。启动方式不同,同一段代码可能在 Linux 上正常,在 Windows 或 macOS 上报错。
最典型的问题是:父进程导入模块时就创建进程,spawn 子进程又会导入主模块,于是再次创建子进程,形成递归。正确写法要把入口放进 if __name__ == "__main__"。
from multiprocessing import Process
def work():
print("child")
if __name__ == "__main__":
p = Process(target=work)
p.start()
p.join()
只要写
multiprocessing,先想入口保护;这不是风格问题,是spawn模式下避免递归创建进程的硬要求。
二、fork:快,但继承状态复杂
fork 会复制父进程当前状态创建子进程。复制不是立刻把所有内存都拷贝一份,而是依赖写时复制:父子进程先共享物理页,谁修改某页时,操作系统再复制那页。这让 fork 创建速度很快。
父进程内存: [A][B][C]
fork 后:
父进程 -> [A][B][C]
子进程 -> [A][B][C] 先共享
子进程修改 B:
父进程 -> [A][B][C]
子进程 -> [A][B'][C]
但快的代价是状态继承复杂。父进程如果有多个线程,fork 后子进程只保留调用 fork 的那个线程,其他线程消失,但它们持有的锁状态可能还在。于是子进程可能看到“锁已被持有,但持锁线程不存在”的死锁场景。
三、spawn:干净,但要求更严格
spawn 不复制当前解释器状态,而是启动一个新的 Python 解释器。新进程会导入主模块,再通过 pickle 反序列化目标函数和参数。这个模型更干净,也更适合跨平台,但启动更慢。
父进程
|
| spawn
v
新 Python 解释器
|
导入主模块
|
反序列化 target 和 args
|
执行子任务
这解释了两个限制:目标函数最好定义在模块顶层,lambda、局部函数、闭包对象经常不能 pickle;主模块导入时不能直接创建进程,否则子进程导入主模块又会创建新子进程。
| 维度 | fork | spawn |
|---|---|---|
| 启动速度 | 快 | 慢 |
| 状态继承 | 继承父进程大部分状态 | 新解释器,状态干净 |
| 跨平台 | Unix 常用 | Windows/macOS 友好 |
| 线程安全 | 多线程父进程中风险高 | 更稳 |
| target 要求 | 相对宽松 | 必须可 pickle |
四、forkserver:为什么是折中方案
forkserver 的思路是先启动一个专门的 server 进程。这个 server 状态比较干净,通常没有业务线程和复杂锁;后续需要子进程时,由 server fork 出子进程。这样既利用 fork 的部分性能优势,又避免从复杂父进程直接 fork。
主进程
|
请求创建进程
|
forkserver(干净、单线程)
|
fork 子进程
它适合对 fork 安全性敏感,又不想每次 spawn 都付出完整启动成本的场景。不过它也不是所有平台都可用,代码仍要考虑 pickle、入口保护、资源初始化位置等问题。
五、数据库连接、锁和随机状态为什么要小心
进程启动方式会影响资源继承。fork 后子进程可能继承父进程已经打开的数据库连接、socket、日志句柄、随机数状态。某些连接库不允许 fork 后继续复用父进程连接,可能导致协议错乱或连接池状态异常。
工程上更稳的做法是:在子进程启动后重新初始化进程内资源,而不是盲目复用父进程对象。比如数据库连接池、HTTP Session、GPU 上下文、日志 handler,都要确认是否 fork-safe。
def worker(task):
db = create_db_connection() # 子进程内初始化
try:
handle(task, db)
finally:
db.close()
数字化理解:父进程连接池里有 10 条连接,fork 出 4 个子进程后,看起来像有 40 个连接句柄副本,但数据库服务端并不知道这些副本的进程边界。多个进程同时操作同一底层连接,很容易出问题。
六、怎么选择启动方式
选择可以按场景来:简单 Linux 脚本、父进程单线程、追求启动速度,可以用 fork;跨平台代码、Web 服务、父进程有线程或复杂运行时,优先考虑 spawn 或 forkserver;生产环境要通过压测和资源库文档确认。
import multiprocessing as mp
if __name__ == "__main__":
mp.set_start_method("spawn")
...
设置启动方式要尽早,通常在程序入口处设置一次。不要在库代码里随意调用 set_start_method(),否则会影响使用方整个进程的多进程行为。
| 场景 | 倾向 |
|---|---|
| Windows 跨平台 | spawn |
| Linux 批处理,父进程简单 | fork |
| 父进程多线程、想降低 fork 风险 | spawn 或 forkserver |
| 需要快速创建大量短任务进程 | 先考虑进程池,再评估 fork/forkserver |
七、常见误区与追问
- 误区:多进程在所有系统上行为一样。 Windows、Linux、macOS 默认启动方式可能不同,代码必须考虑跨平台差异。
- 误区:
fork复制内存会立刻翻倍。 操作系统通常使用写时复制,只有修改页面时才复制物理内存。 - 误区:
spawn只是慢一点,没有代码要求。spawn需要 target 和参数可 pickle,并且必须保护入口,避免递归启动。 - 追问:为什么多线程程序里 fork 危险? 子进程只保留当前线程,其他线程消失,但锁状态可能被继承,导致死锁。
- 追问:为什么子进程里要重新建数据库连接? fork 继承的连接池和 socket 状态可能不安全,多个进程复用同一连接会出错。
- 追问:进程池和启动方式是什么关系? 进程池底层仍要用某种 start method 创建 worker,启动方式决定 worker 初始化语义。
- 追问:库代码能不能强行设置 start method? 不建议;这是应用级决策,库里强设会干扰调用方。
八、加强记忆
把三种启动方式记成“fork 复制、spawn 新生、forkserver 找干净中介”。fork 快但继承状态复杂,spawn 干净但更慢且要求可 pickle,forkserver 在安全和性能之间折中。写多进程代码时,入口保护、资源重建、目标函数可 pickle、平台默认值这四件事一起检查,才能避免“本机能跑、换系统就炸”的并发坑。