4. 进程池与任务分发模式
当数值任务从“单个大计算”变成“成百上千个小计算”时,手工创建 Process 往往得不偿失:进程创建与销毁本身有开销,任务数量一多,调度和结果收集也会变得难以管理。multiprocessing.Pool 正是为这种批处理场景设计的——它预先创建固定数量的工作进程,由主进程把任务分发给它们,再把结果按需回收。本节围绕三种最常用的分发接口展开,并讨论任务粒度与负载均衡这两个决定实际加速比的关键因素。
三种接口的语义差异
Pool.map 是最接近内置 map 的同步接口:它接收一个函数和一个可迭代对象,把每个元素作为一次任务提交,阻塞等待全部完成后,按输入顺序返回结果列表。它的优点是写法简单、结果有序;缺点是必须等最慢的那个任务结束才能拿到任何结果,且整个列表会一次性驻留内存。对于“每个任务耗时相近、结果需要与输入一一对应”的数值批处理(例如对一批参数组合分别计算目标函数值),map 是最省心的选择。
apply_async 是异步提交单个任务的接口,返回一个 AsyncResult 对象。它不阻塞主进程,因此可以连续提交多个任务,再用 get() 逐个取结果,也可以配合回调函数 callback 在任务完成时立即处理。它适合任务参数在提交时才能确定、或需要精细控制提交节奏的场景,但需要自己管理结果对象的收集顺序。
imap_unordered 返回一个迭代器,任务完成一个就产出一个结果,且不保证顺序。它不会像 map 那样把全部结果攒在内存里,也不会因为某个慢任务而阻塞后续结果的消费。对于“结果与顺序无关、只关心尽快拿到可用结果”的批处理(例如扫描一批候选解、找到满足阈值的结果即可提前停止),它通常是最优选择。与之对应的 imap 则保持输入顺序,但同样以流式方式产出。
数值批处理中的实际写法
下面以一个“参数扫描”为例:对阻尼系数 c 与刚度 k 的若干组合,分别积分一个单自由度振动系统,统计稳态振幅。任务函数必须是模块顶层可导入的函数,参数与返回值都应可被 pickle 序列化——这是进程池能工作的前提。
import numpy as np
from multiprocessing import Pool
def simulate(params):
c, k = params
dt, n = 0.001, 4000
x, v, amp = 0.0, 1.0, 0.0
for _ in range(n):
a = -c * v - k * x
v += a * dt
x += v * dt
amp = max(amp, abs(x))
return (c, k, amp)
if __name__ == "__main__":
grid = [(c, k) for c in np.linspace(0.1, 2.0, 20)
for k in np.linspace(1.0, 10.0, 20)]
with Pool(processes=8) as pool:
results = pool.map(simulate, grid) # 有序,一次性返回
# 或:for r in pool.imap_unordered(simulate, grid): ...
这里 grid 共 400 个任务。若单次 simulate 约 20 毫秒,串行约 8 秒;在 8 核机器上理论下限约 1 秒,实际会因进程启动、序列化和调度开销略高。判断加速是否合理,应比较“串行总耗时 / 并行总耗时”与核数的接近程度,而不是只看绝对时间。
任务粒度:不要切得太碎
进程池的每次任务分发都涉及参数与结果的序列化、通过管道传输、以及工作进程的调度。如果单个任务只有几毫秒,这些固定开销可能超过计算本身,导致并行反而更慢。经验做法是让单个任务至少运行几十毫秒到数秒;若原始任务过细,应把若干小任务打包成一个块再提交,例如把 400 个参数组合按每 10 个一组,变成 40 个任务,每个任务内部循环计算并返回列表。这样既减少了分发次数,也让每个工作进程有足够的连续计算时间。
反过来,任务也不能过大:如果只有 4 个任务却开了 8 个进程,就有一半进程空闲,负载无法摊平。合理的任务数通常是进程数的数倍(例如 4~10 倍),既保证每个进程都有活干,又留出动态调度的余地。
负载均衡:chunksize 与任务排序
map、imap、imap_unordered 都接受 chunksize 参数,它决定每次向工作进程派发多少个连续任务。默认值对长任务偏小、对短任务偏大,实际使用时应结合单任务耗时调整:任务越短,chunksize 可适当增大以减少调度次数;任务越长且耗时差异越大,chunksize 应取小(甚至为 1),让先完成的进程能继续领取新任务,避免“一个进程啃大块、其余进程早早空闲”。
另一个常被忽视的手段是把耗时长的任务排在前面提交。进程池按提交顺序分发任务,若大任务集中在末尾,收尾阶段会出现只有少数进程还在工作的“长尾”。将预估耗时大的参数组合前置,能让它们在早期就被并行处理,缩短整体完成时间。对于耗时完全未知的任务,可先用小样本测时,再据此排序。
imap_unordered 时应让任务函数返回携带标识的元组(如上面的 (c, k, amp)),消费端再按标识归位,而不要依赖产出顺序。if __name__ == "__main__": 保护块内,否则子进程重新导入模块时会递归创建进程池。此外,任务函数中不要修改全局可变状态,各进程拥有独立内存,修改不会回传主进程。综合来看,选择哪种接口取决于结果消费方式:需要有序全量结果用 map;需要动态提交或回调处理用 apply_async;追求吞吐、可流式消费或提前终止用 imap_unordered。而无论选哪种,任务粒度与 chunksize 的调校才是把理论加速比真正兑现的关键。