Python multiprocessing spawn:主模块重载与进程池函数可导入性
进程池刚启动,脚本开头的打印又出现了几次,随后程序抱怨启动新进程时主模块尚未初始化。先检查顶层代码,而不是先把worker数改成1。使用spawn时,子进程会建立自己的解释器,并重新准备可导入的主模块;放在模块顶层的启动动作也可能再次执行。
主入口保护解决的是“导入模块时不要再启动一轮进程”。它不会让局部函数突然可序列化,也不会把父进程里已经打开的连接安全地变成子进程资源。下面固定Python 3.12,明确选择spawn,让不同平台也能复现同一类入口规则。
两个文件,把可导入工作函数与启动动作分开
新建空目录,保存 worker_jobs.py:
import os
def transform(value):
return {
"input": value,
"result": value * value,
"pid": os.getpid(),
"module": __name__,
}
再保存 run_jobs.py:
import multiprocessing
import os
import pickle
from concurrent.futures import ProcessPoolExecutor
from worker_jobs import transform
print("driver module prepared:", __name__)
def main():
context = multiprocessing.get_context("spawn")
parent = os.getpid()
with ProcessPoolExecutor(max_workers=2, mp_context=context) as pool:
results = list(pool.map(transform, [2, 3, 4]))
assert [item["result"] for item in results] == [4, 9, 16]
assert all(item["pid"] != parent for item in results)
assert all(item["module"] == "worker_jobs" for item in results)
print("results:", results)
def local_transform(value):
return value + 1
failed = pool.submit(local_transform, 1)
try:
failed.result()
except (AttributeError, pickle.PicklingError):
print("local function: serialization rejected")
else:
raise AssertionError("local function unexpectedly serialized")
print("pool has shut down")
if __name__ == "__main__":
main()
在这个目录执行:
python3 --version
python3 run_jobs.py
主进程先准备主模块,子进程也会打印模块准备信息,名称可能显示为 __mp_main__。结果中的PID应不同于父进程,平方结果按输入顺序返回;具体PID与任务落在哪个worker不固定,也不应成为测试条件。
顶层打印只是观察导入时序的探针。实际库模块不应在导入时启动服务、重建数据或提交任务,否则这种副作用会进入每个需要导入它的执行环境。示例保留打印,正是为了把“模块又执行了一遍”从猜测变成可观察行为。
max_workers=2只给出并发工作者上限,不保证三个短任务均匀分配到两个PID。某个worker先准备好,可能连续完成全部小任务;为证明进程存在而给每个任务添加随机延时,会让演示更难解释。这里仅断言执行PID不同于父进程,工作结果与输入关系正确,不对调度公平性作额外承诺。
子进程的标准输出也可能交错,打印次序不等于任务提交次序。诊断时保留PID和项目编号更有用;业务结果则通过Future或map返回,不应靠解析控制台文本取得。
main保护挡住的是入口,不是整个文件的导入
子进程仍然需要定义、导入和准备工作函数,所以不能把所有内容都藏进main以后,再把其中的局部函数交给池。常规pickle按可导入位置定位这类函数,main.<locals>.local_transform没有一个可以让新解释器正常导入的模块级地址。
示例的局部函数失败项说明,即使main保护已经正确,序列化约束仍然存在。把它搬到 worker_jobs.py 的模块级,再提交同样参数,才符合这里选择的工作协议。lambda通常也不适合作为普通进程池的可导入工作入口。
参数与结果同样要能传输。文件对象、网络连接、锁或带有不可序列化成员的复杂实例,都不能因为工作函数在顶层就自动获得跨进程含义。更清楚的做法是传路径、标识和必要配置,由worker在自己的作用域内获取资源。
这也解释了为什么交互式解释器或某些Notebook里的例子失败:当前定义不一定能被子进程按普通模块方式导入。先把最小样本保存为实际文件执行,可以把入口可导入性与任务业务错误分开排查。
显式选择上下文,比依赖默认启动方式更容易复现
代码没有修改整个应用的全局启动方式,只为当前池传入mp_context。库代码若随意调用全局set_start_method,可能与宿主程序已经作出的选择冲突;把上下文作为构造依赖更容易组合。
spawn不会凭空共享父进程的全部运行状态。模块全局对象在子进程如何建立,受导入路径与初始化代码影响,不能靠父进程先改了一个全局字典,就假设每个worker读到相同实时修改。
其他启动方式有不同继承行为,默认值还会随平台和Python版本变化。教程固定spawn是为了明确讨论边界,不是在声称所有进程池都按同一方式启动,也不建议为绕过一个导入错误就无条件换成fork。
初次启动解释器、导入模块和传输对象本身都有成本。非常小的计算交给进程池,未必比直接运行更合适;是否获益要按真实负载验证。结果里出现多个PID,只证明工作发生在其他进程,不能证明性能提高。
把初始化责任留在对应进程里
worker若需要昂贵只读配置,可以考虑池的initializer,在各自进程启动时准备;每个worker仍有自己的副本和资源寿命。连接类资源应该在实际使用它的进程中建立,并按对应组件协议关闭,不要传一个父进程句柄期待它变成跨平台共享连接。
异常结果通过Future返回,主进程需要实际读取。只submit以后丢掉引用,序列化错误可能已经发生,业务层却没有记录对应项目。工作提交成功,不代表参数已经完成传输,更不代表函数已经执行。
示例把局部函数的错误当预期测试项处理,其他异常保留正常传播。若worker意外退出,池还可能进入不可继续使用的状态;这与某条数据校验失败不同,不应一律捕获后继续往同一个池提交。
最后将那条顶层打印移入main,再运行脚本:子进程准备模块的信息不再出现,但worker结果仍然正常返回。这一步变化正好验证主入口保护的用途——让导入只准备定义,让真正的启动动作只由拥有者执行。