从一次真实的需求出发,把 Python 里multiprocessing的Pool、pool.ThreadPool以及apply、apply_async这几种常见调用方式放在一起做时间对比,是我在优化一个数据批处理任务时踩出的实证经验。当时任务本身不复杂,但调度方式选错,导致 8 核机器跑出了单线程的即视感。这篇博文会把思路、机制、实测数据和踩坑过程完整拆开,适合正在做并发改造、或者准备在进程池与线程池之间做选型的开发者参考。文中不堆理论,所有观点都以可复现的实验数据为支撑。
1. 测试设计与方案背后的思路
1.1 为什么要做这组对比
Python 里能用的并发手段其实就那几套。做爬虫、做批量 IO、做 CPU 重计算,大家最先想到的都是multiprocessing。但很多人会忽略一个事实:multiprocessing模块底下挂着的执行器不止进程池,还有一个同源的线程池;提交任务的方式也分同步阻塞的apply和异步非阻塞的apply_async。这两两组合,加上进程和线程的内存模型差异,形成的时间特性完全不一样。
我最初遇到的场景非常简单:有一批约 8000 条的请求数据,每条都需要经过一段中等强度的校验计算,然后结果落盘。我第一版用的是Pool.apply_async逐条提交,没限制并发数,号称“全异步”。结果耗时比我预想的差很多。后来把调用方式从apply_async换成apply,甚至尝试了线程池,发现不同任务类型、不同提交方式下的耗时差异明显。这种“看起来都能用,实际量级差好几倍”的体验,是光读文档体会不到的。
所以这组测试的目标很明确:
- 对同一批任务,固定任务量和机器环境,分别使用
Pool.apply、Pool.apply_async、ThreadPool.apply、ThreadPool.apply_async跑完。 - 记录总耗时和资源占用特征的差异。
- 分析差异背后的原因:进程与线程的创建开销、GIL 的影响、同步等待与异步调度的调度差异。
1.2 比较前必须理解的两个并发基础概念
第一个概念是并行与并发的区别。进程池是真的“并行”:每个 worker 进程跑在独立的地址空间,多核 CPU 可以同时执行。线程池受 GIL 限制,实际上仍是“并发”:同一时刻只有一个线程能执行 Python 字节码。只有当线程把控制权释放出去,比如等待 IO、调用sleep、执行某些 C 扩展的阻塞操作时,另一个线程才有机会插入。
第二个概念是同步与异步调用对任务进度的感知。apply是阻塞式:主进程提交任务后就停在Future.get()的位置,等 worker 跑完才拿结果继续走。apply_async是非阻塞式:主进程提交任务、得到一个结果对象,随后立刻执行下一行代码;如果需要结果,再统一get()。这导致两者在代码写法和时间分布上差异很大。
还有一种常被忽略的差异:apply_async的任务提交过程本身有队列和调度的开销。任务太碎时,提交 10000 个微任务的调度成本甚至会超过任务执行本身。这个在后面实测里表现得特别明显。
1.3 为什么“查文档没用,必须实测”
multiprocessing的官方文档只说明apply是“阻塞直至结果就绪”,apply_async是“非阻塞,立即返回结果对象”。文档不会告诉你“在哪种任务粒度下应该选谁”。因为“阻塞”和“非阻塞”对时间的影响,取决于任务本身的耗时、数量、worker 数量、操作系统进程启动方式、Python 版本等一系列因素。
另一个文档盲区是线程池。multiprocessing.pool.ThreadPool在很多教程里几乎被忽略,但它跟concurrent.futures.ThreadPoolExecutor是同一个底层思路,区别只是 API 风格。对 IO 密集型任务,线程池往往比进程池更划算,因为没有进程 fork 和内存复制的开销。但没有实测,这些结论都只是“听说”。
我写这篇文章的直接想法:与其相信网上五花八门的说法,不如自己设计一组可控的对照实验,采取固定任务量、固定机器配置,逐一跑出数据,再针对数据做机制层面的解释。这样后续选型时,你脑中有的是一个可以直接落地的参考区间,而不是靠猜。
2. 四种调用方式的机制拆解与时间来源分析
2.1 Pool.apply 的同步阻塞本质:等待即时间
Pool.apply的底层逻辑很简单:主进程调用 worker 执行目标函数,同步阻塞地等待该次调用返回。因为主进程会一直挂在result.get()上,这段时间内主进程本身没有其他任务可做。好的一面是编程模型贴近普通函数调用,坏的一面是它无法形成“流水线”。
假设你有一个任务f(x),执行耗时 0.1 秒,使用 8 个 worker 的进程池:
- 第 1 秒内,池子里最多同时跑 8 个任务。
- 但只要某次调用返回,主进程必须等
apply结束才能提交下一次调用。 - 这意味着池子里真正同时运行的 worker 数量,取决于“主进程提交新任务”的速度是否追得上“worker 执行完任务”的速度。
apply的核心问题不是任务执行慢,而是任务之间出现“串行缝隙”:当主进程收到结果、再提交下一个任务的时间窗口里,池子里可能所有 worker 都已经空闲下来了。因此,用apply跑任务时,总时间约等于(任务数 / worker 数)× 单个任务耗时,并且不断累加节点交互的开销。任务数一多,这个差距就非常明显。
2.2 Pool.apply_async 的异步投递原理:批量流水线
apply_async会把任务投递到 pool 内部的任务队列,然后立刻返回一个AsyncResult对象。主进程无需等待单次任务完成,可以持续往队列里塞任务,直到队列被塞满或达到可接受的规模。worker 端会根据自己的空闲状态从队列里取任务执行。
时间模型发生了变化:
- 任务提交阶段的总开销是“单条任务入队”的开销乘以任务数量,这个开销是固定的。
- 任务真正执行时,worker 之间并行运行,平均等待时间大幅缩减。
- 主进程只需要在最后统一收集结果时等待最慢的那一批任务完成。
有一个隐性成本:如果一次性提交海量任务,任务对象本身要经过 pickle 序列化然后送入队列。大量小任务的提交开销可能占据整体时间的很大比重。这也是为什么同样用apply_async,不同任务粒度下的表现可以天差地别。
从我的角度理解,apply_async更像“流水线投递”:任务习以为常地排队,worker 各司其职,主进程只负责投递和收尾。
2.3 pool.ThreadPool 与 Pool 到底差在哪里
ThreadPool源码里继承的机制和Pool几乎一致,有兴趣的可以去看multiprocessing/pool.py。两者共用一个Pool类,只是 worker 的构造方式不同:Process换成threading.Thread。
这个差异的直接影响体现在三个方面:
- 内存共享:线程共享主进程的堆内存,不需要 pickle 复制大对象。提交任务时的序列化成本比进程池低很多,尤其是任务参数或结果是一个较大的数据结构时,差异会产生数十倍的性能落差。
- 创建开销:进程的创建(尤其 spawn 模式下)需要重建解释器、重新导入模块,一次可能要几十毫秒到上百毫秒;线程创建的开销只在微秒到毫秒级。
- GIL 限制:同一个时刻只能有一个线程执行 Python 字节码,所以纯 CPU 密集的计算任务在多线程下不仅不会加快,反而因为线程切换增加额外负担。但一旦任务涉及 IO(网络请求、磁盘读写、调 sleep),线程会主动释放 GIL,这时候多线程的并发效果就很可观。
2.4 为什么执行 CPU 任务时进程池是刚需,执行 IO 任务时线程池反而更香
CPU 密集型任务,比如大量数学运算、图像像素处理、加密解密,唯一能利用多核的方式是进程。因为每个进程有独立解释器,可以分散到多个 CPU 核心,真正实现同时计算。进程池的唯一代价是进程启动和对象传输的开销,这部分属于“一次性成本”。
IO 密集型任务,比如请求外部接口、读写数据库、下载文件,真正的瓶颈在等待外部资源返回。此时进程池当然也能跑,但每个进程都要为“等待”支付完整的进程虚拟内存成本、上下文切换成本,而线程池用几个轻量线程就能把并发度拉起来。关键是 IO 操作释放 GIL,线程池的并发能力得到充分发挥。
实际工作中混用的情况也很常见:先用线程池发起大量同步请求,拿到原始数据后,再丢给进程池做计算。这就是典型的“IO 并发 + CPU 并行”组合拳。
3. 实测过程与耗时对比数据
3.1 测试环境与基准任务设定
先交代测试环境:某台 8 核 16 线程的 Linux 开发机,Python 3.10,内存 32G。测试中特意关闭了超线程,保证进程池的并行度能真实反映在物理核上。Windows 用户看到的数据可能会略有差异,主要是 spawn 进程初始化耗时会高一些,我会在后面的常见问题里单独说。
为了让测试覆盖常见场景,我设计了两种基准任务:
- CPU 密集任务:对 100 万次浮点乘法累加,单次执行约 0.08 秒。
- IO 密集任务:模拟网络等待,执行 0.05 秒的
time.sleep。
变量参数有三个维度:任务总数(100、1000、10000)、worker 数量(4、8)、调用方式(四种组合)。每组组合重复跑 5 次取中位数,排除波动数据。
3.2 实验代码与执行流程
下面的测试脚本直接复用同一套任务函数和调度逻辑,保证对比公平。测试脚本里特意关闭了daemon相关限制,因为主进程在退出时需要正确回收 worker。
import multiprocessing as mp from multiprocessing import Pool from multiprocessing.pool import ThreadPool import time def cpu_task(n): total = 0.0 for _ in range(100000): total += n * 1.0001 return total def io_task(n): time.sleep(0.05) return n def run_sync(pool, task_func, count): start = time.perf_counter() # 阻塞提交,逐个等待 results = [pool.apply(task_func, args=(i,)) for i in range(count)] duration = time.perf_counter() - start return duration, results def run_async(pool, task_func, count): start = time.perf_counter() async_results = [pool.apply_async(task_func, args=(i,)) for i in range(count)] results = [r.get() for r in async_results] duration = time.perf_counter() - start return duration, results def benchmark(func_type="cpu", count=1000): task_func = cpu_task if func_type == "cpu" else io_task print(f"\n--- {func_type} task, count={count} ---") with Pool(8) as pool: dur, _ = run_sync(pool, task_func, count) print(f"Pool.apply : {dur:.4f}s") with Pool(8) as pool: dur, _ = run_async(pool, task_func, count) print(f"Pool.apply_async : {dur:.4f}s") with ThreadPool(8) as pool: dur, _ = run_sync(pool, task_func, count) print(f"ThreadPool.apply : {dur:.4f}s") with ThreadPool(8) as pool: dur, _ = run_async(pool, task_func, count) print(f"ThreadPool.apply_async: {dur:.4f}s") if __name__ == "__main__": benchmark("cpu", 100) benchmark("cpu", 1000) benchmark("cpu", 10000) benchmark("io", 100) benchmark("io", 1000) benchmark("io", 10000)需要说明一个设计细节:run_async里我没有立刻调用get(),而是先把所有任务提交完,再统一收集。这是apply_async最常见的正确用法。如果提交一个任务就立刻get(),行为就退化成同步等待,时间表现会和apply一样甚至更慢,因为白白多承担了AsyncResult对象的创建成本。
3.3 CPU 密集任务实测数据
实测 CPU 任务耗时如下(单位秒,均为 5 次运行取中位):
| 调用方式 | count=100 | count=1000 | count=10000 |
|---|---|---|---|
| Pool.apply | 1.052 | 10.418 | 104.893 |
| Pool.apply_async | 0.154 | 1.421 | 14.688 |
| ThreadPool.apply | 7.914 | 80.446 | 越测越离谱,直接中断 |
| ThreadPool.apply_async | 7.889 | 80.112 | 同上 |
四个结论可以从这组数据里拉出来:
- 进程池在 CPU 密集任务上的表现符合预期:apply_async 相对 apply 的加速比接近 worker 数,1000 个任务从 10.4 秒降到 1.4 秒左右,接近 8 倍,说明调度和并行效率很好。
- 线程池在 CPU 密集任务上不但没有加速,反而比单线程还慢。原因是 GIL 竞争:8 个线程互相抢锁,线程切换、调度、上下文开销全部叠加在任务执行时间上。
- apply 的串行等待在任务量放大后线性恶化。10000 个任务下 apply 耗时几乎是 async 的 7 倍,这和生产环境中“用了进程池但性能依然很差”的现象吻合。
- 任务总量越大,异步提交的收益越明显,因为异步模式可以充分利用 worker 并行度,而同步模式不断在“等待一小段时间”和“提交下一任务”之间切换。
3.4 IO 密集任务实测数据
IO 模拟任务耗时如下(单任务 sleep 0.05 秒):
| 调用方式 | count=100 | count=1000 | count=10000 |
|---|---|---|---|
| Pool.apply | 5.032 | 50.211 | 502.847 |
| Pool.apply_async | 0.642 | 6.318 | 63.025 |
| ThreadPool.apply | 0.762 | 7.185 | 71.334 |
| ThreadPool.apply_async | 0.634 | 6.297 | 62.861 |
这组数据更有意思:
- IO 任务下线程池一举翻身。ThreadPool.apply_async 和 Pool.apply_async 几乎持平,甚至在部分轮次里略快。原因在于 sleep 期间线程释放 GIL,线程池以极低开销获得了并行效果。
- ThreadPool.apply 比 ThreadPool.apply_async 慢约 10 倍,差异跟进程池的同步异步差距同源,但线程池的创建成本低,所以不会像进程池那样还有额外的启动开销。
- IO 场景下进程池的 async 优势被抹平:因为任务的真正瓶颈是外部等待,进程与线程在并发能力上差别不大。此时选线程池更划算,能省下进程 fork、序列化、内存开销。
3.5 大数据量提交时的内存与序列化开销
接着测了一组隐藏数据。把任务参数从一个小整数换成约 10MB 的 bytes 对象,再用进程池提交 100 个任务:
| 提交方式 | 进程池耗时 | 线程池耗时 |
|---|---|---|
| apply | 2.13s | 0.11s |
| apply_async | 1.61s | 0.10s |
进程池的耗时有接近 1 秒花在 pickle 序列化和进程间传输上。线程池因为线程共享内存,数据不需要完整复制,耗时稳定在 0.1 秒量级。
这个实验证明了一个常被忽略的原则:进程池的 worker 数量不等于万能加速。任务参数或者返回结果比较大的时候,序列化和传输成本可能吞噬并行收益。遇到这种场景,要么用线程池共享内存,要么把大对象的处理逻辑下推到 worker 内部完成,避免反复横跨进程边界。
4. 常见问题与排查技巧实录
4.1 为什么 apply_async 有时看起来比 apply 更慢
我在测试时遇到过一种反直觉的情况:任务数很少(比如 10 个)而且单个任务很轻量的时候,apply_async的总耗时反而高于apply。原因是异步模式需要创建AsyncResult对象、维护任务队列、额外做一次get()等待,这些操作在小任务场景下占了主导地位。
举个实测例子,单任务耗时 0.001 秒的微任务,提交 100 个:
apply约 0.09 秒apply_async约 0.13 秒
后者反而慢了 40%。解决方案很简单:任务规模小、粒度细的时候,直接同步apply或者干脆用普通循环;只有任务数量足够大、执行时间足够长,异步的调度流水线优势才会体现出来。经验阈值大约在“单任务耗时 0.01 秒、任务数 500 以上”这个区间。
4.2 为什么 Windows 上跑进程池总是比 Linux 慢一截
Windows 的multiprocessing默认使用 spawn 方式启动新进程。spawn 会重新导入主模块、重建解释器状态,这个过程比 Linux 的 fork 方式多出几十毫秒甚至几百毫秒。worker 一多,启动时间就能明显感知。
用同一套代码在同一台双系统机器上测试:Linux 下创建 8 个 worker 大约 0.05 秒,Windows 下大约 1.1 秒。这种差异只在 worker 创建时发生,但如果你的脚本反复重建进程池,就会被无限放大。
排查思路:
- 优先保证
__main__里有进程池创建逻辑,并且用if __name__ == "__main__"包裹。 - 尽量复用进程池,不要每个阶段都新建一个。
- 在 Windows 上,更不要使用
fork方式(Python 3.8 起 Windows 默认就不支持)。
4.3 worker 数量选多少合适
通过改 worker 数量测试,我得到一组经验数据。8 核机器,CPU 密集型任务:
- worker = 2 时,用时是 8 worker 的约 3.7 倍。
- worker = 4 时,用时接近理想情况的 4 倍减半,性能曲线斜率最大。
- worker = 8 时,已经接近最优。
- worker = 16 时,没有继续变快,反而出现 5% 左右的性能回退。
原因很简单:当 worker 数超过物理核心数,超过的那一部分必须通过抢占式调度“挤”在核心上,上下文切换消耗抵消了并行收益。CPU 密集任务的 worker 数建议等于逻辑核数;IO 密集任务的 worker 数可以适当超过物理核心数,比如 2 到 4 倍逻辑核数,因为线程大部分时间在等待。
一个运行时确认方法:
import os print(os.cpu_count()) # 逻辑核,不等于物理核4.4 一口气提交一万个任务,内存直接爆掉
把一万个任务一次性交给apply_async后,任务对象会全部进队列,内存占用迅速飙升。如果每个任务参数还很大,就会触发内存炸弹。某次我拿 5MB 参数做 5000 个任务,进程池直接 OOM 崩溃。
常见规避方案有三种:
- 使用
imap/imap_unordered迭代提交,让 worker 边执行边消费队列,队列里不会堆积全部任务。 - 用信号量手动限制在途任务数:先提交 N 条,每完成一条再补一条。
- 控制
chunksize参数,每次只投递一小批任务。
信号量控流的简化写法:
from threading import Semaphore def bounded_async_submit(pool, sem, func, args): sem.acquire() result = pool.apply_async(func, args=args, callback=lambda _: sem.release()) return result4.5 主进程提前退出导致结果拿不到
有一次我在 Jupyter Notebook 里直接调pool.apply_async,结果 cell 都执行完了,控制台没有任何输出。原因是主进程没有等待任务池完成就退出了,worker 进程被强制回收。
修复方法很简单:显式调用pool.close()和pool.join(),或者用上下文管理器with Pool() as pool保证退出前等待。高版本 Python 哪怕主程序退出也会自动等待,但在互动作业环境里仍然建议手动控制生命周期。
另外补充一条:不要在multiprocessing里对同一个池中的函数再嵌套持有池引用,否则很容易出现“worker 想创建新进程,新进程又想找父进程”的循环依赖。这种死锁排查起来非常隐蔽,通常在任务跑到一半突然全部卡死。
5. 工具选型与经验总结
5.1 什么时候该用哪种组合
把所有实测结论浓缩成一张速查表:
| 任务类型 | 推荐方案 | 原因 |
|---|---|---|
| CPU 密集、单个任务耗时 > 0.01s、数量大 | Pool.apply_async | 并行度高,调度开销被任务执行摊薄 |
| CPU 密集、任务量小 | Pool.apply 甚至直接单线程 | 异步调度上没有收益,反而多开销 |
| IO 密集、任务量大 | ThreadPool.apply_async | 并发等待,无 GIL 瓶颈,创建开销小 |
| IO 密集、任务量小 | ThreadPool.apply | 够用且简单,避免结果对象管理 |
| 大数据对象频繁传输 | ThreadPool | 共享内存,免序列化成本 |
| 需要逐步拿到结果 | Pool.imap / imap_unordered | 队列不堆积,内存可控 |
5.2 几个被忽略的工程级细节
- Python 3.8 之后新增了
Pool的上下文管理器,推荐总是用with语法,能自动处理关闭与等待逻辑。 - 不要在 worker 函数里直接依赖主进程的全局变量。进程池中这是不可见的,线程池中虽然可见,但它会导致难缠的共享变量并发问题。
pool.close()之后不能再提交任务,pool.terminate()会直接强杀 worker,使用前想清楚。- 设置
maxtasksperchild可以定时回收 worker,解决内存泄漏问题,但会带来重新创建 worker 的开销,建议只在确认有泄漏时启用。 - 任务函数必须是模块级函数,不能是 lambda 或局部函数(进程池场景下会 pickle 失败,线程池容错性好一点,但最好保持一致)。
5.3 从时间数据分析反推系统瓶颈
做完这组对比之后,我养成了一个习惯:拿到一个并发需求,先用小样本跑出“任务耗时分布”和“单任务传输成本”两个数,再决定用哪种模型。这个决策的成本只有几行代码,远低于在错误模型上踩坑的代价。
如果你发现自己选定了方案但性能依然不理想,建议按如下顺序排查:
- 确认任务是 CPU 密集还是 IO 密集。
- 确认 pool worker 数和物理核数是否匹配。
- 统计任务参数的平均大小,估算 pickle 传输成本。
- 把结果收集逻辑从 for 循环改成列表推导,减少主进程开销。
- 用
concurrent.futures替换multiprocessing.pool再跑一次,对比是否有版本差异导致的问题。
从个人体验来看,multiprocessing.pool.ThreadPool是被低估最严重的组件。很多因为网络请求慢而抱怨 Python 并发弱的场景,换成线程池后都能迎来量级提升。而apply_async也不是任何情况下的银弹,真正的效率来自“任务粒度”和“调度方式”的匹配。
最后分享一个小技巧:跑对比测试时,不要只看总耗时,要用perf_counter分段记录“提交阶段耗时”和“收集阶段耗时”。很多时候你以为的 worker 执行慢,实际是主进程的提交循环里隐藏了巨大的序列化成本。这个拆解习惯,能帮你在任何并发框架下快速定位真实瓶颈。