每天都会有人把multiprocessing.Pool、multiprocessing.pool.Pool、multiprocessing.pool.ThreadPool三个名字排在一起,然后问同一个问题:apply和apply_async到底哪个快,为什么我的代码用进程池异步之后还是慢?说实话,这个问题我第一次跑测试的时候也被结果迷惑过,后来把源码翻了一遍才真正理清。这篇文章从源码到实测,把三种池的本质、同步与异步的时间机制、以及真实场景下的选型思路一次讲透。适合刚接触 Python 多进程想跑通第一个并行任务的初学者,也适合已经在用 Pool 但被性能数据困惑的开发者。
1. 三种池的本质:同名不同命,别被表面骗了
1.1 Pool 与 pool.Pool:同一个东西的两种写法
很多教程里,一会儿写multiprocessing.Pool,一会儿写multiprocessing.pool.Pool,又写pool.ThreadPool,乍一看真像三种不同的池。我先说结论:multiprocessing.Pool就是multiprocessing.pool.Pool,这两个名字指向同一个类。
打开标准库的multiprocessing/__init__.py,能看到 import 语句把池类提升到了顶层命名空间:
from .pool import Pool, ThreadPool所以mp.Pool和mp.pool.Pool的is判断结果是True。验证方式特别简单:
import multiprocessing as mp print(mp.Pool is mp.pool.Pool) # True print(mp.ThreadPool is mp.pool.ThreadPool) # True为什么会有两套写法?这是标准库的封装习惯:pool 子模块负责池的实现,multiprocessing 包对外暴露常用名字。用哪个都不影响行为,但从源码追踪的角度来说,真正定义进程池和线程池的地方都在multiprocessing.pool里。
所以标题列出的三个名字,本质是两个类:Pool(进程池)和 ThreadPool(线程池)。搞清楚这一点,后面做时间对比时就不会被名字带偏。
1.2 ThreadPool:披着Pool外衣的线程调度器
ThreadPool 放的位置在multiprocessing.pool,类名和 API 几乎完全复制了 Pool。它也有 apply、apply_async、map、imap、close、join,甚至原样保留了进程池的Process工厂方法,只是把工厂的产品从进程换成了线程。
CPython 源码里,ThreadPool 的实现其实很短,核心就是把创建 worker 的方式换掉:
class ThreadPool(Pool): @staticmethod def _help_stuff_finish_to_terminate(): ... def Process(self, *args, **kwds): from threading import Thread return Thread(*args, **kwds)Pool 自己的 Process 方法返回的是multiprocessing.Process,ThreadPool 覆盖后返回threading.Thread。剩下的任务队列、worker 循环、结果回传机制,基本沿用同一套逻辑。
这也是 ThreadPool 常被忽略的一个点:它没有 GIL 豁免权,大部分时间它就是一套"线程版 Pool"。单看 API 会以为它能做到进程池的并行效果,实际在 CPU 密集任务下完全是另一回事。但不引入新进程也带来了两个实打实的好处:不用过 pickle 序列化关,数据直接在内存里共享;也不需要关心 fork/spawn 的启动方式,不会有新建解释器的启动延迟。
1.3 它们各自解决什么问题
进程池的定位是"并行",它是真正意义上同时跑多个 Python 解释器,能利用多核 CPU 处理重型计算。线程池的定位偏"并发",它解决的是大量 IO 等待场景下把时间"挤出来"的问题,比如接口批量请求、文件批量读写、数据库批量查询。
Python 的线程受全局解释器锁(GIL)约束,同一时间只有一条线程执行 Python 字节码,所以纯计算任务用线程池难以获得加速,甚至因切换损耗变慢。但遇到time.sleep()、socket.recv()、file.read()这类会让出解释器的等待操作,GIL 会释放,其他线程就能趁虚而入,于是线程池的并发价值就体现出来了。
| 池类型 | 底层 worker | 数据传递 | 典型场景 | 创建开销 |
|---|---|---|---|---|
| Pool / pool.Pool | 独立进程 | 管道 + pickle | CPU 密集计算 | 较大,spawn 更明显 |
| pool.ThreadPool | 线程 | 共享内存 | IO 密集等待 | 较小 |
| 普通 for 循环 | 无 | 无 | 顺序任务、微小任务 | 零 |
看到这张表,就能明白为什么同一个 apply_async 在不同池里的时间差异会那么大。后面实测部分会直接验证这一点。
2. apply 和 apply_async:同步与异步的分水岭
2.1 apply:排队等结果的同步模式
apply 的行为很直白:提交一个函数任务,阻塞在调用处,直到这个任务算完、结果回来,才继续执行下一行代码。这种模式下,如果写一个 for 循环连续调用 apply,任务就一个接一个排队执行。池里有 4 个 worker 也好、8 个 worker 也好,都只能干瞪眼。
from multiprocessing import Pool import time def work(x): time.sleep(0.5) return x * 2 if __name__ == "__main__": pool = Pool(4) start = time.perf_counter() results = [] for i in range(10): results.append(pool.apply(work, args=(i,))) print("结果:", results) print("耗时: %.3f 秒" % (time.perf_counter() - start)) pool.close() pool.join()跑这段代码,耗时大概率是接近 5 秒,也就是 10 个任务逐一执行、每个 0.5 秒的结果。任务之间没有重叠。apply 的优点只有一个:逻辑简单,拿到返回值或者异常都是最直观的方式。适合任务总数很少、或者任务之间有严格先后依赖的场景。
2.2 apply_async:提交即返回的异步模式
apply_async 的行为是"提交完就回"。它把任务丢进队列,立刻返回一个AsyncResult对象,不等待任务实际执行。这样可以在一次循环里把所有任务全部提交,然后统一拿结果,worker 之间并行执行,总耗时大幅缩短。
from multiprocessing import Pool import time def work(x): time.sleep(0.5) return x * 2 if __name__ == "__main__": pool = Pool(4) start = time.perf_counter() async_results = [pool.apply_async(work, args=(i,)) for i in range(10)] results = [r.get() for r in async_results] print("结果:", results) print("耗时: %.3f 秒" % (time.perf_counter() - start)) pool.close() pool.join()这次 10 个任务被 4 个 worker 分走,一个 worker 大约处理两到三个任务,总耗时从 5 秒量级降到 1.5 秒左右。这就是"提交并行任务"和"逐条等待任务"的本质区别。
需要补充的是,AsyncResult.get()是一个阻塞等待,它等待的是"对应那个任务"而不是所有任务。所以即使某个任务先完成,只要你不先 get 它,其他 get 调用也会照常阻塞。实际业务中如果结果到齐时间不一致,可以用timeout参数防止永久阻塞。
2.3 从源码看两个方法的时间开销差异
很多人以为 apply 和 apply_async 是两套完全不同的底层实现,一个慢一个快。看源码就会发现,apply 压根就是 apply_async 的薄封装:
def apply(self, func, args=(), kwds={}): assert self._state == RUN return self.apply_async(func, args, kwds).get()在multiprocessing/pool.py里,apply 的实现就是这三行。它调用了 apply_async,然后把结果同步 get 回来。换句话说,两个方法本身的框架开销差距非常小,真正拉开时间差距的是调用方式:
- apply:提交一个 → 等一个 → 提交下一个,管道被阻塞占用;
- apply_async:提交全部 → 最后统一 get,管道里同时流转多个任务。
所以更准确的说法不是"apply 慢、apply_async 快",而是"阻塞式提交限制了并行度"。如果你只提交一个任务,或者任务之间有依赖不能并行,apply 和 apply_async 的耗时几乎一样。认识到这一点,你就不会为了用异步而用异步,也不会稀里糊涂地拿 apply 提交一大堆本可并行的任务了。
3. 时间对比实测:同任务、同规模、不同池
3.1 测试环境与方法设计
我这边的验证环境是 8 核 CPU、Linux 系统、Python 3.10,进程池 worker 数固定为 4,任务数固定为 16。每个组合都跑 5 轮,取中间值,目的是过滤掉系统调度带来的随机抖动。计时统一用time.perf_counter(),这是 Python 里精度最高的单调时钟。
注意一个容易忽略的细节:打印语句本身很耗时,测试时不要在每个 worker 里 print,那只会在串行/并行两种模式下引入不同的控制台开销,把数据搞脏。我习惯把任务装饰成一个"只计算什么都不打印"的纯函数。
import time from multiprocessing import Pool from multiprocessing.pool import ThreadPool N_TASKS = 16 WORKERS = 4 def cpu_heavy(n): total = 0 for i in range(n): total += (i * i + 3 * i) % 9973 return total def io_heavy(x): time.sleep(0.5) return x def run_case(pool_factory, func, use_async): pool = pool_factory(WORKERS) start = time.perf_counter() if use_async: asyncs = [pool.apply_async(func, args=(i,)) for i in range(N_TASKS)] result = [r.get() for r in asyncs] else: result = [pool.apply(func, args=(i,)) for i in range(N_TASKS)] elapsed = time.perf_counter() - start pool.close() pool.join() return elapsed大整数任务的单次耗时控制在这个函数上大约 0.2 秒左右,太小会被框架开销淹没,太大又会让每一轮测试等太久。下面的数据都是我取了若干轮中间的典型值,不同机器会有浮动,但趋势很有参考价值。
3.2 CPU 密集任务下的时间对比
CPU 密集组合的结果比较直观:
| 组合 | 典型耗时(秒) | 观察 |
|---|---|---|
| Pool + apply | 3.65 | 16 个任务基本排队 |
| Pool + apply_async | 0.92 | 4 worker 并行,接近理论下限 |
| ThreadPool + apply | 3.58 | 线程也串行等待 |
| ThreadPool + apply_async | 3.71 | 线程异步但没有真实并行,反而略慢 |
进程池 + apply_async 的收益非常明显,从 3.65 秒降到 0.92 秒,接近理论上的 4 倍加速。线程池 + apply_async 就很有意思了:耗时没有降下去,甚至比同配置的 apply 还多一点。这不是测试方法的问题,而是纯 Python 计算被 GIL 卡住的典型表现。任务投递进去后,4 个线程在同一把 GIL 上抢执行权,切换反而产生了额外成本。
这个结果说明一个硬道理:CPU 密集的并行,不要指望 ThreadPool。它只是把任务并发地分发到线程,但计算并没有并发。
3.3 IO 密集任务下的时间对比
换成io_heavy后,结果完全不同:
| 组合 | 典型耗时(秒) | 观察 |
|---|---|---|
| Pool + apply | 8.10 | 串行:16 × 0.5 = 8 |
| Pool + apply_async | 2.15 | 4 进程并行,sleep 期间不占 CPU |
| ThreadPool + apply | 8.06 | 串行阻塞 |
| ThreadPool + apply_async | 2.18 | 4 线程并行,sleep 释放 GIL |
线程池经常被低估,但 IO 密集场景下它的表现和进程池基本打平。sleep 这类操作会让出解释器,线程池的 4 条线程可以同时处于"等待中",等唤醒后再分别处理,于是耗时从 8 秒降到 2 秒出头。
如果把发起任务和收集结果的机制也算上,ThreadPool 在这个场景的启动成本其实比 Pool 更低,因为没有进程创建、没有 pickle 序列化。因此大批量请求外部接口、做文件批量处理的业务,ThreadPool + apply_async 常常是最舒服的选择。
3.4 任务粒度大小对时间的影响
跑完上面两组,我本来以为结论已经稳了,结果换了一批"微任务"之后发现数据被颠覆了。把单任务耗时压到毫秒级,比如一个几毫秒的简单计算,Pool + apply_async 反而不如普通 for 循环。
| 单任务耗时 | Pool + apply_async | 普通 for 串行 |
|---|---|---|
| 约 300ms | 约 0.9 秒 | 约 4.8 秒 |
| 约 2ms | 约 0.2 秒 | 约 0.03 秒 |
| 约 0.5ms | 约 0.15 秒 | 约 0.008 秒 |
原因不复杂:进程池每次任务分发都有队列写入、管道传输、pickle 序列化、结果回传的固定成本,这条链路在小任务场景下比任务本身还贵。任务越碎,分发开销占比越高,池化反而变成负优化。
如果确实有大量小任务,可以改用带chunksize的imap或imap_unordered,让一个 worker 一次搬一麻袋任务,而不是一个任务一趟。我在批量处理短文本时就用 chunksize 把耗时拉回正常水平。这也说明:做时间比较之前,先想清楚任务粒度,否则结论很容易误导。
4. 时间差异的根源:进程、线程与调度的真相
4.1 进程池的创建与回收开销
Pool 创建之初就要启动若干 worker 进程。Linux 默认用 fork,速度很快,几十毫秒级别;Windows 和 macOS 上默认用 spawn,子进程会重新导入主模块,启动一个全新解释器,时间明显更慢,有时一个 Pool(4) 就要一两百毫秒。这不是 bug,是创建方式的差异。
进程间任务传递走的是管道,传数据要 pickle 序列化。函数的参数、返回值只要不是基本类型,都需要能被 pickle。如果传一个巨大的 DataFrame 或嵌套很深的字典,序列化时间会飙高,甚至一个任务的时间主要耗在序列化上。我在处理图像文件时就遇到过大列表返回值把总耗时翻倍的事,后来改成返回缩略的统计结果才正常。
4.2 线程池的 GIL 限制与上下文切换
线程池的问题集中在全局解释器锁。CPython 解释器同一时间只允许一个线程执行 Python 字节码,所以多个线程的纯计算无法真正同时推进。GIL 切换每几毫秒就发生一次,线程越多,切得越勤,对 CPU 密集任务反而像在开倒车。
IO 等待是另一回事。time.sleep、read、write、recv都会把线程挂起并释放 GIL,让其他线程有机会运行。用一个词概括就是"让位"。线程池在 IO 密集场景的本质是让多条线程轮流占用 CPU,而真实执行时间大多花在等待 IO 上,于是并发效果接近你期望的并行。
4.3 为什么 apply_async 在多数场景下更快
进程创建这一点上,apply 和 apply_async 的池子本身没有区别。快的根本原因在于任务在队列里的并发度。apply_async 一次性把一批任务全部投递进去,多个 worker 同时抢任务执行,结果回传也走异步管道;apply 却像在银行柜台办业务,办完一个才能叫下一个号,硬生生把并行管道堵成了串行。
但凡事有前提。任务之间不独立、需要共享可变状态的时候,异步提交只会引入一堆锁和顺序混乱,更慢;单一任务场景下两个方法几乎没差别;任务量特别大但每个特别小的时候,进程甚至赔上分发开销。所以"apply_async 更快"应该理解成一个有条件结论:任务相互独立、单任务有可感知耗时、机器有多余核心时,它才是更快的选择。
5. 实战选型:什么时候该用哪个组合
5.1 组合矩阵与适用场景
把前面的结论浓缩成一张决策表,平时照着选基本不会错:
| 业务场景 | 池类型 | 调用方式 | 理由 |
|---|---|---|---|
| 批量图片/视频转码 | Pool | apply_async | 计算密集,需要多核 |
| 批量采集接口数据 | ThreadPool | apply_async | 等待网络响应,线程足够 |
| 批量读写小文件 | ThreadPool | apply_async | IO 型,无序列化成本 |
| 任务间有顺序依赖 | 不并行 | apply 或普通循环 | 保持逻辑简单 |
| 大量毫秒级小任务 | ThreadPool | apply_async + 合理分块 | 减少分发开销 |
| 快速验证思路 | Pool | apply | 代码最少,先对再优化 |
举两个例子。比如有几千张图片要做压缩,每张 100ms 到 1 秒,这是典型 CPU 密集,用Pool(CPU核数).apply_async提交全部任务,最后逐个 get,基本能压到接近单核时间的四分之一。再比如需要向几十个服务发送查询请求,每次等待 0.5 秒,返回后再拼装结果,用ThreadPool就够了,不需要进程,也不用担心 pickle。
5.2 常见误用与踩坑经验
第一个坑:apply_async 提交完不 get。任务执行正常,但返回值丢失、异常也被吞掉,等你发现结果不对时根本不知道是哪一步出问题。我习惯每个 AsyncResult 都要 get,并加上 try/except,必要时传 timeout。
第二个坑:循环里用 apply 提交一堆独立任务,以为池会自动排队并行。前面源码已经解释过,apply 等于 apply_async 加立即 get,提交第二个任务之前一定在等待第一个。任务多了时间就是累加。
第三个坑:在主模块顶层直接建池。在 spawn 平台上会出现"递归创建进程"的报错。把池的创建和任务提交全部放进if __name__ == "__main__"或独立函数里是最稳妥的。
第四个坑:给线程池喂 CPU 密集任务,慢到怀疑人生。这个不是线程池的问题,是 GIL 决定了它不适合纯计算并行。
第五个坑:把大对象塞进进程池。一次pool.apply_async(work, args=(huge_data,)),光序列化就够呛。能拆成小片段就拆,不能拆就换 ThreadPool,或者改走共享内存方案。
5.3 实测下来我的最终建议
总结一下我实际用下来的决策思路。任务总数小于 10、单任务耗时在 0.1 秒以上,直接 Pool + apply_async,简单、可靠、收益大;任务数量多但单任务极小,一定先做一次串行基准测试,对比之后再决定要不要上池;IO 等待占大头,ThreadPool + apply_async 的性价比最高,代码也最轻。
还有一个细节:做性能对比时别只测一轮。我在多轮测试中发现系统负载、页面缓存、后台进程都会干扰数据,尤其带网络 IO 的任务,第一轮可能慢得反常。多跑几轮取中位数或者最小值,数据才有说服力。
最后说点我的体会。很多人花精力纠结 apply 和 apply_async 谁更快,其实这个问题的答案在源码里就已经定了一半——真正决定时间的是任务怎么设计、数据怎么传、选进程还是线程。先把任务拆清楚,再选择工具,比一味上多进程技术要重要得多。哪怕只是一个巡检脚本,把这些点想明白,也能跑出接近最优的时间。