从一次真实的需求出发把 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_typecpu, count1000): 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(fPool.apply : {dur:.4f}s) with Pool(8) as pool: dur, _ run_async(pool, task_func, count) print(fPool.apply_async : {dur:.4f}s) with ThreadPool(8) as pool: dur, _ run_sync(pool, task_func, count) print(fThreadPool.apply : {dur:.4f}s) with ThreadPool(8) as pool: dur, _ run_async(pool, task_func, count) print(fThreadPool.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 次运行取中位调用方式count100count1000count10000Pool.apply1.05210.418104.893Pool.apply_async0.1541.42114.688ThreadPool.apply7.91480.446越测越离谱直接中断ThreadPool.apply_async7.88980.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 秒调用方式count100count1000count10000Pool.apply5.03250.211502.847Pool.apply_async0.6426.31863.025ThreadPool.apply0.7627.18571.334ThreadPool.apply_async0.6346.29762.861这组数据更有意思IO 任务下线程池一举翻身。ThreadPool.apply_async 和 Pool.apply_async 几乎持平甚至在部分轮次里略快。原因在于 sleep 期间线程释放 GIL线程池以极低开销获得了并行效果。ThreadPool.apply 比 ThreadPool.apply_async 慢约 10 倍差异跟进程池的同步异步差距同源但线程池的创建成本低所以不会像进程池那样还有额外的启动开销。IO 场景下进程池的 async 优势被抹平因为任务的真正瓶颈是外部等待进程与线程在并发能力上差别不大。此时选线程池更划算能省下进程 fork、序列化、内存开销。3.5 大数据量提交时的内存与序列化开销接着测了一组隐藏数据。把任务参数从一个小整数换成约 10MB 的 bytes 对象再用进程池提交 100 个任务提交方式进程池耗时线程池耗时apply2.13s0.11sapply_async1.61s0.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, argsargs, callbacklambda _: 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 执行慢实际是主进程的提交循环里隐藏了巨大的序列化成本。这个拆解习惯能帮你在任何并发框架下快速定位真实瓶颈。