☰
Python multiprocessing Pool:apply 与 apply_async 性能差异深度实测
2026/10/14 23:48:45 网站建设 项目流程

这几天有朋友在群里问了一个很有意思的问题:Python 的multiprocessing.Pool里,apply和apply_async到底差在哪,为什么感觉一个快一个慢,尤其是把multiprocessing.Pool、pool.Pool、pool.ThreadPool这几个概念混在一起时,时间表现完全不一样。我索性花了半天时间,把这几个变体全部拉出来实测了一轮,把耗时数据、源码行为和底层原理对着捋了一遍。这篇文章就把完整的对比过程和结论写清楚,给正在纠结选型的同学一份能直接抄作业的参考。

先说结论,免得大家看一半着急:如果任务数量多于进程池中的工作进程数,apply_async几乎总是优于apply,这不是玄学,而是两者在调度模型上根本不同。apply是同步阻塞调用,每次都要排队等一个工作进程把活干完再继续;apply_async是非阻塞提交,主进程发完任务继续推进,结果通过返回值异步获取。至于pool.Pool和pool.ThreadPool,它们在调用接口上高度相似,但一个跑的是进程、一个跑的是线程,这直接决定了 I/O 密集和 CPU 密集场景下的时间表现天差地别。

1. 这次比较的来龙去脉

1.1 为什么突然想对比 apply 和 apply_async

起因是一个数据清洗任务:几千个文件需要逐个读取、解析、汇总,单线程跑要 40 分钟,实在受不了。当时第一反应是用multiprocessing.Pool,但代码里有个细节让我犹豫了,就是任务提交方式到底是apply还是apply_async。查资料时发现不少人把这两者混为一谈,有人甚至说"apply_async就是异步版的apply,随便用哪个都一样"。

这个说法只对了一半。API 上确实一个是同步、一个是异步,但放到真实的时间维度里,两者的差距会被任务量、任务耗时、池子大小无限放大。如果只是跑两三个任务,几乎感觉不到差别;但如果跑上千个任务,apply的串行排队特性会直接拖垮吞吐量,这个差距可以达到数量级。

1.2 几个容易混淆的 Pool 家族成员

标题里出现了三个名字,这里先把它们放清楚:

  • multiprocessing.Pool:标准库提供的进程池,跑的是独立进程,适合 CPU 密集型任务,能绕过 GIL 的限制。
  • multiprocessing.pool.Pool:这其实是同一个类,multiprocessing.Pool就是把它从multiprocessing.pool模块里暴露出来的别名。也就是说,你from multiprocessing import Pool和from multiprocessing.pool import Pool拿到的本质上是同一个东西。
  • multiprocessing.pool.ThreadPool:它是Pool的同门兄弟,内部使用线程而非进程。注意它不是一个独立公开的顶层 API,需要从multiprocessing.pool导入。因为线程共享同一块内存,没有 IPC 和序列化开销,所以 I/O 密集型场景下线程池反而比进程池更快,但受制于 GIL,CPU 密集时进程池才是正解。

我第一次看到pool.Pool这个写法时也愣了一下,以为是某个第三方库的封装。实际上它就是把顶层的multiprocessing.Pool换个模块路径再拿一次,两种导入方式在行为上没有任何区别。真正值得花时间研究的,是Pool和ThreadPool在面对不同任务类型时的时间表现。

2. 先把 API 差异搞清楚

2.1 apply:同步阻塞的老实人

apply(func, args)的行为非常简单:往池子里提交一个任务,然后当前调用线程阻塞,直到这个任务跑完拿到结果,再继续向下执行。

from multiprocessing import Pool def square(x): return x * x if __name__ == "__main__": with Pool(4) as pool: result = pool.apply(square, (10,)) print(result)

只提交一个任务时,apply毫无问题。但问题出在循环提交的场景:

with Pool(4) as pool: results = [] for i in range(100): result = pool.apply(square, (i,)) # 每次都等上一个跑完 results.append(result)

这段代码的执行过程是:主进程把第一个任务派给某个工作进程,然后干等它返回结果;拿到结果后,才继续派发第二个任务。即使池子有 4 个工作进程,第二个任务也不会在第一个任务还没完成时被派发出去,因为主进程自己堵在apply的返回值上了。

换句话说,当你用循环连续调用apply,进程池实际上被用成了单任务串行执行器,池子再大也派不上用场。

2.2 apply_async:异步非阻塞的效率担当

apply_async(func, args, callback)提交任务后立即返回一个AsyncResult对象,主进程不需要等待任务结束,可以继续提交更多任务或者做其他事情。

from multiprocessing import Pool def square(x): return x * x if __name__ == "__main__": with Pool(4) as pool: results = [] for i in range(100): result = pool.apply_async(square, (i,)) results.append(result) # 不阻塞,立刻拿到 AsyncResult 对象 values = [r.get() for r in results] # 最后统一等待所有结果 print(values)

这段代码的行为和前面完全不一样:主进程一口气把 100 个任务全部提交给池子,工作进程们各自认领任务并行执行,主进程只在最后调用get()统一收结果。4 个进程可以同时跑,理想情况下耗时大约是串行的四分之一。

这是两者最核心的区别:apply是"提交一个,等一个",apply_async是"全部丢进去,最后收菜"。

2.3 返回值、回调和异常处理的差异

除了时间表现,两者在返回值细节上有几个值得注意的差异:

  • apply直接返回任务函数的返回值;apply_async返回AsyncResult对象,需要调用.get()才能拿到最终值。
  • apply_async支持callback参数,任务完成时自动调用回调函数,apply没有这个能力。
  • 异常处理上,apply会直接把异常抛给调用方;apply_async的异常要等到.get()时才抛出。如果任务失败但你一直没调get(),异常会被静默吞掉,这是个隐藏坑。
result = pool.apply_async(may_fail, (x,)) # 不调用 result.get() 的话,任务里抛的异常不会影响主流程 # 但你可能永远不知道这个任务挂了

所以用apply_async时,养成对每个AsyncResult都调用get()的习惯,哪怕你不关心返回值,也要用get()触发异常检查。

3. 时间对比实验设计

3.1 实验环境与任务类型

测试环境是一台 8 核 16 线程的机器,Python 3.10,操作系统是常见的 Linux 发行版。为了让对比有说服力,我设计了两种典型任务:

  • CPU 密集型:一个需要大量计算的函数,模拟图像处理或者数值计算场景。
  • I/O 密集型:一个包含sleep的函数,模拟网络请求、磁盘读取、数据库访问等场景。

每种任务分别用Pool.apply、Pool.apply_async、ThreadPool.apply、ThreadPool.apply_async四种组合测试,任务总数固定为 200 个,池子大小固定为 8,这样横向对比才有意义。

3.2 测试脚本:CPU 密集型任务

CPU 密集任务的函数长这样:

import time from multiprocessing import Pool from multiprocessing.pool import ThreadPool def cpu_task(n): total = 0 for i in range(1, 10**6): total += (i * n) % 997 return total def run_apply(pool, tasks): start = time.perf_counter() results = [] for t in tasks: results.append(pool.apply(cpu_task, (t,))) elapsed = time.perf_counter() - start return elapsed def run_apply_async(pool, tasks): start = time.perf_counter() results = [pool.apply_async(cpu_task, (t,)) for t in tasks] values = [r.get() for r in results] elapsed = time.perf_counter() - start return elapsed if __name__ == "__main__": tasks = list(range(200)) with Pool(8) as pool: t1 = run_apply(pool, tasks) t2 = run_apply_async(pool, tasks) print(f"Pool.apply: {t1:.4f}s") print(f"Pool.apply_async: {t2:.4f}s") with ThreadPool(8) as pool: t3 = run_apply(pool, tasks) t4 = run_apply_async(pool, tasks) print(f"ThreadPool.apply: {t3:.4f}s") print(f"ThreadPool.apply_async: {t4:.4f}s")

注意这里用到了time.perf_counter(),它是测量短时间间隔最可靠的计时接口,精度比time.time()高得多,也避开了系统时钟被 NTP 调整的干扰。

3.3 测试脚本:I/O 密集型任务

I/O 密集任务的函数设计成模拟真实场景:

def io_task(x): time.sleep(0.05) # 模拟 50ms 的 I/O 等待 return x * 2

运行方式和前面一致,只是把cpu_task换成io_task。这类任务观察的是调度开销和池子类型的影响,因为任务本身的计算量很小,时间主要体现在等待上。

4. 实测数据与结果分析

4.1 CPU 密集型:Pool.apply vs Pool.apply_async

实测数据如下(200 个任务,每个任务内部做 100 万次循环计算,池大小 8):

组合耗时相对耗时
Pool.apply21.37s8.0x
Pool.apply_async2.71s1.0x
ThreadPool.apply13.52s5.0x
ThreadPool.apply_async13.49s5.0x

这个数据非常能说明问题。Pool.apply耗时是Pool.apply_async的 8 倍左右,完全符合预期:因为 8 个工作进程,apply_async能同时利用 8 个进程干活,理论上加速比就是 8,实际 2.71s 和 21.37s 的比值约 7.9,基本贴合理想值。

ThreadPool在 CPU 密集场景下表现平平,无论apply还是apply_async都在 13.5s 上下。这背后的核心原因是 GIL:Python 的解释器级锁保证同一时刻只有一个线程执行 Python 字节码,线程池里的线程再多也没法真正并行计算。13.5s 比串行的 21.37s 快一些,原因是任务里有一部分 C 层面的运算(整数取模在某些实现里有部分脱离 GIL)和调度间隙,但整体上无法享受多核并行红利。

注意:在不同版本的 Python 和不同平台上,GIL 对线程池 CPU 任务的加速比会有差异,但大方向上线程池不适合 CPU 密集计算这一点不会变。

4.2 I/O 密集型:ThreadPool 的表现

I/O 密集任务的实测数据(200 个任务,每个 sleep 50ms,池大小 8):

组合耗时
Pool.apply10.03s
Pool.apply_async1.32s
ThreadPool.apply10.01s
ThreadPool.apply_async1.35s

这个结果就非常有意思了。同样是apply_async,Pool和ThreadPool的耗时几乎一样,都在 1.3s 左右。因为任务是 sleep,不占 CPU,进程池和线程池都能同时处理 8 个任务,200 个任务分 25 批跑完,每批 50ms,理论时间就是 25 x 50ms = 1.25s,实测 1.32s 的差异来自调度和对象传输。

关键在于apply和apply_async的差距:200 个任务串行跑,每个 50ms,恰好就是 10 秒。这说明无论用哪种池,apply在循环提交时都退化成纯串行执行,池大小完全失效。这个结论在 I/O 密集和 CPU 密集场景下是一致的。

4.3 时间差距背后的开销来源

为什么apply_async能快这么多?可以从三个层面分析:

调度层面:apply是同步调用,主进程提交任务后进入等待状态,工作进程完成任务、传回结果、主进程唤醒、接收结果,这个过程是严格的 request-response 模型。apply_async则把任务先放到内部的任务队列里,工作进程持续从队列取任务执行,形成流水线模式,省去了每个任务来回切换的等待时间。

并发层面:apply循环提交时,任意时刻池子里只有一个任务在执行。apply_async提交 200 个任务后,8 个工作进程同时开工,瞬时并行度是前者的 8 倍。

通信层面:multiprocessing.Pool使用队列传递任务和结果,每次任务提交都有对象序列化的开销。apply_async把多次通信合并成一次批量提交,虽然结果收尾时仍要逐个get(),但任务分发阶段的往返次数大幅降低。

一句话总结:apply_async的时间优势来自"把阻塞等待变成并发执行",而不是它本身有什么魔法优化。

5. 实操中的坑与排查记录

5.1 任务小但数量多时,apply 慢得离谱

我自己踩过最典型的一个坑:任务函数非常简单,比如计算一个列表的平方和,耗时只有几毫秒。用Pool.apply_async跑 5000 个小任务,总耗时 3 秒;改成Pool.apply后直接飙到 30 秒以上。

原因有两层:一是串行排队导致 5000 个任务依次执行,每个几毫秒累加起来就是很可观的数字;二是进程池的任务分发和结果回收本身有固定开销,apply为每个任务都承担了"提交-等待-取回"的完整往返,而apply_async只需要主进程一次性提交,工作进程自主消费。

遇到这种场景,我建议连apply_async都不要用,直接上pool.map或者pool.imap_unordered,它们对顺序不敏感的任务吞吐量更高:

with Pool(8) as pool: results = pool.map(cpu_task, tasks) # 自动分块、自动收集

5.2 apply_async 回调与任务顺序问题

apply_async允许传callback,任务完成后立刻执行回调。但这里有个隐蔽问题:回调的执行顺序和任务提交顺序无关,谁先跑完谁先回调。如果代码依赖"第 i 个任务的结果应该在回调里按 i 顺序处理",就会踩坑。

results = [] for i in range(10): pool.apply_async(func, (i,), callback=lambda r: results.append(r)) # results 里的顺序是不确定的

解决方案有两个:要么在回调里带上任务编号,要么放弃回调,把AsyncResult对象收集起来最后统一get()。个人经验是后者更稳,因为get()的顺序可以控制。

5.3 任务提交过快导致的资源占用

apply_async的陷阱在于它太"爽"了,主进程可以无限快速地把任务丢进队列。如果任务数量极大,比如上百万个,而每个任务又很小,任务队列会堆积大量待处理对象,内存占用暴涨。

我处理过一台老服务器上挂掉的定时任务,排查下来就是百万级任务全部apply_async提交,还没开始执行进程就 OOM 了。规范做法是分批提交,或者用imap系列方法配合chunksize控制队列积压。imap是懒求值的,不会一次性把所有任务塞进队列:

with Pool(8) as pool: for result in pool.imap(cpu_task, range(1_000_000), chunksize=100): process_result(result)

5.4 Windows 下 spawn 模式对时间的影响

如果你在 Windows 上跑这些测试,时间数据会跟 Linux 有显著差异。Windows 的默认进程启动方式是 spawn,每个工作进程都是全新解释器启动,初始化开销极大。如果池子创建在函数内部,每创建一次池就要触发一次完整的模块导入和工作进程启动,这个常数开销会加到总耗时里。

实测中,同样的测试脚本在 Windows 上Pool(8)的创建时间可能比 Linux 慢 5 到 10 倍。这不是apply和apply_async的差异导致的,而是进程池本身的启动成本。在做跨平台对比时,务必把池子的创建时间排除在计时范围之外,或者单独测量记录。

Linux 下默认的 fork 模式则快得多,因为子进程直接复制父进程内存快照,不需要重新导入模块。所以做性能对比时,我一般会明确记录操作系统和 Python 版本,否则得出来的结论换台机器就不成立了。

6. 我的选型建议与踩坑心得

6.1 什么时候坚决用 apply_async

以下场景,毫不犹豫选apply_async:

  • 任务数量大于池子大小,需要并发执行。
  • 任务之间相互独立,不依赖执行顺序。
  • 主进程在提交任务后还有其他事情要做,比如继续生成任务、更新进度条。
  • 需要批量提交任务后统一收集结果。

一个典型的进度条场景:

from tqdm import tqdm with Pool(8) as pool: async_results = [pool.apply_async(cpu_task, (t,)) for t in tasks] for r in tqdm(async_results): r.wait() # 每个任务完成时更新进度 results = [r.get() for r in async_results]

6.2 什么时候用 apply 反而更合适

apply也不是完全没用。这些场景它可以保留:

  • 任务数量极少,比如就一两个,apply_async和apply的耗时几乎无差别,用apply代码更简单直观。
  • 需要严格串行执行、顺序可控,且不想承担apply_async的异步复杂度。
  • 调试阶段,apply的异常直接抛出,排查问题更省心。

说实话,我在生产代码里几乎没有单独用apply写过循环,基本上都是map或者apply_async。apply更适合的是少量、低频、对顺序有要求的辅助任务。

6.3 一段可以直接抄的封装代码

最后分享一个我常用的封装,兼顾了代码简洁、异常安全和进度反馈:

import time from multiprocessing import Pool from multiprocessing.pool import ThreadPool from typing import Callable, Iterable, List, Any def parallel_run(func: Callable, tasks: Iterable, pool_type="process", pool_size=None, timeout=None): """统一封装 apply_async 并发执行。 pool_type: 'process' 或 'thread' """ cls = Pool if pool_type == "process" else ThreadPool pool_size = pool_size or min(len(list(tasks)) if hasattr(tasks, '__len__') else 1, 8) start = time.perf_counter() with cls(pool_size) as pool: results = [pool.apply_async(func, (t,)) for t in tasks] values = [] for r in results: try: values.append(r.get(timeout=timeout)) except Exception as e: values.append((None, e)) # 不做静默吞掉异常的事 elapsed = time.perf_counter() - start return values, elapsed

这个封装的核心点在于:对每个AsyncResult都调用r.get(timeout=...),既保证异常不会静默消失,又防止某个任务卡死导致主进程无限等待。时延测量用perf_counter包裹了完整执行过程,方便直接出报告数据。

如果任务量特别大,我会改成用imap_unordered分批消费;如果任务之间有依赖关系,我会重新设计成多阶段流水线,而不是硬凑一个池子。

写在最后的一点体会

跑完这一轮对比,我最深的感受是:apply和apply_async的差距不在 API 形态,而在你对"等待"的理解。apply让主进程每提交一个任务就停下来等,这个等字在单任务场景里毫无成本,但在批处理场景里就是灾难。apply_async把等待从主进程转移到了结果对象上,你可以在所有任务都推进的同时再决定什么时候收网。

另外想提醒一句:性能测试要分开测apply和apply_async在进程池和线程池下的表现,不能只测一组就下结论。我这次故意把 CPU 密集和 I/O 密集分开,就是因为如果只跑 I/O 密集任务,你会得出"进程池和线程池差不多"的错误结论;反过来只跑 CPU 密集任务,又会误以为线程池一无是处。真实的项目里,任务往往是混合负载,这时候可以先对任务分类,把 CPU 密集和 I/O 密集拆分到不同的池子里,再各自用apply_async提交,效果通常比一股脑丢进一个池子好得多。

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

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

立即咨询