想象你是火锅店老板(主进程)。饭点一到,订单像雪片一样砸过来,要是你一个人又点单、又备菜、又端锅,店门早就被等位的客人拆了。聪明的做法是招一支厨师团队(进程池):订单从传菜口递进来,谁手空谁接活,做完从出菜口端出去。Python 的 multiprocessing.Pool 就是这套后厨班底——把任务丢进去,它自动派给多个 CPU 核心同时做,绕开 GIL 这把“一把勺只能一人用”的限制。这篇文章用后厨故事讲清楚 Pool 的两大点单方式 map 和 apply、后厨传菜的 Queue 和 Pipe、大家共看一块黑板算数的共享内存、厨师到底雇几个划算,以及糊锅翻车的几个大坑(序列化失败、僵尸进程、死锁)。学完你也能把自己的数据处理厨房开起来。

岗位关联

进程池是把单核“一口锅”升级成多核“流水线后厨”的核心工具。搞定它,批量计算和数据处理项目能快出一个数量级——这是数据与 AI 工程岗位实实在在的竞争力。

目录
  1. 可视化演示
  2. 进程池基础:组建你的后厨班底
  3. map 批量映射:一百份同样的宫保鸡丁
  4. apply 灵活调用:单点定制菜
  5. 进程间通信:传菜窗口与专用滑道
  6. 共享内存方案:一块共用的白板
  7. 进程数调优:雇几位厨师最划算
  8. 踩坑避雷:后厨翻车现场
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示全概率公式P(A)=ΣP(A|Bi)P(Bi)的计算过程,将样本空间划分为互斥事件Bi,通过加权求和得到事件A的总概率。

本节配有交互动效(进入视口后自动加载,约 3KB)。

全概率公式 样本空间被划分为互斥事件B1,B2,B3 P(A)等于各条件下概率的加权平均 P(A) = Σ P(A|Bi)P(Bi)

进程池基础:组建你的后厨班底

老板决定开一家川菜馆,第一件事就是定班底。他不会每天都重新招人,而是固定雇好 4 位厨师,让岗位一直有人——这就是进程池的“进程复用”思想:创建一批常驻子进程,反复接单,而不是每来一单就现招一个人(那是 Process 裸用,创建销毁成本极高)。

翻译成 Python,multiprocessing.Pool(processes=4) 就是这次招聘。Pool 创建时会 fork 出 4 个子进程,每个子进程都有自己独立的 Python 解释器和内存空间,好比每位厨师有自己的灶台和调料柜。随后你把任务“递进传菜口”,空闲的厨师会自动取单、执行、把结果端到出菜口,全程不需要你干预。

具体来说,Pool 给出两大点单接口:map 把一个函数批量套到整个列表上;apply 传具体参数执行一次。两者都会自动派单。要注意的是,子进程是主进程的“复制品”:模块里的全局变量在 fork 时被复制了一份,大家在各自的副本上操作,谁也看不见谁的改动——就像每位厨师手里各有一张复印的菜单,改自己那份不影响别人。

所以后厨创业有个基本觉悟:订单太少时,单锅快炒比开团队更划算;只有订单源源不断,团队成本才摊得薄。这正是进程池的适用边界:任务量要够大、够密集,否则你付的“培训费”比省下的时间还多。

T并行=T串行P+T创建+T通信\displaystyle T_{\text{并行}} = \frac{T_{\text{串行}}}{P} + T_{\text{创建}} + T_{\text{通信}}
并行不可能“零成本提速”:把任务均分给 P 个进程只是理想收益,进程创建和任务通信都要额外付出时间。
  1. 串行总耗时一个进程把所有任务挨个跑完所需的总时间
    T串行T_{\text{串行}}
  2. 进程数进程池里的厨师数量,即同时开火的灶台数
    PP
  3. 理想均分假如任务能完美平分,每个进程只干原来 1/P 的活
    T串行P\frac{T_{\text{串行}}}{P}
  4. 创建开销fork 出 P 个子进程、初始化解释器的固定成本,一次性的“培训费”
    T创建T_{\text{创建}}
  5. 通信开销把任务序列化送进池、把结果序列化收回来的时间
    T通信T_{\text{通信}}

map 批量映射:一百份同样的宫保鸡丁

中午来了一个百人旅行团,每人点同一道宫保鸡丁。老板做了一件聪明事:不一份份地跟厨师交代,而是把 100 张单子合成一摞递进传菜口,附一句“全部做成宫保鸡丁”。这就是 map 的运作方式——把一个函数应用到可迭代对象的每一个元素上。

在代码里,pool.map(函数,列表) 接受两样东西:一个处理函数,一串待处理的数据。各个子进程从内部队列取一个(或一块)数据,执行函数,把结果放回队列。等所有人干完,pool 按输入顺序把结果排好还给你——第 i 个返回值永远对应第 i 个输入。就像出菜口按点单号摆盘,厨师炒菜的顺序可能千奇百怪,端出来的顺序绝对不乱。

如果函数需要多个参数,比如“统计区间 [a,b) 里的素数个数”,就用 pool.starmap(函数,[(a1,b1),(a2,b2),……]),它会把每个元组拆开当参数。还有一个小旋钮 chunksize:默认可能一个任务一块,也可以设置成“一次端 10 盘”,减少派单次数,适合单任务极短、任务量极大的场景。

map 就像“标准化菜单”:所有菜品规格统一、流程一致时,批量派单效率最高。如果你的任务各不相同、参数五花八门,那 map 的整齐队形反而派不上用场。

Sp=T串行T并行≈N×t单p×t单=Np\displaystyle S_p = \frac{T_{\text{串行}}}{T_{\text{并行}}} \approx \frac{N \times t_{\text{单}}}{p \times t_{\text{单}}} = \frac{N}{p}
理想情况下,N 个等长任务分给 p 个进程,加速比约等于 N/p——但真实世界还有通信和调度,所以是“约等于”。
  1. 任务总数菜的总盘数,也是列表长度
    NN
  2. 单任务耗时做一盘菜的时间
    t单t_{\text{单}}
  3. 串行总时长一个厨师挨个做 N 盘菜的用时
    N×t单N \times t_{\text{单}}
  4. 并行总时长p 位厨师同时开工的理想用时
    Np×t单\frac{N}{p} \times t_{\text{单}}
  5. 理想加速比串行时间除以并行时间
    Np\frac{N}{p}

apply 灵活调用:单点定制菜

不是所有客人都点套餐。有人单独点一碗“不要香菜不要葱的牛肉面”,这种单笔定制订单,适合用 apply。老板把这张特殊单子交给后厨,然后站在窗口前等——这段等待就是阻塞:apply(func, args) 提交后,主进程会一直等到结果出锅才继续干别的事。

如果老板不想干等,可以用 apply_async(func, args) 下单,拿到一个“取餐号”(AsyncResult 对象)。你可以先去招呼别的客人,等有空了再凭号取餐:调用 .get() 会阻塞到结果真正就绪;或者干脆留下电话号码——在参数里传 callback 函数,结果一炒好,子进程立刻通过回调通知主进程,主进程不用盯着窗口。

多个 apply_async 同时发出去时,池子会自动把单子分给空闲厨师,实现“多发单、齐开工”。这和 apply 逐个排队等结果形成鲜明对比:同步是“一手交钱一手交货”,异步是“先下单、做完了叫我”。

一句话总结:apply 是“点菜—等菜—吃菜”的餐馆体验,apply_async 是“点外卖—干别的—被叫号”的现代生活。异步的关键收益,是把你等菜的时间也利用起来。

T异步≈∑i=1ktip+T汇总\displaystyle T_{\text{异步}} \approx \frac{\sum_{i=1}^{k} t_i}{p} + T_{\text{汇总}}
把 k 个任务的耗时加总后均分给 p 个进程,再补上最后收集结果、触发回调的收尾时间,就是异步并发的大致总时长。
  1. 第 i 个任务耗时每一道定制菜的烹饪用时
    tit_i
  2. 总工作量k 道菜的全部工时,把所有 t_i 加起来
    ∑i=1kti\sum_{i=1}^{k} t_i
  3. 并行均摊p 位厨师同时开工,理想情况下总工时除以 p
    ∑i=1ktip\frac{\sum_{i=1}^{k} t_i}{p}
  4. 汇总开销最后收齐 k 个结果、依次触发回调的固定成本
    T汇总T_{\text{汇总}}

进程间通信:传菜窗口与专用滑道

后厨和前台之间怎么配合?最土的办法是前台亲自跑到后厨喊一嗓子,但人多时必然撞车。餐厅标配是传菜窗口:两边都能往里放、往外取,系统保证不会两份菜互相挤洒——这就是 Queue(队列)。Queue 是进程安全的 FIFO,q.put() 放任务、q.get() 取任务,底层用管道加锁实现,天然支持多生产者、多消费者。

另一种场景:后厨和某个特定档口要高频传菜,专门拉一条滑道,两点直连,速度更快——这就是 Pipe(管道)。Pipe() 返回连接两端的 conn1、conn2,可以单向也可双向传。但它只有两个端点,多个进程抢一根管子时,必须自己加锁,否则数据就穿乱了。

最经典的组合是“生产者—消费者”模型:一个(或几个)进程专职产数据,几个进程从 Queue 里取数据加工,队列充当缓冲,消化掉双方的速度差。速度不匹配会怎样?看下面的公式——整个系统的吞吐,不取决于最快的一环,而取决于最慢的一环。

所以调优时别只盯着生产者死命加速:如果消费者是瓶颈,把菜做得再快也只是让窗口堆成山。找到最慢的一环,才是传菜优化的正确姿势。

T流水线=max⁡(T生产者,T消费者)\displaystyle T_{\text{流水线}} = \max(T_{\text{生产者}}, T_{\text{消费者}})
队列吞吐量由生产速度和消费速度中较慢的那个决定:出菜再快,前台收不过来,窗口照样堵。
  1. 生产者耗时后厨把所有菜做出来的总时间
    T生产者T_{\text{生产者}}
  2. 消费者耗时前台把所有菜端上桌的总时间
    T消费者T_{\text{消费者}}
  3. 速度差快的一方与慢的一方的时间差,决定队列积压程度
    ∣T生产者−T消费者∣|T_{\text{生产者}} - T_{\text{消费者}}|
  4. 整线吞吐整条流水线的实际时间取两者最大值
    max⁡(T生产者,T消费者)\max(T_{\text{生产者}}, T_{\text{消费者}})

共享内存方案:一块共用的白板

传菜窗口每次传“大件货”都很费劲——几 GB 的数组要是走 Queue,得先格式化(pickle 序列化)、搬运到窗口、再拆开还原,来回好几趟。后厨的办法是:在墙上挂一块大白板,所有厨师抬眼就能看到今日材料存量,不用人手一份复印。这就是共享内存。

multiprocessing 提供两个基础构件:Value(类型码,初始值) 共享一个标量(一个数字、一个布尔),Array(类型码,初始数组) 共享一块定长数组。它们存放在所有进程都能直接访问的同一块内存里,读写走的是内存地址,而不是序列化拷贝。代价是“规矩”:任何“读—改—写”操作都要用 Lock 包起来,不然两个厨师同时往白板上加数字,数据就写花了——这叫数据竞争。

适用边界要拎清:共享内存适合数值型、定长的数据(计数器、像素数组、特征向量);复杂的嵌套对象(字典套列表)不适合,暴力塞进 Array 只会让你怀疑人生,这时候还是老老实实走 Queue。

白板虽然省纸,但所有人都得守规矩:先举手(拿 Lock),再改写,最后放下手。丢掉锁的共享内存,比复制方案更容易把人坑进数据混乱的泥潭。

M节省=(p−1)×M数据\displaystyle M_{\text{节省}} = (p - 1) \times M_{\text{数据}}
一份数据若每进程复制一份要占 p 份内存;共享方案只留一份,省下 (p-1) 份——进程越多,省得越多。
  1. 单份数据大小一份数据占用的内存字节数
    M数据M_{\text{数据}}
  2. 进程数池里的进程个数
    pp
  3. 复制方案占用每个进程各持一份拷贝的总占用
    p×M数据p \times M_{\text{数据}}
  4. 共享方案占用白板只写一份,大家共看
    M数据M_{\text{数据}}
  5. 节省量复制方案减去共享方案,得到省掉的内存
    (p−1)×M数据(p - 1) \times M_{\text{数据}}

进程数调优:雇几位厨师最划算

后厨面积有限,灶台数等于 CPU 核心数。你雇 4 位厨师,每个占一个灶,刚刚好;雇 16 位,灶不够用,大家挤来挤去,光让位子就耗掉一半精力——这就是进程数超过核心数后的“上下文切换”惩罚。反过来雇 1 位,订单又积压。多少合适?看任务类型。

如果任务全是纯计算(素数、矩阵乘),一位厨师从头到尾占着一个灶,进程数取核心数最划算。如果任务经常等外部(读文件、等网络响应),厨师在等的时候灶台空着,就可以多雇几个“轮流等”——进程数取核心数乘上一个系数,系数由“等待时间 / 计算时间”决定。

但理论归理论,每台机器、每类任务的开销都不一样。最务实的做法是实测:进程数取 1、2、4、8、16 各跑一轮,画一条“进程数—耗时”曲线,找到谷底。另外别忘了算内存账:每个 Python 进程自带几十 MB 常驻内存,进程开多了,内存账单先爆。

开店的智慧是“先试营业再定编制”。别迷信某个固定值,让数据告诉你答案——跑一组进程数扫描,耗时曲线的最低点,就是你的最优班底规模。

p最优={ncore,CPU 密集ncore×(1+r),IO 密集\displaystyle p_{\text{最优}} = \begin{cases} n_{\text{core}}, & \text{CPU 密集} \\ n_{\text{core}} \times (1 + r), & \text{IO 密集} \end{cases}
一个粗略公式:CPU 密集取核心数;IO 密集乘以 1+等待比例。最终数字要用实测曲线校准。
  1. 物理核心数机器 CPU 有多少个物理核,等于灶台数
    ncoren_{\text{core}}
  2. CPU 密集任务在不停计算、占满核心
    p=ncorep = n_{\text{core}}
  3. IO 等待比例等待时间与计算时间的比值
    r=t等t算r = \frac{t_{\text{等}}}{t_{\text{算}}}
  4. IO 密集调参等待占比越高,越可以多雇厨师轮流等
    p=ncore×(1+r)p = n_{\text{core}} \times (1 + r)

踩坑避雷:后厨翻车现场

后厨经营久了,总有几个经典翻车瞬间。第一次翻车:主厨把菜谱写得“只可意会不可言传”——你在代码里写了个 lambda 塞给 Pool,子进程一脸懵:这菜谱没法抄送(pickle 序列化失败)。因为进程间传函数靠 pickle 按名字导入,lambda 没有名字,传不过去。解法是把逻辑写成模块级别的 def 函数。

第二次翻车:厨师下班后赖在休息室不走。进程池里的子进程不会因为“没活干”就自动消失,你不 close() 关掉接单窗口、不 join() 等人走完,它们就成了僵尸进程,占着进程表不撒手。最省心的是用 with Pool() as pool 上下文管理器,块结束时自动收尾。

第三次翻车:全员僵在原地互相等。经典死锁是“队列没排空就 join”——子进程还在等队列里的任务,主进程却开始等子进程结束,两边干瞪眼。用 with 语句、先 close 再 join、确保队列任务能被消费完,才能避免这种互相等待。最后提醒:进程是有内存成本的,开池前算算内存账单。

打烊前要做三件事:清点厨师(join 回收)、关火关气(close 关池)、核对账单(留意内存)。后厨管理做扎实,流水线才跑得长久。

M峰值≈p×M基进程+M任务\displaystyle M_{\text{峰值}} \approx p \times M_{\text{基进程}} + M_{\text{任务}}
峰值内存约等于“每个进程的常驻内存 × 进程数”再加上在途任务数据——进程开太多,内存先撑不住。
  1. 单进程基础内存每个 Python 进程自带解释器、栈等常驻内存
    M基进程M_{\text{基进程}}
  2. 进程数池里同时存活的进程个数
    pp
  3. 基础内存总和所有厨师“行头”的总开销
    p×M基进程p \times M_{\text{基进程}}
  4. 任务数据排队中、执行中的数据占用的内存
    M任务M_{\text{任务}}
  5. 峰值估算两者相加,就是进程池的内存账单
    M峰值≈p×M基进程+M任务M_{\text{峰值}} \approx p \times M_{\text{基进程}} + M_{\text{任务}}

AI 应用场景

海量日志的特征提取

阶段:数据准备 / 特征工程

解决的问题:单进程解析千万行日志要数小时,特征工程成了训练流水线的瓶颈。进程池把日志分片交给多个 CPU 核并行解析,几百 MB 文本的 ETL 时间从小时级压到分钟级。

具体案例:某推荐团队每天处理 2000 万条用户行为日志,用 Pool.map 把按行切分的日志块分给 8 个进程,每进程解析出会话时长、点击率等特征,结果按行序合并成特征 DataFrame 落地。这就像把一车皮蔬菜分给 8 位洗菜工,各洗各的,最后按编号归堆。

模型超参数网格搜索

阶段:模型调优

解决的问题:每个超参组合训练一次要跑几分钟,串行试 100 组要几小时。进程池并行训练多组,配合回调记录分数,相当于同时开几口锅试菜,大大缩短调优周期。

具体案例:算法团队用 apply_async 一次性提交 64 组 (learning_rate, batch_size) 组合,每完成一组回调就把验证分数写进结果队列,主进程汇总后画学习曲线,快速锁定最优区间。

图像数据集的批量预处理

阶段:数据管线构建

解决的问题:训练前要对十万张图片做缩放、归一化、增强,单核处理又慢又费内存;每张图都走 Queue 又太慢。共享内存 Array 存像素,池里进程各自处理分块,兼顾速度和内存。

具体案例:CV 团队把十万元图先读成一维像素数组放进 Array,启动 8 进程并行做 resize 和标准化,处理完回调把结果写回新 Array,内存只占一份图集的容量——正如大家共用一块白板,不用人手一份复印件。

Python 演示

python65 行
import timefrom multiprocessing import Pool, Queue, Process, cpu_count  def is_prime(n):    """朴素素数判断, CPU 密集型小任务。"""    if n < 2:        return False    for i in range(2, int(n ** 0.5) + 1):        if n % i == 0:            return False    return True  def primes_in_range(start, end):    """统计半开区间 [start, end) 内素数个数。"""    return sum(1 for x in range(start, end) if is_prime(x))  def worker_sum(q_in, q_out):    """Queue 消费者: 从 q_in 收数字并累加, 结果放进 q_out。"""    total = 0    while True:        num = q_in.get()        if num is None:            q_out.put(total)            break        total += num  def main():    parts = min(cpu_count() or 2, 8)    limit = 400000    step = limit // parts    jobs = [(i * step, (i + 1) * step) for i in range(parts)]     t0 = time.time()    with Pool(parts) as pool:        counts = pool.starmap(primes_in_range, jobs)    print("进程池统计素数:", sum(counts), "耗时 %.3f s" % (time.time() - t0))     t0 = time.time()    serial = sum(primes_in_range(s, e) for s, e in jobs)    print("串行统计素数:", serial, "耗时 %.3f s" % (time.time() - t0))     q_in = Queue()    q_out = Queue()    procs = [Process(target=worker_sum, args=(q_in, q_out))             for _ in range(parts)]    for p in procs:        p.start()    for i in range(1, parts + 1):        q_in.put(i)    for _ in procs:        q_in.put(None)    result = 0    for _ in procs:        result += q_out.get()    for p in procs:        p.join()    print("Queue 通信求和:", result)  if __name__ == "__main__":    main()
目的:这个脚本演示了两件核心事——用进程池并行统计素数(对比串行耗时,直观看到多核加速),再用手动进程 + Queue 演示进程间通信的“传菜窗口”。
  1. with Pool(parts) as pool:创建进程池并自动清理,等价于“招厨师且打烊自动收尾”,避免僵尸进程。
  2. pool.starmap(primes_in_range, jobs)批量派单:把每个 (start, end) 元组拆开传给函数,像把 100 张宫保鸡丁单子一次塞进传菜口。
  3. Process(target=worker_sum, args=(q_in, q_out))手动创建子进程并接上传菜窗口,演示 Pool 之外的底层玩法。
  4. q_in.put(None)放入“结束信号”,让每个 worker 在队列里读到 None 后主动下班,这是优雅停止消费者的惯用技巧。
  5. p.join()等子进程回收,防止厨师赖在休息室变成僵尸进程。
  6. if __name__ == "__main__":多进程入口保护:Windows 上 fork 子进程会重新导入本模块,没有这层保护会无限递归创建进程。

常见误区

误区把任何任务都丢进进程池,以为开得进程多就万事大吉,结果跑 IO 密集任务时发现根本没变快,有时甚至更慢。

正解进程池的加速原理是让多个 CPU 核心同时算。如果任务大部分时间在等网络、读磁盘,CPU 本来就在摸鱼,多开进程也救不了——此时应该考虑线程或 asyncio,开进程反而要额外付创建和序列化成本。动手前先判断任务是“算得多”还是“等得多”。

为何常见:教程示例全是素数、排序这类 CPU 密集任务,容易让人形成“多进程=万能加速器”的错觉,忽略了任务类型这个前提。

误区16 核的机器就把 processes=64,甚至直接等于任务数,结果耗时不降反升,内存还爆了。

正解进程数超过核心数后,操作系统要在进程间来回切换(上下文切换),调度开销吃掉并行收益。每个 Python 进程还自带解释器和内存副本,进程越多内存账单越厚。最优值不是拍脑袋,用 1、2、4、8、16 跑一轮画耗时曲线,选谷底。

为何常见:直觉上“人多力量大”,忽略了灶台有限和让位成本,忘了并发不是免费的。

误区把几 GB 的数组丢进 Queue 传给子进程,以为和平时调用函数一样是“引用传递”,传完还能顺手改原数组。

正解put/get 底层要做 pickle 序列化和内存拷贝,等于把数据搬进搬出好几趟,且传过去的是副本。大数组共享应该用 multiprocessing.Array 等共享内存方案,只存一份、直接读写;Queue 适合小消息、任务指令这类“轻便传菜”。

为何常见:调用函数时参数本来就是引用传递,看不到序列化过程,于是想当然地认为进程间传参也一样。

误区在模块里写了个全局字典,子进程往里加数据,主进程以为能直接读到,结果读到的永远是最初的空字典。

正解fork 时子进程拿到的是主进程内存的“照片”(写时复制),之后各写各的副本,互不可见。想共享数据必须显式用 Queue / Pipe / 共享内存,Python 多进程里没有“隐式共享”这回事。

为何常见:单进程编程里全局变量随手可用,惯性思维带到了多进程,忘了“复制”这个关键动作。

误区脚本跑完就结束了,不 close 不 join,进程反正也会跟着退出,所以每次都懒得管。

正解池里的子进程不会自动退出,不回收就成了僵尸进程,占着进程表条目不撒手,积累多了系统会卡顿甚至无法新建进程。最省心的写法是 with Pool() as pool 上下文管理器,或手动 close() + join() 成对出现,雷打不动。

为何常见:小脚本跑完操作系统会兜底回收,掩盖了问题;换成常驻服务或批量任务脚本反复执行时,僵尸进程就会越积越多。

面试题

Q1. pool.map(func, data) 返回的结果顺序是怎样的?

单选题 · easy

  • A.与传入数据的顺序保持一致← 正确答案
  • B.按任务完成的先后顺序返回
  • C.随机返回没有任何固定顺序
  • D.按进程编号的大小顺序返回

答案:A。map 内部先把所有子任务派发出去,等全部完成后按输入序号组装结果,保证结果列表第 i 位对应输入第 i 个元素。就像餐厅出菜口按点单号摆盘,厨师炒菜顺序再乱,端出来的顺序也和点单一致。B 是 apply_async + get 的乱序特性,C、D 都是对 map 保序机制的误解。

Q2. apply 与 apply_async 的主要区别是什么?

单选题 · mid

  • A.apply 阻塞而 async 立即返回← 正确答案
  • B.apply 并行而 async 串行
  • C.apply 只处理批量任务
  • D.async 不支持回调函数

答案:A。apply 是同步阻塞,提交后主进程一直等到结果返回才继续往下走,等于客人站在灶台前等菜;apply_async 提交后立即返回 AsyncResult 对象,主进程可以先干别的,之后用 .get() 或回调拿结果。两者都能传入回调,也能并行执行,B、C、D 说法都不成立。

Q3. 下列哪些方式可以把任务提交给进程池?

多选题 · mid

  • A.用 pool.map 提交批量任务← 正确答案
  • B.用 pool.apply_async 提交← 正确答案
  • C.用 pool.starmap 提交多参← 正确答案
  • D.用 pool.terminate 销毁池

答案:ABC。map 批量映射单参数任务,starmap 支持把参数元组打散传入,apply_async 提交单个任务并可带回调,三者都能把任务交进池子。terminate 是暴力终止进程池的方法,作用是立即销毁所有 worker,而不是提交任务的方式。

Q4. 为什么多进程能绕开 GIL 的限制?

单选题 · mid

  • A.每个进程有独立解释器← 正确答案
  • B.GIL 只存在于 Windows
  • C.多进程共享一套解释器
  • D.进程池会自动释放 GIL

答案:A。GIL 是 CPython 解释器层面的全局锁,保证同一时刻只有一个线程执行字节码。多进程 fork 出的是多个独立解释器,每个进程各有一把 GIL,互不挤占,所以能真正同时跑在多个 CPU 核心上。B 说 GIL 只在 Windows 存在是错的,C、D 都违背多进程内存与解释器隔离的原则。

Q5. 有一个超大数组需要共享给所有进程频繁读写,哪种方案最合适?

单选题 · hard

  • A.使用 Array 共享内存方案← 正确答案
  • B.使用 Queue 逐元素传递
  • C.使用全局列表直接赋值
  • D.使用多个文件保存数据

答案:A。大数组走 Queue 要反复 pickle、复制、拷贝,开销巨大;全局列表在 fork 后每个进程各持一份副本,改动互不可见;文件方案速度太慢。Array 把数据放在共享内存里,所有进程直接读写同一块内存,只维护一份数据,速度最快、内存最省——正如大家共看一块白板。

Q6. 关于进程池进程数的确定,下列说法合理的是?

多选题 · mid

  • A.CPU 密集任务取核心数← 正确答案
  • B.IO 密集任务适当增加进程← 正确答案
  • C.进程数越多性能必然越高
  • D.用实测曲线选定合理数值← 正确答案

答案:ABD。CPU 密集任务每个进程占满一个核心,进程数约等于核心数收益最大;IO 密集任务进程经常在等待,适当多开能提高利用率;进程数超过核心数后上下文切换、内存翻倍会让耗时回升,所以 C 的“越多越快”不成立。最可靠的办法是跑一组进程数扫描,用实测耗时曲线选谷底。

Q7. Queue 与 Pipe 在进程通信上的区别是什么?

单选题 · easy

  • A.Queue 支持多对多安全通信← 正确答案
  • B.Pipe 能连接任意多个进程
  • C.Queue 无法保证数据安全
  • D.Pipe 一定比 Queue 慢很多

答案:A。Queue 内部用管道加锁实现,天然支持多生产者、多消费者安全读写,适合“传菜窗口”式协作;Pipe 只返回一根管的两端,只能两点直连,多个进程抢一根管需要自己加锁。B 说 Pipe 支持任意多进程是错的,C 与事实相反,D 说 Pipe 一定更慢也没有依据。

Q8. 用 Pool 时出现死锁,最可能的成因是什么?

单选题 · extreme

  • A.join 前任务队列尚未排空← 正确答案
  • B.进程数量设置得偏少
  • C.CPU 核心数检测不准确
  • D.任务函数参数类型错误

答案:A。经典死锁场景是 join 时队列里还有任务没排空——子进程等着从队列取任务,父进程却已经开始 join 等待子进程结束,双方互等,谁也无法推进。用 with 语句、先 close 再 join、并确保队列任务能被消费完,才能避免这种僵局。进程数少、核心数检测不准、参数写错都不会造成互相等待的死锁。

Q9. 下列哪些属于正确的进程池清理操作?

多选题 · easy

  • A.用 with Pool() 自动清理← 正确答案
  • B.任务结束后调用 close← 正确答案
  • C.再调用 join 等待回收← 正确答案
  • D.完事不清理直接退出

答案:ABC。with Pool() as pool 是官方推荐写法,代码块结束自动 close + join;手动写法是任务提交完后 close() 关闭接单窗口,再 join() 等所有厨师撤场,两者缺一不可。D 不清理直接退出会留下僵尸进程,长期跑任务的脚本会越积越多,拖垮系统。

Q10. 为什么 lambda 函数不能直接提交给进程池?

单选题 · mid

  • A.lambda 无法完成 pickle 序列化← 正确答案
  • B.lambda 函数执行速度较慢
  • C.lambda 不支持数值运算
  • D.lambda 只能用于主进程

答案:A。子进程与主进程之间传递函数靠 pickle 序列化,pickle 需要按模块路径找到这个函数的名字。lambda 是匿名的,没有名字可供导入,所以一传就炸。解决方法很简单:把逻辑写成模块级别的 def 函数。执行速度、数值运算能力、使用位置都不是根因。