☰
Python多进程实战:进程池、队列与生产者消费者模式
2026/10/14 7:13:11 网站建设 项目流程

Python 的多进程,是我被问得最多的一块内容。尤其是把“进程、通信、进程池、生产者消费者”这几个词拼在一起的时候,多半是已经遇到了并发处理的瓶颈:数据量大、CPU 密集、单个进程跑不完,想用上多核又发现线程被 GIL 卡得死死的。这篇文章把我这几年在处理数据解析、批量计算任务里反复用到的这套组合打法整理出来,适合已经知道 threading 不够用、但还没把 multiprocessing 玩熟的人。

我先把结论放前面:这组概念真正要解决的问题,不是“怎么并发”,而是“任务怎么流动”。生产者负责产生数据,消费者负责处理数据,中间的队列负责缓冲,进程池负责控制消费者的数量和生命周期。只要这张图在脑子里清楚了,后面就是调参的事。

1. 为什么是进程:GIL、隔离和并行

1.1 GIL 到底卡在哪

很多新手一开始会困惑:同样是并发,我为什么不用 ThreadPoolExecutor,非要用进程?这里面最核心的一个词就是 GIL。

CPython 的解释器里有一个全局解释器锁,叫 GIL,它保证同一个进程里同一时刻只有一个线程在执行 Python 字节码。这不是 bug,是 CPython 为了内存管理安全选择的一种实现方式。问题是,它对 CPU 密集型任务非常不友好:你开 8 个线程去跑纯计算,Python 字节码并不能真正并行,反而会因为线程切换和锁竞争拖慢速度。

但在多进程模式下,每个进程都有自己独立的解释器实例,也各自持有一把 GIL。4 个进程就是 4 个独立的解释器在跑,它们可以真正被调度到多个 CPU 核上并行执行。这就是在 CPU 密集场景下,进程终归是“绕开 GIL”的最直接手段。

不过代价也很明确:进程之间内存隔离。你在主进程里改一个全局变量,子进程看不到;反过来,子进程里算出来的结果,主进程也不能直接拿。这种隔离带来安全性的同时,也逼着你必须用一套显式的通信机制把数据搬运过去。

1.2 进程不是银弹:开销和通信成本

进程比线程重得多。创建进程要启动解释器、导入模块、初始化运行时,这些步骤都有成本。如果一个任务只需要 0.1 毫秒的计算时间,你却为它创建一个进程,那总耗时大概率比单线程还慢。多进程适合的是那种“单个任务有一定计算量”的工作,比如日志解析、图片处理、数值计算,而不是打印一句话。

进程池就是为了复用进程资源而出现的。你把几个进程放到池子里,任务来了就从池子里拿一个空闲 worker 去执行,执行完 worker 不退出,继续接下一个任务。这比“每来一个任务就 fork/spawn 一个进程”高效得多,也把 worker 数量控制在合理范围内,不会一不留神把机器资源打满。

所以现在再回头看“进程、通信、进程池、生产者与消费者”这四个词,它们其实是一条链:进程是执行单位,通信是搬运数据的管道,进程池是执行资源的管理方式,生产者消费者是组织任务流动的架构模式。后面所有代码,都是围绕这条链展开的。

2. 进程间通信:别再用全局变量了

2.1 Queue:生产消费模型里的默认答案

进程间通信的选择很多,但在生产者消费者场景下,我的默认答案永远是multiprocessing.Queue。

Queue 的内部实现可以理解为 pipe 加上锁,再加上一个 feeder 线程。它对外提供了 put 和 get 两个方法,能保证多个进程同时写入或读取时不会乱套。更重要的是,它天然支持一对多、多对多的关系:多个生产者往同一个队列里放任务,多个消费者从队列里抢任务,这种模式不需要你写额外协调逻辑。

Queue 还有一个很关键的能力是maxsize参数。它指定了队列中最多能放多少条数据,当队列满时,put 会阻塞,直到有消费者取走数据。这个“阻塞”就是背压机制,后面我会反复提到。

看一个最基础的双进程例子:

from multiprocessing import Process, Queue def consumer(q): while True: item = q.get() if item is None: # 收到哨兵,退出 break print("consume", item) if __name__ == "__main__": q = Queue(maxsize=64) p = Process(target=consumer, args=(q,)) p.start() for i in range(10): q.put(i) q.put(None) # 通知消费者结束 p.join()

这里有个我反复强调的细节:消费者是一个while True循环,它会一直阻塞在q.get()上。如果你只是join()生产者,却忘了让消费者退出,主进程会永远等下去。通用的做法是在队列末尾放一个哨兵值,消费者拿到哨兵就 break。

注意:如果有多个消费者,就必须给每个消费者各放一个哨兵。只放一个 None,只会有一个消费者退出,其他消费者会继续阻塞。

2.2 Pipe、Manager 和共享内存:各自什么时候用

Queue 很通用,但并不是唯一选择。遇到不同场景,需要灵活切换。

Pipe 适合两个进程之间的点对点通信。它有两个连接端,一端 send,另一端 recv,也可以双向。它比 Queue 轻量,但设计和 Queue 不太一样,多线程、多消费者同时读写同一根 Pipe 时需要自己加锁,很容易踩坑。所以我一般只把 Pipe 用在主进程和一个 worker 之间的简单请求/响应场景,生产消费模型里很少用。

Manager 则是另一类思路。它会在后台启动一个 manager 进程,真正共享的数据对象放在这个进程里,其他进程通过代理去访问。好处是你可以直接共享 dict、list、Namespace,用起来像操作普通对象一样。坏处是每次访问都是一次跨进程调用,性能开销比 Queue 大得多,不适合高频读写。

共享内存则是最底层、最快的方式,适合数值数组、计数器这类数据。可以通过multiprocessing.Value和Array使用,也可以直接用shared_memory模块共享大块内存。代价是要自己配合 Lock 来保证数据不会被多个进程同时改坏。

下面这张表基本能决定你用什么通信方式:

通信方式典型场景性能使用复杂度
Queue多生产者、多消费者的任务队列中等低
Pipe两个进程之间的点对点通信较高中
Manager共享 dict/list/Namespace低低
Value/Array/共享内存数值、数组、计数器等高频数据高高

2.3 理解 Pickle:通信链路里的看不见的成本

不管用哪种方式,multiprocessing在跨进程传递数据时,默认都会经过 Pickle 序列化和反序列化。也就是说,你在主进程里 put 一个 Python 对象,它不是直接被另一个进程拿过去,而是先被打成字节流,再在对方进程里重新还原出来。

这带出来两个实际影响。第一个是性能:如果每个任务本身很小,序列化占的时间可能比计算时间还长,那并行就不划算。第二个是限制:不是所有对象都能被 Pickle,比如 lambda、嵌套函数、局部类等在绝大多数情况下都不能直接传给子进程。这也是为什么我强烈建议所有传给多进程的函数都写成模块级函数,别在循环里临时定义。

3. 进程池:把创建进程的代价控制住

3.1 进程池在替你做哪些事

如果没有进程池,你要自己维护一批常驻消费者进程,还要考虑 worker 数量、空闲分配、异常重启。进程池把这一整套生命周期管理封装掉了。你只需要说:我要 4 个 worker,然后往池子里扔任务,池子会告诉你任务完成了,结果在哪。

从实现上看,进程池内部大致有一个任务入口队列、一组 worker 进程,以及一个收集结果的通道。当你调用apply_async或submit时,任务和参数会被打包发给某个空闲 worker,worker 执行完后把结果送回来。

理解这一点很重要,因为很多坑都是从这里冒出来的。进程池只是在“并发执行”这层帮了你,它并不替你做“流量控制”。如果生产任务的速度远超 worker 的处理速度,任务会在池子的队列里越积越多,内存照样爆给你看。所以生产者和消费者模型里的“背压”,到最后还是要自己管。

3.2 ProcessPoolExecutor 与 multiprocessing.Pool 怎么选

Python 里有两套常用池子:concurrent.futures.ProcessPoolExecutor和multiprocessing.Pool。我刚学的时候很纠结,后来发现它们的定位有点不一样。

ProcessPoolExecutor是 Python 3.2 之后引入的高层 API,风格现代,和 ThreadPoolExecutor 用法一致,还支持 with 上下文管理器。对于“并发执行一批任务,拿结果集”这种场景,它最顺手。

multiprocessing.Pool更老,但功能更细,特别是imap、imap_unordered、starmap、maxtasksperchild这几个方法非常实用。我现在的选型标准大概是:

需求推荐方案
简单并发批处理,只需要最终结果ProcessPoolExecutor.map
大文件逐行处理,希望惰性取结果Pool.imap / imap_unordered
参数是多个值需要解包Pool.starmap
不在乎结果顺序,只要快Pool.imap_unordered

举个例子,用ProcessPoolExecutor跑一个 CPU 密集的简单任务:

from concurrent.futures import ProcessPoolExecutor def cpu_job(n): return sum(i * i for i in range(n)) if __name__ == "__main__": with ProcessPoolExecutor(max_workers=4) as pool: for result in pool.map(cpu_job, range(20)): print(result)

这里用 with 是必须养成的习惯,它会在代码块结束时自动调用 shutdown,等待所有 worker 结束,避免留下僵尸进程。

4. 生产者与消费者实战:从队列到进程池

4.1 经典双进程版本:Queue 加哨兵

先写一个最朴素、但能完整表达模型核心的版本:一个生产者进程,多个消费者进程,任务通过 Queue 传递。

import time from multiprocessing import Process, Queue def produce(q: Queue, total: int, consumer_num: int): for i in range(total): q.put({"idx": i, "delay": i % 3}) # 关键:给每个消费者各放一个哨兵 for _ in range(consumer_num): q.put(None) def consume(q: Queue, name: int): while True: item = q.get() if item is None: break time.sleep(item["delay"]) print(f"[consumer-{name}] done {item['idx']}") if __name__ == "__main__": q = Queue(maxsize=16) consumer_num = 3 producer = Process(target=produce, args=(q, 50, consumer_num)) consumers = [ Process(target=consume, args=(q, i)) for i in range(consumer_num) ] producer.start() for c in consumers: c.start() producer.join() for c in consumers: c.join()

这个版本最值得关注的是Queue(maxsize=16)这一行。当队列放满 16 条数据时,生产者会被阻塞在q.put(),直到消费者取走数据。这个机制天然形成了“消费者处理多快,生产者就生产多快”的节奏,不会让任务无限堆积。

多个消费者同时从一个队列里取任务时,谁抢到就是谁的,结果顺序和提交顺序没有关系。如果对顺序有要求,要么在任务里带编号,要么用接下来要说的imap。

4.2 进程池版本:用 imap 和信号量控制流量

经典版好懂,但每次都要手动管理生产者进程和消费者进程,代码也不算少。绝大多数生产场景,我更倾向于直接使用进程池,让池子扮演消费者那一端。

最简单的一种写法是用multiprocessing.Pool.imap_unordered处理大批量数据。比如一个很大的日志文件,按行处理:

from multiprocessing import Pool def process_line(line): # 这里必须是模块级函数,不能是 lambda cleaned = line.strip() return len(cleaned), cleaned[:20] if __name__ == "__main__": total_length = 0 with Pool(processes=4) as pool: with open("big.log", "r", encoding="utf-8") as f: for line_len, head in pool.imap_unordered( process_line, f, chunksize=256 ): total_length += line_len print(total_length)

文件对象是一个生成器,imap_unordered会一边迭代文件行,一边把任务分发给池子里的 worker。它不会一次性把整个文件的所有行都读进内存,而是按需取出、分批发送。chunksize=256表示每 256 行打包成一组发给 worker,这能显著减少跨进程通信的次数。

但如果任务源不是文件,而是像接口分页、数据库游标这种要持续获取的数据源,我建议给进程池加一个信号量做背压。直接往ProcessPoolExecutor里 submit 上万条任务,内存会很快涨上去,因为所有任务都在等待执行。

一个非常实用的实现是这样:

import threading from concurrent.futures import ProcessPoolExecutor class BoundedProcessPool: def __init__(self, max_workers=4, max_pending=64): self._executor = ProcessPoolExecutor(max_workers=max_workers) self._sem = threading.BoundedSemaphore(max_pending) def submit(self, fn, *args, **kwargs): self._sem.acquire() try: future = self._executor.submit(fn, *args, **kwargs) except Exception: self._sem.release() raise future.add_done_callback(lambda _: self._sem.release()) return future def shutdown(self): self._executor.shutdown(wait=True)

用的时候,生产者每准备一条任务就submit一次。如果池子里未完成的任务数量已经达到max_pending,acquire()就会阻塞,等某个任务完成并释放信号量后再放新的进去。这样既保留了进程池的优势,又解决了无界队列导致的内存问题。

bpool = BoundedProcessPool(max_workers=8, max_pending=32) for item in source(): # 不断产生任务 bpool.submit(process_line, item) bpool.shutdown()

4.3 关于 chunksize、顺序和任务粒度的取舍

用过一段时间后你会发现,进程池性能有没有发挥出来,很多时候不是取决于并发数,而是取决于任务粒度。

如果每条任务太小,比如只是把一个字符串转成小写,那跨进程的序列化和通信成本就会压过计算收益,开 4 个进程可能比单进程还慢。这时候应该把多条任务合成一批,一次交给 worker。这就是chunksize的效果。

imap和imap_unordered的区别也要注意。imap保持提交顺序,返回结果时严格按原顺序;imap_unordered哪个任务先完成就先返回哪个。如果后续处理不依赖顺序,用imap_unordered吞吐会更高,因为不会被最慢的一条任务堵住。

我的一个习惯是:任务处理耗时在 10 毫秒以上,才值得用多进程;耗时在 1 毫秒以下,优先考虑批量合并或者直接单线程重写。

5. 常见问题与排查实录

5.1 程序卡死:多半是哨兵和队列阻塞的问题

多程序多进程最常出现的现象就是程序卡住不动。最常见的两个原因分别是:消费者没有收到结束信号,或者q.get()在不该阻塞的地方一直阻塞。

排查时,我一般先在 get 上加一个timeout参数,比如q.get(timeout=1),让它至少能抛个异常,方便看出卡在哪。但激进地缩短 timeout 也会导致高负载时消费者误退出,所以这只是定位手段。

另一个容易忽略的是Queue.qsize()在很多平台上并不可靠,甚至可能报 NotImplementedError。不要用它来判断队列是否空了,重点应该放在哨兵和队列有界设计上。

5.2 数据量一大就内存暴涨:无界队列是元凶

进程池和 Queue 默认都不会限制任务堆积量。生产中一旦生产者瞬间产生几万条任务,而这些任务都带着大对象,内存就会成倍上涨。有些数据甚至只是在队列里排队,还没轮到 worker 处理,队列已经先把进程撑爆了。

我遇到过最典型的场景是批量抓取接口数据后直接往池子里 submit,结果任务对象一个个被 Pickle 排队,内存直接翻了几倍。解决办法也很直接:所有用 Queue 的地方都设置 maxsize;所有用 Executor 的地方都用带信号量的包装池;所有能用 imap_unordered 的地方别用 map 一次性把所有任务铺开。

注意:背压不是可选项,而是生产者和消费者模式里必须设计的一环。队列有界,就是把“任务积压”这个风险显式暴露出来,而不是让它在你看不见的地方悄悄吃掉内存。

5.3 跨平台启动方式和 Pickle 限制

Windows 和 macOS 上启动多进程的方式和 Linux 不太一样。Linux 默认用 fork 复制当前进程,Windows 和部分场景下会重新启动解释器并导入主模块。这意味着如果你把创建队列、启动多进程的代码直接裸写在模块顶层,在 Windows 上会因为递归导入而疯狂启动新进程。几乎所有多进程代码都要放进if __name__ == "__main__":里,这是保命写法。

Pickle 限制也是个高频报错点。当你看到 “can't pickle local object” 或类似错误时,第一反应应该是检查是不是把 lambda、局部函数、嵌套函数传给子进程了。改成模块级普通函数通常就能解决。

我现在的习惯是:不管任务多简单,先画一条生产者到消费者的数据流。生产者管数据来源和流量控制,消费者管 CPU 密集运算,中间用一个有界队列缓存数据。这样即使后面数据量翻倍,我也只需要改 maxsize 和 max_workers 两个参数,不用再去推翻架构。如果没有这种底层的“队列-worker-结果回收”的想象力,遇到复杂并发需求时很容易被各种 API 细节绕晕。先跑通经典 Queue 版本,再切换到进程池版本,你会明显发现自己对任务在进程间如何流动有了真正的手感。

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

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

立即咨询