← 返回题目列表

Python multiprocessing 的 fork、spawn、forkserver 有什么区别?

高频 困难 第 15 / 27 题 更新于 2026/07/31
multiprocessingforkspawnforkserver多进程

简化版

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;主模块导入时不能直接创建进程,否则子进程导入主模块又会创建新子进程。

维度forkspawn
启动速度
状态继承继承父进程大部分状态新解释器,状态干净
跨平台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 服务、父进程有线程或复杂运行时,优先考虑 spawnforkserver;生产环境要通过压测和资源库文档确认。

import multiprocessing as mp

if __name__ == "__main__":
    mp.set_start_method("spawn")
    ...

设置启动方式要尽早,通常在程序入口处设置一次。不要在库代码里随意调用 set_start_method(),否则会影响使用方整个进程的多进程行为。

场景倾向
Windows 跨平台spawn
Linux 批处理,父进程简单fork
父进程多线程、想降低 fork 风险spawnforkserver
需要快速创建大量短任务进程先考虑进程池,再评估 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、平台默认值这四件事一起检查,才能避免“本机能跑、换系统就炸”的并发坑。