前言
multiprocessing模块让 Python 能真正并行地跑多个进程:每个进程有独立的解释器、独立的内存空间,因此可以绕开 CPython 的 GIL(全局解释器锁),把多核 CPU 用起来。它对外暴露的第一个、也是最基础的类是Process。
初学者最常被绊住的地方不是语法,而是那句看起来像「装饰」的if __name__ == "__main__":。很多人都写过它,但从没想过为什么多进程代码必须有它——少写一次,在 Windows 上就会看到程序疯狂地自我复制直到报错,或者抛出莫名其妙的RuntimeError。本文就把这件事讲透:Process怎么创建、start()与join()各做什么、进程的入口为什么要保护,以及不同平台默认的启动方式有什么差别。
示例基于 Python 3.8 及以上。文中每一段都会标明「这行在父进程还是子进程里执行」,因为多进程代码最容易出错的地方,正是分不清一段代码会跑几遍。
另外,Python 2.7 已于2020 年 1 月 1 日停止维护。它的xrange在 Python 3 里已被移除,直接用range即可(本就是惰性的);它的print是语句不是函数。老教程里for i in xrange(n): print i这样的多进程示例,在 Python 3 里要改写成for i in range(n): print(i)。
一、Process 的创建与生命周期
官方文档给出的最小例子长这样:
# 适用于 Python 3.8+
from multiprocessing import Process
def f(name):
print("hello", name)
if __name__ == "__main__":
# 以下在父进程执行
p = Process(target=f, args=("bob",))
p.start() # 父进程此处启动子进程;子进程从 f 开始跑
p.join() # 父进程在此阻塞,等子进程结束Process(target=..., args=(...))只是创建一个进程对象,还没有启动任何东西。真正启动的是start()。args必须是元组——args=("bob",)里那个逗号不能少,否则("bob")只是一个字符串,会被当成三个字符参数拆开。
常用的生命周期成员:
| 成员 | 类型 | 作用 | 在哪边可见 |
|---|
start() | 方法 | 启动子进程 | 父进程调用 |
join(timeout=None) | 方法 | 等子进程结束,可设超时 | 父进程调用 |
is_alive() | 方法 | 子进程是否还在运行 | 父进程调用 |
terminate() | 方法 | 发信号强制结束(不保证清理) | 父进程调用 |
exitcode | 属性 | 退出码,None表示未结束 | 父进程读取 |
name | 属性 | 进程名,如Process-1 | 两边都可读 |
pid | 属性 | 进程 ID,未启动时为None | 两边都可读 |
daemon | 属性 | 是否为守护进程 | 需在start()前设置 |
一个把「谁在跑」看清楚的小例子:
# 适用于 Python 3.8+
import os
from multiprocessing import Process
def worker(name):
# 下面这些行只会在【子进程】里执行
print(f"[子进程] 名字={name} 子PID={os.getpid()} 父PID={os.getppid()}")
if __name__ == "__main__":
# 下面这些行只在【父进程】里执行
print(f"[父进程] PID={os.getpid()}")
jobs = [Process(target=worker, args=(n,)) for n in ("alpha", "beta", "gamma")]
for p in jobs:
p.start() # 连续启动三个子进程,它们并发运行
for p in jobs:
p.join() # 等三个都结束
print("全部退出码:", [p.exitcode for p in jobs])这段代码里,worker的函数体是被子进程执行的,而构造Process、start()、join()都发生在父进程。os.getpid()在子进程里返回的是子进程自己的 PID,os.getppid()返回父进程 PID,两相对照就能确认父子关系。
几点要留心:
join()之前exitcode是None。子进程正常结束退出码是0,被terminate()或异常结束则非零。daemon=True的进程会在父进程退出时被一起终止,而且守护进程不允许再创建子进程。设置daemon必须在start()之前,启动之后再改会抛AssertionError。terminate()是强杀,不给子进程清理的机会,滥用可能留下损坏的中间文件或没释放的共享资源。Process的 API 与threading.Thread基本对应,从线程切到进程时用法是相似的。
二、为什么必须放在 if __name__ == "__main__" 里
先看官方文档里的反面教材:
# 反面示例:没有入口保护,在 spawn 方式下会出问题
from multiprocessing import Process
def foo():
print("hello")
p = Process(target=foo) # 这行在模块顶层
p.start()文档说得很明确:在spawn或forkserver启动方式下,运行上面这个模块会以RuntimeError失败。原因在于「新进程要能安全地导入主模块」这条要求。
理解它需要一个关键前提:Windows 与 macOS 默认用spawn方式启动子进程。spawn的做法是「父进程启动一个全新的 Python 解释器进程」,这个新解释器要重新把主模块(也就是你的.py文件)从头 import 一遍。如果没有if __name__ == "__main__":这层保护,那么「import 主模块」这一步又会执行到模块顶层的p.start(),于是再启动一个新的解释器、再 import 一遍、再start()……进程数呈指数增长,直到系统资源耗尽或解释器报错。
加上保护后,情况就变成:
# 适用于 Python 3.8+
from multiprocessing import Process
def foo():
print("hello")
if __name__ == "__main__":
# 子进程重新 import 本模块时,__name__ 不是 "__main__",
# 于是这段被跳过,只跑了上面的 foo 定义,不会再次启动进程
p = Process(target=foo)
p.start()
p.join()关键在于__name__的取值:主程序运行时__name__是"__main__",所以保护块会执行;子进程 import 本模块时,__name__是模块名而非"__main__",保护块被跳过。这就是「入口保护」的全部道理。
还有一个相关的实际影响:Process的参数通常需要可 pickle(序列化)。文档指出,参数要被序列化后传给子进程,所以传进去的东西必须可 pickle;如果直接在交互式解释器里敲上面的例子,子进程在__main__模块里找不到f函数,会抛AttributeError。lambda同样不可 pickle,不能直接当target。
三、三种启动方式与平台差异
multiprocessing支持三种启动方式,选择不同,进程的「出生姿势」完全不同:
| 启动方式 | 做法 | 默认平台 | 特点 |
|---|
spawn | 起一个全新的 Python 解释器 | Windows、macOS | 只继承必需资源,启动较慢 |
fork | 用os.fork()复制父进程 | 3.14 起不再是任何平台的默认 | 子进程与父进程几乎相同,多线程下不安全 |
forkserver | 先起一个服务器进程,再让它 fork | 3.14 起是 POSIX/Linux 的默认 | 不继承多余资源,单线程服务器较安全 |
版本变化要记清:spawn自 Python 3.8 起在 macOS 上成为默认;fork从Python 3.14 起不再是任何平台的默认启动方式,需要fork的代码必须显式指定;同时 3.14 起 POSIX 平台的默认从fork改成了forkserver。文档解释这一改动是为了「保留性能又避免多线程进程的常见不兼容」。另外,从 3.12 起,如果解释器检测到进程里有多个线程,fork内部调用的os.fork()会发出DeprecationWarning。
选择启动方式有两种写法。一是在程序里指定,但必须在创建任何进程之前:
# 适用于 Python 3.8+
import multiprocessing as mp
def foo():
print("hello")
if __name__ == "__main__":
mp.set_start_method("spawn") # 只能调用一次,且要在用进程之前
p = mp.Process(target=foo)
p.start()
p.join()二是用get_context()拿到一个「上下文对象」,再从这个上下文里创建进程。这种方式更干净,适合同一个程序里需要混用不同启动方式的场景:
# 适用于 Python 3.8+
import multiprocessing as mp
def foo():
print("hello")
if __name__ == "__main__":
ctx = mp.get_context("spawn") # 得到一个 spawn 上下文,不改变全局默认
p = ctx.Process(target=foo) # 从这个上下文创建 Process
p.start()
p.join()get_context("spawn")的好处是它不会去动全局状态;set_start_method则是「一锤定音」,重复调用(除非传force=True)会RuntimeError。
四、Process 拿不到返回值
这是刚接触多进程时最容易困惑的一点:p = Process(target=f); p.start(); p.join()之后,p上没有地方能取到f的返回值。Process不提供result之类的成员,能拿到的只有exitcode和进程信息。
# 适用于 Python 3.8+
from multiprocessing import Process
def add(a, b):
return a + b # 这个返回值传不回父进程
if __name__ == "__main__":
p = Process(target=add, args=(1, 2))
p.start()
p.join()
print(p.exitcode) # 0,表示正常结束
# print(p.result) # 根本没有这个属性这不是 bug,而是设计使然:进程之间不共享内存,子进程的返回值如果没被显式送回来,就随着子进程一起消失了。想把结果带回父进程,必须借助进程间通信机制——队列、管道、管理器对象或共享内存。这是多进程编程里一条根本性的分界线,也是最有价值的入门认知:Process负责「跑起来」,不负责「把结果带回来」。
顺带把 GIL 的位置说准:GIL 是CPython 实现的机制,保证同一进程内同一时刻只有一个线程执行 Python 字节码。它因此限制了多线程对 CPU 密集任务的收益,而多进程因为各自有独立的解释器,不受这个限制。但要注意,GIL 只是一把「解释器级」的锁,它并不保证你写的复合操作是原子的——x += 1这种「读、加、写」三步,在多线程下依然可能交错。多进程场景虽然回避了 GIL,却换来了「不共享内存、要序列化」这另一套限制。
常见坑点
坑点 1:把p.start()写在模块顶层。❌ 在if __name__ == "__main__":之外调用p.start()—— 在spawn方式下子进程重新导入主模块时会再次执行这行,进程无限自我复制。 ✅ 所有创建与启动进程的代码都放进if __name__ == "__main__":保护块。
坑点 2:args忘记写成元组。❌Process(target=f, args=("bob"))—— 少一个逗号,这其实是字符串,会被当成三个单字符参数。 ✅ 写args=("bob",);只有一个参数时那个逗号是必须的。
坑点 3:以为join()能拿到返回值。❌p.join()之后读p.result—— 没有这个属性。 ✅ 用队列、管道、管理器或共享内存把结果传回来;join()只负责「等它结束」。
坑点 4:调了start()才去设置daemon。❌p.start(); p.daemon = True—— 启动后再设会抛AssertionError。 ✅daemon必须在start()之前设置。
坑点 5:set_start_method调了两次。❌ 在不同函数里各set_start_method("spawn")一次 —— 第二次会RuntimeError。 ✅ 只在入口处设置一次,或改用get_context()拿局部上下文,不动全局默认。
坑点 6:把lambda或交互式里定义的函数当target。❌ 在 REPL 里Process(target=lambda x: x)—— 无法 pickle,子进程会报错或AttributeError。 ✅ 用模块顶层定义的有名函数;把入口代码放进.py文件跑,而不是交互式环境。
坑点 7:子进程里改了变量,父进程却以为能看到。❌ 子进程给一个全局变量赋值,父进程读到的还是旧值 —— 进程之间不共享内存。 ✅ 需要共享时必须显式使用进程安全的共享机制,普通变量各自一份。
坑点 8:用terminate()当常规收尾手段。❌ 任务本该自然结束,却统一用terminate()强杀 —— 不保证资源清理,可能留下半截文件。 ✅ 常规路径用join()等它自然结束;terminate()只在需要中止时才用。
总结
| 你想做的事 | 写法 | 关键点 |
|---|
| 起一个子进程跑函数 | Process(target=f, args=(x,))然后start() | 创建与启动是两步 |
| 等子进程结束 | p.join() | 之后才能可靠地读exitcode |
| 看进程是否还活着 | p.is_alive() | 配合轮询或超时使用 |
| 判断成功与否 | p.exitcode | 0 表示正常结束 |
| 保护入口 | if __name__ == "__main__": | Windows/macOS 默认 spawn,必须有 |
| 指定启动方式 | set_start_method/get_context | 后者不改全局状态 |
| 拿子进程的结果 | 进程间通信机制 | Process本身不提供返回值 |
Process的全部职责可以概括成一句:把一个函数在另一个解释器里跑起来,并让你能等它、看它的退出状态。围绕它需要记住三件事——入口保护(if __name__ == "__main__":)不是可选项,因为 Windows 与 macOS 默认用spawn会重新导入主模块;参数必须可 pickle,进程之间不共享内存;返回值不会自动回来,要另想办法。把这三条分清,多进程的代码就不会再出现「怎么跑了这么多遍」和「结果去哪了」这两类典型困惑。