4. 进程池与任务分发模式

当数值任务从“单个大计算”变成“成百上千个小计算”时,手工创建 Process 往往得不偿失:进程创建与销毁本身有开销,任务数量一多,调度和结果收集也会变得难以管理。multiprocessing.Pool 正是为这种批处理场景设计的——它预先创建固定数量的工作进程,由主进程把任务分发给它们,再把结果按需回收。本节围绕三种最常用的分发接口展开,并讨论任务粒度与负载均衡这两个决定实际加速比的关键因素。

三种接口的语义差异

Pool.map 是最接近内置 map 的同步接口:它接收一个函数和一个可迭代对象,把每个元素作为一次任务提交,阻塞等待全部完成后,按输入顺序返回结果列表。它的优点是写法简单、结果有序;缺点是必须等最慢的那个任务结束才能拿到任何结果,且整个列表会一次性驻留内存。对于“每个任务耗时相近、结果需要与输入一一对应”的数值批处理(例如对一批参数组合分别计算目标函数值),map 是最省心的选择。

apply_async 是异步提交单个任务的接口,返回一个 AsyncResult 对象。它不阻塞主进程,因此可以连续提交多个任务,再用 get() 逐个取结果,也可以配合回调函数 callback 在任务完成时立即处理。它适合任务参数在提交时才能确定、或需要精细控制提交节奏的场景,但需要自己管理结果对象的收集顺序。

imap_unordered 返回一个迭代器,任务完成一个就产出一个结果,且不保证顺序。它不会像 map 那样把全部结果攒在内存里,也不会因为某个慢任务而阻塞后续结果的消费。对于“结果与顺序无关、只关心尽快拿到可用结果”的批处理(例如扫描一批候选解、找到满足阈值的结果即可提前停止),它通常是最优选择。与之对应的 imap 则保持输入顺序,但同样以流式方式产出。

三种任务分发模式的结果回收方式对比 Pool.map(同步·有序) 提交:一次性传入整个列表 [t1, t2, t3, t4, t5] 执行:阻塞等待全部完成 最慢任务决定返回时刻 回收:按输入顺序返回列表 [r1, r2, r3, r4, r5] 适用:任务耗时相近、 结果需与输入一一对应 注意:结果全量驻留内存 apply_async(异步·单任务) 提交:逐个提交,立即返回句柄 AsyncResult × N 执行:主进程不阻塞 可继续提交或做其他事 回收:get() 或 callback 取结果 按句柄顺序自行收集 适用:参数动态确定、 需精细控制提交节奏 注意:需自行管理结果顺序 imap_unordered(流式·无序) 提交:传入可迭代对象 惰性分发,边算边取 执行:完成一个产出一个 先完成者先被消费 回收:迭代器,顺序不保证 r3 → r1 → r5 → r2 … 适用:结果与顺序无关、 希望尽早消费或提前停止 注意:结果需自带标识

数值批处理中的实际写法

下面以一个“参数扫描”为例:对阻尼系数 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)),消费端再按标识归位,而不要依赖产出顺序。
在 Windows 上,进程池代码必须放在 if __name__ == "__main__": 保护块内,否则子进程重新导入模块时会递归创建进程池。此外,任务函数中不要修改全局可变状态,各进程拥有独立内存,修改不会回传主进程。

综合来看,选择哪种接口取决于结果消费方式:需要有序全量结果用 map;需要动态提交或回调处理用 apply_async;追求吞吐、可流式消费或提前终止用 imap_unordered。而无论选哪种,任务粒度与 chunksize 的调校才是把理论加速比真正兑现的关键。