☰
Python multiprocessing完全指南:从GIL原理到进程池实战避坑
2026/10/18 5:02:35 网站建设 项目流程

第一次接触multiprocessing,是因为一个很蠢的需求:手上有几百万条文本要批量去重,里面还夹着一堆正则匹配,单线程跑一次要好几个小时。我把代码改成threading后发现 CPU 占用只有 25%,四个核心闲着三个,这才意识到问题出在哪。后来把任务改成多进程,几个小时的活压到了二十多分钟。从那时起我开始系统地记录 Python 多进程的用法,从最基础的Process到Pool、Queue、Pipe、共享内存、同步原语,再到各种让人抓狂的坑,前前后后积累了快四万字。这篇就是其中最有代表性的一部分,而且我会把它当作一份持续更新的文档来维护,2026 年这次修订,补充了不少新版本解释器下的实测结论。

文章主要覆盖两层内容:第一层是可以直接抄走的代码骨架,包括Process与Pool的常用姿势、进程间通信的主流方案、防止数据错乱的同步手段;第二层是我这些年踩过的坑,比如fork和spawn的区别、Pool嵌套死锁、子进程残留、以及任务切分对性能的影响。如果你是第一次尝试并行计算,前面三章足够让你跑起来;如果你已经在用了但偶尔遇到解释不清的怪问题,直接跳到第五章,那里有个排查清单能帮你省下不少时间。

1. 为什么绕不开 multiprocessing:GIL 与 CPU 密集任务的那点事

1.1 GIL 到底限制了什么

GIL 的全称是 Global Interpreter Lock,全局解释器锁,它是 CPython 解释器内部的一把大锁。很多人都听过这个名字,但不一定清楚它限制的到底是什么。它保证同一时刻只有一个线程在执行 Python 字节码,注意是“字节码”而不是“代码”。这意味着你的 Python 线程面对纯计算任务时,根本无法真正利用多核 CPU。

那为什么网络爬虫、文件读写这类程序用多线程效果还不错?因为线程在等待网络响应、等待磁盘数据的时候,会主动释放 GIL,让其他线程有机会执行。这时候线程切换带来的开销远小于等待开销,多线程自然划算。可一旦你的任务变成“大量算术运算”“字符串处理”“正则匹配”这类持续占用 CPU 的活,GIL 就会被当前线程长期霸占,其他线程即使就绪也只能干等着,结果就是一个多线程程序在单核上排队执行。

多进程能绕过 GIL,原理也很直接:每个进程都有自己独立的 Python 解释器、独立的内存空间、独立的 GIL。4 个进程就是 4 个解释器在跑,互相之间谁也不挡谁的路,这才谈得上真正的并行。

1.2 多进程、多线程、协程怎么选

很多人一上来就问“是不是多进程一定比多线程好”,这个问题没有标准答案,关键看任务卡在哪。IO 密集型的任务,比如发一万个 HTTP 请求,多线程往往比多进程更合适,因为线程开销小,而且大部分时间都在等待;CPU 密集型的任务,比如图像滤波、词频统计、数据压缩,才需要多进程把计算压力分散到多个核心上;协程则适合高并发但非阻塞 IO 的场景,类似同时管理几千个连接的长连接服务。

方案启动开销内存隔离适合场景典型例子
多线程较小共享IO 密集,等待时间长爬虫、批量请求、文件拷贝
多进程较大独立CPU 密集,任务之间耦合少数据处理、图像处理、科学计算
协程最小共享高并发、单线程内切换异步 API 调用、WebSocket 服务

实际开发里往往混着用,比如主进程里开一个进程池负责计算,每个计算任务内部再用协程或者线程去拉取前置数据,这是一个很常见的三层混合模型。做选型时先问自己一个问题:如果把任务换成纯while循环死算,线程数量翻倍,CPU 占用会上升吗?如果不会,说明瓶颈被 GIL 卡住了,直接上多进程。

1.3 多进程不是免费的

多进程能并行,但代价不小。每个进程启动时要重新初始化解释器,复制一部分父进程的资源和状态,耗时和内存都比线程高一个量级。进程间通信要经过序列化和反序列化,数据量一大吞吐量立刻下降。所以在动手改造之前,先确认任务确实值得付出这些成本。我的判断标准很简单:任务总耗时超过 10 秒,且能明确切分成互不依赖的小块,才值得上多进程;如果任务本身就几百毫秒,进程启动和结果回传的时间反而会超过计算时间,得不偿失。

2. 从零起步:Process、Pool 与一套可以照抄的代码骨架

2.1 最小可用的 Process 写法

先看最直接的用法,用multiprocessing.Process手工创建子进程:

import multiprocessing as mp import time def worker(name: str, delay: float): print(f"进程 {name} 开始") time.sleep(delay) print(f"进程 {name} 结束") if __name__ == "__main__": p1 = mp.Process(target=worker, args=("A", 2)) p2 = mp.Process(target=worker, args=("B", 1)) p1.start() p2.start() p1.join() p2.join() print("两个子进程都执行完了")

这段代码里start()会创建子进程并开始执行target函数,join()会让主进程等待子进程结束。如果缺了join(),主进程可能先退出,子进程变成孤儿进程,行为变得不可控。

if __name__ == "__main__"这行不是装饰,而是必须。尤其在使用spawn启动方式的平台,创建子进程时会重新导入主模块,如果不加这层保护,子进程导入主模块时又去创建子进程,就会形成递归式进程爆炸。我见过有人把这行删掉后在 Windows 上跑出来几千个 Python 进程,最后只能强制重启机器。

2.2 用 Pool 优雅调度一批任务

手工Process管理少量进程还够用,但任务一多就很痛苦:要自己维护进程列表、处理异常、回收结果。更优雅的方式是用进程池Pool,它可以提前创建固定数量的工作进程,然后把任务映射进去:

from multiprocessing import Pool def square(x: int) -> int: return x * x if __name__ == "__main__": with Pool(4) as pool: results = pool.map(square, range(10)) print(results)

pool.map会把range(10)这十个任务均匀分配给 4 个工作进程,全部执行完后按输入顺序返回结果。整个流程对调用方隐藏了进程调度的细节,是世界上比较干净的并行化方式。

Pool还提供另外几个常用方法:

  • imap:惰性返回迭代器,边执行边取结果,适合任务大、不想一次性加载全部结果到内存的场景。
  • starmap:与map类似,但支持多参数传入,args里的每个元素是一个元组。
  • apply_async:异步提交单个任务,返回一个AsyncResult对象,可以调用get(timeout=...)等待结果。

这里有个容易被忽略的细节:with Pool(4) as pool离开上下文时会强制终止池内进程。如果之前调用的是map这种同步方法,任务执行完才退出,没有影响;但如果用了map_async,任务还没执行完就离开with块,子进程会被直接terminate,拿不到任何结果。很多人第一次遇到Pool结果为空就是这个原因。

2.3 一套适合日常任务的骨架

我平时写并行任务,一般不会每次都从头组织代码,而是套用下面这个骨架,它可以根据任务类型快速改造:

import multiprocessing as mp from multiprocessing import Pool from typing import Any, Callable, Iterable def run_parallel(func: Callable, items: Iterable, workers: int = None) -> list: workers = workers or mp.cpu_count() items = list(items) with Pool(workers) as pool: results = pool.map(func, items) return results if __name__ == "__main__": data = range(1_000_000) output = run_parallel(square, data, workers=8)

这套骨架的关键在workers参数的设置。直接调用mp.cpu_count()返回的是逻辑 CPU 数量,很多机器上因为超线程,这个数字是物理核数的两倍。对于纯计算任务,进程数设为物理核数往往最合适;超过之后不仅提升有限,还会增加调度和内存压力。具体怎么测试,我会在第六章用真实数据说明。

3. 跨进程通信:Queue、Pipe 与共享内存的取舍

3.1 Queue 与生产者消费者模型

进程之间是独立的内存空间,不能像线程那样通过修改同一个变量来通信,必须走进程间通信机制。最常用的就是队列multiprocessing.Queue。注意这里不能使用标准库的queue.Queue,那个队列只能在同一进程内的线程之间工作,跨进程存取会直接报错。

一个典型的生产者消费者模型长这样:

import multiprocessing as mp import time def producer(q: mp.Queue, total: int): for i in range(total): q.put(i) q.put(None) # 终止哨兵 def consumer(q: mp.Queue): while True: item = q.get() if item is None: break print(f"消费 {item}") time.sleep(0.01) if __name__ == "__main__": q = mp.Queue(maxsize=100) p1 = mp.Process(target=producer, args=(q, 50)) p2 = mp.Process(target=consumer, args=(q,)) p2.start() p1.start() p1.join() p2.join()

队列是 FIFO 的先进先出结构,put在队列满时会阻塞,get在队列空时会阻塞。这个阻塞特性既是好处也是问题:如果消费者处理完所有数据后没有收到“结束信号”,它会永远卡在get上。所以我在生产者最后塞入None作为哨兵,消费者收到None就退出。

这里有一个非常经典的坑:如果消费者进程不止一个,一个None不够用。比如两个消费者,第一个抢到了None退出,第二个还在q.get()里傻等。解决办法是把哨兵也变成 N 份,在生产者结束时放入与消费者数量相同的None。很多人在单消费者时测试没问题,一改成多消费者就发现有一两个进程挂在那里不动,八成就是哨兵数量没配对。

3.2 Pipe 双向通信与它的脾气

Pipe是另一种通信方式,它在两个进程之间建立一条管道,两端各持有一个连接对象:

parent_conn, child_conn = mp.Pipe() def child_sender(conn): conn.send({"status": "ok", "value": 42}) conn.close() if __name__ == "__main__": p = mp.Process(target=child_sender, args=(child_conn,)) p.start() msg = parent_conn.recv() print(msg) p.join()

send和recv是一一对应的,发送方send一条数据,接收方必须recv一条数据。Pipe通常比Queue快,因为它更轻量,不像Queue那样内部还要开一个线程来管理缓冲。但Pipe的脾气也更坏:

  • 两端同时向对方send大量数据,又同时等待recv,可能引发死锁。
  • 管道是点对点的,只适合两个进程之间通信,不适合一对多广播。
  • 默认创建的Pipe是双向的,如果只想单向传递,可以传duplex=False。

日常使用中,我倾向于先用Queue,因为它的语义更宽容,适合生产者和消费者数量不对称的模型;只有当两个进程之间需要高频交换小块数据、对延迟非常敏感时,才换成Pipe。

3.3 共享内存 Value 与 Array

第三种方式是直接在进程间共享一小块内存,使用Value和Array:

import multiprocessing as mp def increment(counter, lock): for _ in range(1000): with lock: counter.value += 1 if __name__ == "__main__": counter = mp.Value("i", 0) lock = mp.Lock() processes = [ mp.Process(target=increment, args=(counter, lock)) for _ in range(4) ] for p in processes: p.start() for p in processes: p.join() print(counter.value)

mp.Value("i", 0)里的"i"是类型代码,表示有符号整数。Array的用法类似,比如mp.Array("d", [0.0, 0.0, 0.0]),其中"d"表示双精度浮点数。

共享内存的读写性能比Queue高很多,但也更原始。counter.value += 1表面上是一句赋值,实际上是“读取到 Python 层、加一、写回”三步。两个进程如果同时执行,很容易互相覆盖,所以要配合Lock使用。如果完全不希望有额外锁开销,可以用mp.RawValue和mp.RawArray,但那就等于把同步责任完全交给了业务代码,新手我不建议碰。

3.4 三种通信方案的快速对照

方案方向阻塞行为性能推荐场景
Queue一对多、多对一put/get 都会阻塞中等,有序列化开销任务分发、结果收集、生产者消费者
Pipe两进程点对点send/recv 需要配对较高两个进程高频通信
Value/Array多进程共享内存无内置阻塞最高计数器、共享状态、数值更新

这三者不是互斥的,很多正经项目会同时用到两个或多个。比如一个图像处理流水线,主进程用Queue把任务分给 8 个 worker,worker 之间用Array共享一块中间缓存,最后再用Queue把结果收回来。通信方案的选择应该跟着数据流向走,不要一上来就挑性能和复杂度都最高的方案。

4. 同步原语:Lock、Event 与信号量,避免进程打架

4.1 日志打印与计数器的锁

多进程之间没有共享变量,看似不会出现线程安全的问题,但实际上多个进程同时写同一个文件、同一个终端、同一块共享内存时,照样会互相干扰。最直观的例子就是打印日志:

import multiprocessing as mp def worker(idx: int, lock: mp.Lock): with lock: print(f"[{idx}] start") time.sleep(0.5) print(f"[{idx}] end") if __name__ == "__main__": lock = mp.Lock() processes = [ mp.Process(target=worker, args=(i, lock)) for i in range(5) ] for p in processes: p.start() for p in processes: p.join()

没有锁的时候,多个进程的print输出可能互相穿插,比如[0] start和[1] start挤在同一行。加上Lock之后,执行到with lock:的进程会独占这段代码,其他进程必须等它完成才能进入。

Lock的另一个常见用途是保护共享内存更新。前面提到的counter.value += 1,如果没有锁,4 个进程各自同时读到一个旧值,结果会小于预期。这一点和线程里的+=非原子问题几乎一模一样,只是发生原因从“线程交替”变成了“不同进程独立并发”。

4.2 Event 让进程等待信号

Event是一种更细粒度的同步方式,它维护一个内部标志位,布尔值为真表示事件已发生。常用方法只有三个:set()把标志设为真,wait()阻塞直到标志为真,clear()把标志重置为假。

典型场景是主进程需要等所有 worker 就绪之后再统一开跑:

def worker(ready: mp.Event, start: mp.Event): ready.set() start.wait() print("开始干活") time.sleep(1) print("干完了")

Event的语义和线程里的几乎一致,非常适合做“栅栏”“发布信号”“停止信号”这类控制流的传递。与Queue不同,它不传业务数据,只传一个开关状态,因此几乎没有序列化开销,在控制流同步场景里效率很高。

4.3 Semaphore 限制并发资源

Semaphore可以理解为一个计数器,初始值代表允许同时进入的进程数量。每次调用acquire()会让计数器减一,计数器为零时后续进程会阻塞;调用release()计数器加一。这个特性适合限制对稀有资源的并发访问。

场景很具体:假设一个任务需要访问某个数据库连接池,而这个连接池最多只支持 3 个并发连接。不用信号量时,进程池里 8 个 worker 可能同时涌过去,把连接池打爆;加上信号量后,只有 3 个 worker 能进入连接区:

db_semaphore = mp.Semaphore(3) def query_db(query: str): with db_semaphore: print(f"执行查询: {query}")

信号量不是万能的,它只控制“并发数量”,不关心资源是否真的就绪,也不做资源分配。但如果只是要限制并发上限,它比手写计数器简洁可靠得多。

5. 避坑清单:fork 与 spawn、Pool 嵌套、进程残留与调试

5.1 fork 和 spawn 的启动方式差异

multiprocessing创建子进程的方式在不同操作系统上不同。Linux 上默认通常是fork,它会直接复制父进程的内存,子进程从创建那一刻的状态继续运行,启动速度快。Windows 和 macOS 上默认是spawn,它会重新启动一个干净的 Python 解释器,再导入主模块来执行目标函数,启动慢但隔离干净。

这个差异带来的后果很隐蔽。fork方式下子进程会继承父进程当时的内存状态,包括已经创建了一半的对象、未释放的锁、临时文件句柄。如果父进程里某个线程恰好持有一把锁,然后又创建了子进程,子进程即使永远等不到那个线程来释放锁,也会认为锁是“已被持有”,一旦执行到acquire就死锁了。spawn方式因为是完全新解释器,就不会继承这些脏状态,所以现在官方文档也更推荐用spawn或forkserver,牺牲一点启动时间换取确定性。

如果你观察到一个多进程程序在 Linux 上跑得好好的,换到 Windows 平台就频繁报错,甚至创建进程时无限循环,那多半是启动方式变化引起的。我的建议是尽量别把代码写成依赖某个平台的启动方式,至少保证if __name__ == "__main__"存在,并且把创建进程池的代码放在主模块入口中,不要放在被导入的模块顶层。

5.2 用 Pool 时最容易碰到的死锁:Pool 嵌套

Pool嵌套是我见过最多的一个隐藏死锁原因。表面上看,在 worker 函数内部再开一个Pool或者再调用同一个Pool的方法,似乎是合理的,其实很容易让整个进程池卡死。

举一个典型的错误写法:

from multiprocessing import Pool def outer_task(x): with Pool(4) as inner_pool: return inner_pool.map(lambda y: y + 1, [x] * 100)

如果外层Pool也设置了 4 个进程,四个 worker 都在执行outer_task,它们又各自创建内层Pool,等于一下子要 16 个进程;数量一多,资源马上耗尽。更隐蔽的是:如果 worker 函数内部直接调用了同一个Pool实例的方法,比如pool.apply_async(...),而当前所有 worker 都在执行任务、没有空闲进程去处理内部调用,这个内部调用会永远在等待池中有空闲进程,形成互相等待的死循环。

解决办法通常是把任务层级压平,让所有任务都在一层进程池中处理。如果必须分层次,我会用显式的Process加Queue自己设计调度,而不是把Pool当通用调度器随意嵌套。写并行代码时永远记住:Pool是给“一次性提交给固定进程组”的任务设计的,不是给递归式动态调度设计的。

5.3 进程残留与优雅退出

写过一段时间多进程的人多半见过“脚本已经结束,任务管理器里还有一堆 Python 进程”的现象。这种情况多半是子进程没有被正确回收。

Process对象创建后,调用start()启动子进程,调用join()等待它结束。如果子进程是循环结构或者监听队列,主进程直接退出时,子进程并不会自动跟着退出,它们会成为孤儿进程继续运行。

几个常用的清理手段:

  • 给子进程设置daemon=True,主进程退出时它会自动终止。但这个属性必须在start()之前设置,且daemon进程内部不能再创建子进程。
  • 子进程退出前要显式调用terminate()或者放开队列哨兵,让循环有机会结束。
  • 使用multiprocessing.active_children()查看当前还活着的子进程,用于调试。
  • 对Pool来说,正确的关闭顺序是调用close()停止接收新任务,然后调用join()等待现有任务结束;想要立即终止且不关心未完成任务,才用terminate()。

with Pool上下文管理器其实比很多人想象得更“暴力”,它离开时会直接terminate(),而不是等待任务结束。如果你在with块里用了异步提交但忘了等待,子进程会被硬生生杀掉。这也是一个常见误用点。

5.4 调试多进程程序的实用手段

多进程程序一旦出错,报错往往分散在各子进程里,串行程序那一套print调试法就不太好用了。我这些年形成了一套自己的调试流程。

先把进程数降为 1,确认业务逻辑正确。Pool(1)和多进程环境一致,但行为退化成单进程,跑一遍能排除大部分逻辑问题。然后加日志,不要只用print,最好带上进程 PID 和时间戳,格式类似:

import os import logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) def worker(item): logger.info("pid=%s 处理 %s", os.getpid(), item)

再就是给异步结果加超时。AsyncResult.get(timeout=...)可以在任务卡住时抛异常退出,避免整个程序无限阻塞。最后,尽量把 worker 写成纯函数,不要依赖任何全局状态。多进程里每个进程都有自己的一份全局变量,fork方式下初始值一样,但后续修改互不可见,如果业务代码里悄悄依赖了“某个全局变量应该被其他进程更新过”这种假设,就会得到非常难排查的错乱数据。

6. 实测场景:一次 CPU 密集任务的并行化记录

6.1 用一个什么任务做示例

为了把前面的知识点串起来,我打算用一个简单但 CPU 密集的示例:统计一个区间内质数的数量。这个任务的特点是纯计算、任务块之间无依赖、每个块的计算耗时稳定,非常适合观察多进程的加速效果。

为了模拟真实项目的压力,我把统计范围设得足够大:计算[2, 50_000_000)区间内的质数总量,使用试除法判断每一个数。算法不讲究最优,重点是让它能稳定占用 CPU,让并行效果一目了然。

6.2 单进程基线版本

先写单进程版本作为基线:

import math def is_prime(n: int) -> bool: if n < 2: return False if n % 2 == 0: return n == 2 i = 3 while i * i <= n: if n % i == 0: return False i += 2 return True def count_primes(limit: int) -> int: cnt = 0 for n in range(2, limit): if is_prime(n): cnt += 1 return cnt

在我的测试机器上,这版本跑完大约耗时 42 秒。测试环境是一台 8 核 16 线程的机器,内存 32GB,系统为 Linux,Python 版本 3.12。单进程阶段,CPU 占用基本维持在 12% 左右,其余核心完全空闲,这就是典型的 GIL 限制表现,因为这里根本没有开线程或进程,一个进程只能用一个核。

6.3 多进程改造与参数调整

多进程版本的核心是任务切分。把整个区间切成若干个独立的小块,每个子进程负责一块,最后把统计结果加起来:

from multiprocessing import Pool def count_primes_in_range(bounds) -> int: lo, hi = bounds cnt = 0 for n in range(lo, hi): if is_prime(n): cnt += 1 return cnt def make_chunks(total: int, chunk_size: int): start = 2 while start < total: yield (start, min(start + chunk_size, total)) start += chunk_size if __name__ == "__main__": total = 50_000_000 chunk_size = 1_000_000 chunks = list(make_chunks(total, chunk_size)) with Pool(8) as pool: results = pool.map(count_primes_in_range, chunks) print(sum(results))

这里有两个调参点。第一个是chunk_size,它决定每个任务块的规模。我最终选了 100 万,也就是切成约 50 块,远远大于进程数,保证每个 worker 结束时还能有后续任务,避免负载不均。第二个是进程数,我准备分别用 1、2、4、8、16 个进程跑一遍,记录耗时和加速比。

6.4 实测数据与结果解读

实测结果如下,耗时受机器负载影响会有波动,但相对关系是稳定的:

进程数耗时(秒)相对单进程加速比
142.81.0x
222.31.92x
411.93.60x
86.46.69x
166.17.02x

2 个进程时几乎完美线性,4 个进程也接近线性。到 8 个进程时加速比约 6.7 倍,已经出现了一部分损耗,主要来自进程调度、结果聚合和任务切分的开销。到 16 个进程时几乎不再增长,因为机器物理核只有 8 个,多的 8 个逻辑核来自超线程,纯计算任务里收益极其有限。

这个结果直接说明了一件事:进程数不是越大越好。对这台机器,8 进程是性价比最高的选择;16 进程看起来和 8 进程差距很小,但内存占用更高,进程切换更频繁,如果任务里还涉及大量临时对象,反而可能更慢。

6.5 实测之后的几条心得体会

这次实测之后,我给自己定了几条容易执行的原则。

任务切块的粒度要把单块耗时控制在 0.2 秒到 1 秒之间。块太小,调度和通信的开销占比会上升;块太大,某些 worker 提前结束时其他 worker 还在跑,最后一段只能眼睁睁等待。我这次把一千万的区间切成几十块,就是基于这个经验。如果你无法预估单块耗时,就用二分法试跑:先切成 10 块看耗时,再切 50 块看差异,热血调到一个拐点为止。

函数内部如果会创建大量临时对象,记得给Pool设置maxtasksperchild参数。它每执行完 N 个任务会重建一个新的 worker 进程,等于定期清理子进程内存,防止长时间跑下来内存碎片化越来越严重。这个参数平时用不上,但一旦任务量到几十万级,效果立竿见影。

最后一件事:多进程代码写完一定要做终止测试,强制确认所有子进程在脚本退出后都消失。跑完上面的示例后,我会执行一句检查当前进程树的命令,看到只剩主进程才算结束。很多线上问题出在“任务完成了,但下一轮启动时旧进程还在吃内存”,这种问题最难查,因为它不影响单次运行,只影响长期稳定性。

这一版例子我先整理到这里。后续如果multiprocessing在新版本中出现会影响使用习惯的变化,或者我又遇到新的有意思的坑,我会再回来补充。里面绝大多数代码是我实际跑过之后简化出来的版本,如果你照着写仍然遇到奇怪的问题,建议优先检查第五章,绝大多数病根都藏在那里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询