想象一家餐厅在午市高峰期一次性雇了十位厨师,但老板定了一条规矩:厨房里只有一口灶,同一时刻只允许一个人开火。于是十位身怀绝技的厨师排成队,轮流翻炒,其余九位只能站在旁边干瞪眼。这条“一口灶”的规矩,在 Python 的世界里就叫 GIL(全局解释器锁)。你雇再多线程也挤不进那口唯一的锅。而 multiprocessing 的思路完全不同:与其挤在同一间厨房抢一口灶,不如一口气开四间分店,每间都有自己的灶台、厨师和食材仓库。四道菜在同一秒同时下锅,这才是真正的多核并行。这篇文章会带你从前台点单到后厨出餐,一步步理解如何用进程破局、用进程池管理团队、用队列和共享内存传递食材、用锁守住秩序,以及什么时候开分店反而亏本。

岗位关联

对 AI 从业者而言,掌握 multiprocessing 等于真正唤醒机器上那些常年闲置的 CPU 核心。数据处理、特征工程、网格搜索这些“等得心慌”的 CPU 密集任务,从此可以成倍提速。

目录
  1. 可视化演示
  2. GIL限制
  3. 进程基础
  4. 进程池Pool
  5. 进程通信
  6. 共享内存
  7. 进程同步
  8. 开销与选型
  9. 实战并行
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示Python multiprocessing三大核心:进程池任务分发、Queue/Pipe进程间通信、以及锁与同步原语的时间轴调度。交互切换不同模式查看多核并行工作原理。

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

进程池任务分发 Pool主进程将任务分发给4个Worker并行执行 T_parallel = T_serial / N_cores 多进程绕开GIL实现真正多核并行

GIL限制

想象一家餐厅同时雇了十位厨师,但老板立下规矩:厨房只有一口灶,同一时刻只允许一个人开火。于是十位厨师排成队轮流炒菜,其余九位只能站在旁边鼓劲。这个“一口灶”的规矩,就是 CPython 里的 GIL(全局解释器锁)。

GIL 是 CPython 解释器自带的一把互斥锁,它保证同一时刻只有一个线程执行 Python 字节码——不管你的机器有几个 CPU 核心。对纯计算型任务来说,线程开得越多,切换开销反而越大,吞吐量不升反降。要让多核真正派上用场,就必须绕开这把锁。

绕开的办法不是“找更多厨师进同一间厨房”,而是“再多开几间厨房”——这就是多进程。每个 Python 进程都有自己独立的解释器和 GIL,互不干扰,可以同时跑在不同核心上。用 Amdahl 定律能更精确地看清收益边界:

回到厨房:一间厨房再挤进十个厨师也是轮流开火;开四间厨房,四口灶同时轰鸣,效率自然翻倍。这,就是多进程破局的第一步。

S(N)=1(1−P)+PN\displaystyle S(N)=\frac{1}{(1-P)+\frac{P}{N}}
加速比 S 表示并行后快了多少倍;P 是可并行部分占比,N 是核心数。串行比例越高,加速上限越低。
  1. 串行比例指无法并行、只能顺序执行的部分占比,比如进程创建、结果汇总。
    (1−P)\displaystyle (1-P)
  2. 并行比例指可以被多个核心同时处理的部分占比,比如大量独立计算。
    P\displaystyle P
  3. 核心数均摊并行部分时间被 N 个核心均摊后,总耗时变成串行时间加并行均摊时间。
    (1−P)+PN\displaystyle (1-P)+\frac{P}{N}
  4. 加速比定义串行耗时除以并行耗时,得到最终加速比 S。
    S(N)=1(1−P)+PN\displaystyle S(N)=\frac{1}{(1-P)+\frac{P}{N}}

进程基础

打开一间新厨房并不便宜:要租店面、买灶台、备食材、招厨师。Python 里的 Process 对象就是“开一间新厨房”的指令。每次创建进程,解释器都会准备独立的内存空间和运行时环境,成本远高于创建一个线程。

使用 Process 只需三步:定义要做的事(target 函数)、把食材清单传给新厨房(args 参数)、启动后等它收工(start() 和 join())。join 就像老板在门口等这间厨房交付成果,没交付完就不走。主进程统一管理这些分店,这在 multiprocessing 里称为父子进程关系。

还有几个细节值得注意:daemon 守护进程相当于“跟着总店下班的分店”,主进程退出它就强制关闭;进程从创建、运行到结束有完整生命周期,可以用 current_process 查看状态。生命周期公式提醒你,创建成本不可忽视:

总之,开分店成本不低,但一旦开起来,它就有自己的厨师、自己的锅,可以独立并行工作。这就是进程的基础世界观。

Tlife=Tcreate+Trun+Tjoin\displaystyle T_{\text{life}}=T_{\text{create}}+T_{\text{run}}+T_{\text{join}}
一个进程从出生到告别,总耗时等于创建、执行、汇合三部分之和。创建和汇合都是纯开销,任务太小时它们会吃掉全部收益。
  1. 创建耗时分配地址空间、初始化运行时、准备数据副本所花的时间。
    Tcreate\displaystyle T_{\text{create}}
  2. 执行耗时target 函数真正在子进程里跑业务逻辑的时间。
    Trun\displaystyle T_{\text{run}}
  3. 汇合耗时主进程调用 join 等待子进程结束、回收退出状态的时间。
    Tjoin\displaystyle T_{\text{join}}
  4. 总计公式三段相加就是进程生命的全部时长,其中第一段和第三段是不可避免的税。
    Tlife=Tcreate+Trun+Tjoin\displaystyle T_{\text{life}}=T_{\text{create}}+T_{\text{run}}+T_{\text{join}}

进程池Pool

如果每个订单都临时招一名厨师、做完就辞退,餐厅早就破产了。更聪明的做法是养一支固定团队,让他们轮流接单。Python 的 Pool 就是这支固定团队——预先把一批工作进程雇好,静静等待任务分配。

使用 pool.map(func, tasks) 会把整个任务清单分发给团队,然后阻塞等待全部结果;pool.apply_async 则像“派单后不等,自己忙别的,稍后拿回执”。Pool 一次只创建固定数量的进程,避免了反复创建和销毁的浪费,是处理大批量独立任务的首选。

还有一个贴心设计:map 返回的结果顺序和传入任务的顺序完全一致,不管团队里谁先做完,交付清单永远按原编号排列,方便与数据一一对应。用 with Pool() as p: 管理时,即使中间抛出异常,团队也会被妥善解散。整体耗时可以这样估算:

所以,与其为每个任务单独开进程,不如养一个“固定厨师团”。Pool 把雇佣成本摊薄到每个订单里,是并行编程的日常标配。

Tpool≈⌈mp⌉×ttask+toverhead\displaystyle T_{\text{pool}}\approx\left\lceil\frac{m}{p}\right\rceil\times t_{\text{task}}+t_{\text{overhead}}
有 m 个任务、池里有 p 个厨师,每轮能同时做 p 道菜,共需约 m/p 轮;再叠加固定开销,就是总时长。
  1. 任务总数待处理的最小工作单元数量,比如要计算平方和的 10 万个数字。
    m\displaystyle m
  2. 池大小同时工作的进程数量,通常对应 CPU 核心数。
    p\displaystyle p
  3. 轮次计算每轮做 p 个任务,至少需要向上取整的轮数。
    ⌈mp⌉\displaystyle \left\lceil\frac{m}{p}\right\rceil
  4. 总耗时估计轮数乘以每轮任务耗时,再加上创建池与汇总结果的固定开销。
    Tpool≈⌈mp⌉×ttask+toverhead\displaystyle T_{\text{pool}}\approx\left\lceil\frac{m}{p}\right\rceil\times t_{\text{task}}+t_{\text{overhead}}

进程通信

分店之间离得远,A 厨房做好的一道菜怎么送到 B 厨房?最常见的办法是装一部升降梯(Queue):A 把菜放进去,B 按铃取走。Queue 是进程安全的 FIFO 队列,谁先放谁先拿,自带排号机制,非常适合多人协作。

另一种是架一条窄管道(Pipe),只连通两端,速度快但只能一对一通话,就像两部对讲机。Queue 适合“多对多”或“中心调度”场景,Pipe 适合两个进程私下频繁交换数据。无论用哪种,数据都要先被 pickle 序列化——相当于把菜打包进饭盒,只有能被装进饭盒的菜(可序列化的对象)才能传送。

通信不是免费的。打包、运送、拆包都在消耗时间。如果任务的做菜时间非常短,而传菜时间很长,总效率会被通信拖垮。用通信占比可以提前评估风险:

回到厨房:菜一份份坐升降梯送到另一头,虽然比在同一口锅里直接炒慢,但跨厨房合作必须靠这座桥。通信方式选得好,分店协作才有灵魂。

η=tcommtcomm+tcalc\displaystyle \eta=\frac{t_{\text{comm}}}{t_{\text{comm}}+t_{\text{calc}}}
η 是通信时间占总时间的比例。η 越接近 1,说明大部分时间都在传菜而不是做菜,并行就失去了意义。
  1. 通信时间序列化数据、写入队列或管道、在进程间搬运的总耗时。
    tcomm\displaystyle t_{\text{comm}}
  2. 计算时间进程真正用于业务计算的耗时,比如遍历数据、执行数学运算。
    tcalc\displaystyle t_{\text{calc}}
  3. 通信占比用通信时间除以总时间,得到开销比例。
    η=tcommtcomm+tcalc\displaystyle \eta=\frac{t_{\text{comm}}}{t_{\text{comm}}+t_{\text{calc}}}

共享内存

与其每道菜都坐升降梯来回送,不如给两家分店开一扇共用的大冰箱,两位厨师都能直接伸手取食材。这就是共享内存。Python 的 Value 和 Array 就扮演这台冰箱的角色。

Value('i', 0) 开辟一个共享整数,Array('d', size) 开辟一段共享数组。它们不是普通全局变量——普通变量写在各自进程的私有内存里,改来改去互不可见;共享内存则映射到同一块物理地址,所有进程看到的是同一份数据。子进程里对 Value 的修改,父进程立刻能读到。

共享带来的风险也在这:两位厨师同时伸手拿同一颗鸡蛋,动作不是“原子”的。比如 v += 1 在底层要“读、加、写”三步,两个进程同时交叉执行,就会丢失一次更新。最终累积值会小于理论值:

大冰箱的确方便,但每个动作必须有序进行。下一节要学的“锁”,就是给这台冰箱配上的那把钥匙。

Rfinal=k×n−L\displaystyle R_{\text{final}}=k\times n-L
k 个进程各执行 n 次加 1,理论上应有 k×n 次更新;由于竞争互相覆盖,实际丢失 L 次更新,最终只有 k×n−L。
  1. 理论总量k 个进程各完成 n 次自增,理想情况下总计执行 k×n 次。
    k×n\displaystyle k\times n
  2. 丢失更新两个进程同时读到旧值,先后写回,导致其中一次写入被覆盖。
    L\displaystyle L
  3. 最终结果理论值减去丢失次数,就是共享计数器上真实可见的数值。
    Rfinal=k×n−L\displaystyle R_{\text{final}}=k\times n-L

进程同步

共用大冰箱之后,新问题来了:两位厨师同时伸手,一个拿鸡蛋、一个也拿鸡蛋,手碰手,食材也乱了。于是店规规定:谁进冰箱谁拿钥匙,别人只能在门外等。这就是 Lock。

Lock 保证同一时刻只有一个进程进入“临界区”,比如把共享计数器的读、加、写三步整体保护起来。Semaphore 是升级版——仓库有 N 把钥匙,允许最多 N 个人同时进入。Event 则像一面“汤好了”的旗子,别的进程看到旗子才继续行动,适合做简单的发布订阅通知。

锁也有翻车的时候:A 厨师握着鸡蛋等酱油,B 厨师握着酱油等鸡蛋,两人互不撒手,活活僵住。这就是死锁。死锁的本质是“等待关系形成闭环”:

破局的店规是:所有人按同样顺序拿食材,或者拿不到就暂时放手(超时机制)。锁本身不产生价值,规范地使用锁,共享盛宴才能持续。

deadlock  ⟺  ∃ (p1→p2→⋯→pk→p1)\displaystyle \text{deadlock}\iff\exists\ (p_1\to p_2\to\cdots\to p_k\to p_1)
当进程 p₁ 等待 p₂ 持有的资源、p₂ 等待 p₃ 持有的资源……最后 pₖ 又等待 p₁ 持有的资源时,环就形成了,谁也等不到谁。
  1. 等待关系pᵢ 需要某个资源,而这个资源正被 pⱼ 持有,记作 pᵢ→pⱼ。
    pi→pj\displaystyle p_i\to p_j
  2. 环形等待把多个等待关系串起来,首尾相接形成闭环。
    p1→p2→⋯→pk→p1\displaystyle p_1\to p_2\to\cdots\to p_k\to p_1
  3. 死锁判据存在这样的环,就意味着系统无法继续推进,判定为死锁。
    deadlock  ⟺  ∃ (p1→p2→⋯→pk→p1)\displaystyle \text{deadlock}\iff\exists\ (p_1\to p_2\to\cdots\to p_k\to p_1)

开销与选型

开分店要砸真金白银:选址、装修、购灶、招人,每一件都是成本。多进程同样如此——创建进程要准备独立地址空间,进程间通信要序列化,操作系统还要调度这些“新店铺”。对小订单来说,开店的成本比做菜本身还贵。

所以多进程不是万灵药。CPU 密集型任务(数学计算、数据清洗、模型推理中的矩阵运算)适合多进程,因为计算时间远大于通信时间;而 I/O 密集型任务(读写文件、网络请求)用线程和 asyncio 更轻快,因为等待 I/O 时并不占用 GIL。任务还要尽量独立,不要频繁互相传菜。

量化收益时,不能只看“加速了多少倍”,还要看“每个核心有没有白干”。定义并行效率 E=S/N:即使加速比 S 随核心数 N 上升,效率通常也会下降,因为串行部分和通信开销被放大了:

这就解释了为什么“进程越多越好”是错觉。老板开店前先算账:这道菜到底要不要开新厨房?算清楚这笔账,多进程才能真正成为你工具箱里趁手的那把刀。

E=1N(1−P)+P\displaystyle E=\frac{1}{N(1-P)+P}
效率 E 表示每个核心平均贡献了多少加速。核心数 N 越大,分母里 N(1−P) 越大,效率越低——这就是“人多会互相拖累”的数学表达。
  1. 加速比公式沿用 Amdahl 定律,先写出加速比 S 的表达式。
    S(N)=1(1−P)+PN\displaystyle S(N)=\frac{1}{(1-P)+\frac{P}{N}}
  2. 效率定义加速比除以核心数,得到每核心的平均收益。
    E=S(N)N\displaystyle E=\frac{S(N)}{N}
  3. 化简结果代入 S 后化简,得到只含 N 和 P 的效率公式。
    E=1N(1−P)+P\displaystyle E=\frac{1}{N(1-P)+P}

实战并行

把前面所有的厨房智慧汇总:假设你接到一份超大订单——一万道菜。你不会让一位厨师从头炒到尾,而是切成十份,交给四间厨房同时开工,再用一辆送餐车统一汇总。这就是数据并行的模型。

在 AI 工程里,这类场景比比皆是:对百万行日志做特征统计、对图像数据集做旋转裁剪增强、在网格搜索里并行评估多组超参数。它们都是“同一处理逻辑作用于不同数据块”的 CPU 密集任务,正是 Pool 的天然主场。

实战中还要注意三件事:用 Queue 把每个分店的进度回传给总店;用 Lock 保护进度计数器,避免多个进程同时 print 刷屏;用 pool.imap 流式获取结果,边算边收;始终把入口写在 if __name__ == "__main__" 里,防止 Windows 下 spawn 模式无限递归创建进程。总耗时可以这样预估:

至此,你的“连锁餐厅”已经成型:GIL 的紧箍咒被甩开,进程各自为战,通信和锁守护着共享资源,成本账也一笔笔算清。面对 CPU 密集任务,你已经知道如何让每个核心都真正忙起来。

Twall=⌈mp⌉×tcalc+m×tcomm\displaystyle T_{\text{wall}}=\left\lceil\frac{m}{p}\right\rceil\times t_{\text{calc}}+m\times t_{\text{comm}}
墙钟时间等于“轮数×每轮计算耗时”加上“任务数×单次通信耗时”。通信次数越多,总耗时越容易被传菜拖慢。
  1. 计算轮数p 个进程一起做 m 个任务,需要的轮次是向上取整。
    ⌈mp⌉\displaystyle \left\lceil\frac{m}{p}\right\rceil
  2. 计算总耗时轮数乘以每个任务的平均计算时间。
    ⌈mp⌉×tcalc\displaystyle \left\lceil\frac{m}{p}\right\rceil\times t_{\text{calc}}
  3. 通信总耗时每个任务都要至少一次数据往返,累计 m 次通信。
    m×tcomm\displaystyle m\times t_{\text{comm}}
  4. 墙钟时间计算与通信两部分相加,就是现实世界真正流逝的时间。
    Twall=⌈mp⌉×tcalc+m×tcomm\displaystyle T_{\text{wall}}=\left\lceil\frac{m}{p}\right\rceil\times t_{\text{calc}}+m\times t_{\text{comm}}

AI 应用场景

海量特征工程的并行清洗

阶段:数据预处理 / 特征工程

解决的问题:逐条 for 循环计算窗口统计量、滑动均值、分位数的速度极慢,单核跑几千万条记录要数小时。用 Pool.map 把数据按行分块,让多个进程同时计算,再合并特征列,能从小时级降到分钟级。

具体案例:某风控团队对 5000 万条交易记录计算 30 日滑动均值与方差。原始脚本单进程跑 42 分钟,改为 8 进程 Pool 分块处理后缩短到 11 分钟,特征上线节奏明显加快。

训练前的数据增强加速

阶段:数据管线 / 模型训练准备

解决的问题:图像旋转、裁剪、加噪等增强操作是 CPU 密集的,如果串行生成增强样本,GPU 会因“喂不饱”而空转。用进程池并行生成增强样本,再通过 Queue 源源不断发送给训练队列,让数据供给与 GPU 消耗保持平衡。

具体案例:图像分类项目用 8 进程增强流水线后,每秒生成样本数提升近 3 倍,GPU 利用率从 52% 升到 91%,训练吞吐明显改善。

超参数网格搜索并行评估

阶段:模型调优

解决的问题:每组超参数的训练与评估相互独立,天然适合多进程。串行跑 27 组实验要一夜,用 Pool.map 同时分派到多核,能把结果收集与比较集中到一处,快速锁定最优组合。

具体案例:随机森林调参实验中,27 组参数组合串行耗时 5.2 小时;改用 12 进程并行评估后压缩到 41 分钟,且结果完全一致。

Python 演示

python43 行
import osimport timefrom multiprocessing import Pool, Process, Queue  def heavy_task(n):    total = 0    for i in range(1, n + 1):        total += i * i    return total  def worker_with_queue(q, n):    q.put(heavy_task(n))  def pool_demo(nums):    with Pool() as pool:        results = pool.map(heavy_task, nums)    return results  if __name__ == "__main__":    nums = [300000, 400000, 500000, 600000]    start = time.perf_counter()    seq = [heavy_task(n) for n in nums]    seq_time = time.perf_counter() - start    print("串行结果:", seq, "耗时", round(seq_time, 3), "秒")     start = time.perf_counter()    par = pool_demo(nums)    par_time = time.perf_counter() - start    print("并行结果:", par, "耗时", round(par_time, 3), "秒")     q = Queue()    p1 = Process(target=worker_with_queue, args=(q, 200000))    p2 = Process(target=worker_with_queue, args=(q, 300000))    p1.start()    p2.start()    gathered = [q.get(), q.get()]    p1.join()    p2.join()    print("Queue 汇总:", gathered, "进程数:", os.cpu_count())
目的:对比 CPU 密集任务在串行、进程池 Pool、手工 Process + Queue 三种模式下的执行效果,直观感受多进程并行带来的加速。
  1. seq = [heavy_task(n) for n in nums]串行逐个计算,作为耗时的基准线。
  2. with Pool() as pool:上下文管理器创建进程池,退出时自动回收,不需要手写 close/join。
  3. pool.map(heavy_task, nums)把 nums 中每个 n 分配给池内进程并行计算,返回顺序与输入一致。
  4. q.put(heavy_task(n))子进程把计算结果放进队列,跨进程传回主进程。
  5. Process(target=worker_with_queue, args=(q, 200000))手工创建一个子进程,并传入队列和参数。
  6. p1.start()启动子进程,操作系统开始调度它执行。
  7. gathered = [q.get(), q.get()]从队列中取出两个结果;注意顺序不一定与启动顺序一致。
  8. p1.join()等待子进程结束,避免主进程抢先退出造成资源泄漏。
  9. os.cpu_count()返回逻辑核心数,是决定进程池大小的常用参考。

常见误区

误区只要我把代码丢进 Pool,开了并行就肯定比原来的 for 循环快,进程数越多越快。

正解进程创建、内存复制、pickle 序列化都是真金白银的开销。如果单个任务只算了几毫秒,通信和调度时间反而占大头,整体可能比串行更慢。只有“计算时间远大于通信时间”的 CPU 密集任务,多进程才划算。

为何常见:看演示时只注意到大任务并行加速了几倍,忽略了演示里任务量足够大、通信占比足够小这个前提条件。

误区在模块里定义一个全局 dict,子进程往里写数据,主进程应该能看到更新,就像多线程那样。

正解每个进程有独立的地址空间。子进程启动时复制了父进程的数据,之后各自修改互不可见。真正要共享,必须用 multiprocessing 的 Value、Array、Queue 等机制,它们才映射到同一块物理内存。

为何常见:多线程里 globals 天然共享的印象太深,没意识到“进程隔离”是操作系统层面的硬边界。

误区8 核机器开 8 个进程能快 8 倍,那把池子开到 100 个进程岂不是更快?

正解进程数超过 CPU 核心数后,操作系统要频繁切换上下文,调度成本迅速上升;同时每个进程都要一份独立内存,内存占用成倍膨胀。合理大小通常是 os.cpu_count() 或略低于它,再大只会更慢。

为何常见:直觉是“人多力量大”,没意识到 CPU 是物理资源,超员只会排队拥挤,而不是无限加速。

误区进程间通信就是把 Python 对象直接“丢”过去,任何类型都能传,大对象也不在乎。

正解跨进程的数据必须能被 pickle 序列化。普通函数、lambda、锁等对象无法序列化;传输大型 DataFrame 或嵌套对象时,打包和解包的时间可能超过计算本身,甚至撑爆内存。

为何常见:对“进程边界”没有感知,以为通信与函数调用一样零成本,忽略了序列化这道必经工序。

误区既然有 GIL,那 Python 的多线程就是废的,所有并发需求一律改用多进程。

正解GIL 锁住的是“执行字节码”,不是“等待 I/O”。网络请求、文件读写这类 I/O 密集任务在线程阻塞时会释放 GIL,线程照样能高效并发,而且线程比进程轻得多。只有 CPU 密集任务,GIL 才是真正的瓶颈。

为何常见:记住了“GIL 存在”的结论,却忽略了 GIL 只在 CPU 计算阶段生效,把“不适合 CPU 密集”扩大成了“完全没用”。

面试题

Q1. 关于 Python 的 GIL,下列说法正确的是?

单选题 · easy

  • A.所有 Python 解释器都有 GIL
  • B.同一时刻只有一个线程可以执行 Python 字节码← 正确答案
  • C.GIL 能保证线程安全,共享数据不需要加锁
  • D.GIL 只影响多进程,不影响多线程

答案:B。CPython 的 GIL 让同一时刻只有一个线程执行字节码,所以 CPU 密集的多线程几乎无法利用多核。“所有解释器都有 GIL”是错的,PyPy 等实现不一定有;GIL 也不等于数据安全,共享数据仍要加锁;D 刚好说反,GIL 影响的正是多线程。

Q2. 对纯 CPU 密集计算,multiprocessing 比 threading 更合适,主要原因是?

单选题 · easy

  • A.多进程创建线程更快
  • B.多进程各自拥有解释器和 GIL,能真正并行使用多核← 正确答案
  • C.多进程不需要进程间通信
  • D.多线程不能用函数做任务

答案:B。每个进程有独立解释器和独立 GIL,互不争抢,可同时运行在多个核心上;线程在同一个进程内共享一把 GIL,只能交替执行。进程创建其实更慢,通信也不可避免,A 和 C 都是错的。

Q3. 使用 Pool.map(func, tasks) 得到的 results,顺序是怎样的?

单选题 · mid

  • A.按任务最早完成顺序返回
  • B.随机返回
  • C.与 tasks 的传入顺序保持一致← 正确答案
  • D.与进程编号有关

答案:C。Pool.map 虽然让多个进程并行执行,但返回列表按输入顺序排列,便于和原始数据一一对应。这与“谁先完成谁先返回”的直觉相反,是容易混淆的细节;apply_async 则要自己管理顺序。

Q4. 下列哪些是 Python multiprocessing 支持的进程间通信或共享方式?

多选题 · mid

  • A.Queue← 正确答案
  • B.Pipe← 正确答案
  • C.Value/Array← 正确答案
  • D.普通全局变量

答案:ABC。Queue 是进程安全的 FIFO 通信,Pipe 是两端直通管道,Value/Array 是共享内存,都能跨进程使用。普通全局变量在子进程里是独立副本,修改互不可见,不属于进程间通信手段。

Q5. 为什么子进程修改普通全局变量,父进程看不到变化?

单选题 · mid

  • A.因为变量被 GIL 锁住了
  • B.因为子进程复制了父进程的地址空间,各自独立← 正确答案
  • C.因为 Python 会自动延迟同步
  • D.因为普通变量不能写

答案:B。多进程拥有独立内存,创建子进程时复制父进程数据,之后各改各的互不影响,就像分店各有各的账本,不能直接看总店的账。要共享必须用 Value/Array 等共享内存机制,GIL 与普通变量可见性无关。

Q6. 为什么大量创建进程会让程序反而变慢?

单选题 · mid

  • A.进程会自动为每个变量加锁
  • B.创建进程开销大,且超过核心数后会频繁上下文切换← 正确答案
  • C.所有进程只能跑在同一个核心上
  • D.多进程无法接收返回值

答案:B。创建进程要准备独立地址空间、复制数据,还要序列化通信数据;进程数远超 CPU 核心数时,操作系统反复调度,上下文切换吃掉收益。这就是“再多开几间厨房也不会无限快”的原因。

Q7. 两个进程各持有一把锁,并等待对方释放锁才能继续,这种局面称为?

单选题 · hard

  • A.饥饿
  • B.活锁
  • C.死锁← 正确答案
  • D.数据竞争

答案:C。互等对方持有的资源,形成循环等待,就是死锁。就像两位厨师一个握鸡蛋等酱油、一个握酱油等鸡蛋,谁都不松手。饥饿是长期得不到资源,活锁是反复谦让却无进展,都不是题干描述的场景。

Q8. 适合使用 multiprocessing 的场景有哪些?

多选题 · mid

  • A.大规模 CPU 密集的数值计算← 正确答案
  • B.对图片数据集做旋转裁剪增强← 正确答案
  • C.以网络请求为主的爬虫
  • D.任务互相独立且计算量大← 正确答案

答案:ABD。数值计算、图像增强都是 CPU 密集且任务独立,适合多进程;爬虫是 I/O 密集,等待请求时线程会释放 GIL,用协程或线程更轻。D 是判断是否该用多进程的通用标准。

Q9. 某任务可并行比例 P=0.8(串行比例 0.2),核心数 N=8 时,按 Amdahl 定律最大加速比约是?

单选题 · hard

  • A.8.0
  • B.3.3← 正确答案
  • C.5.0
  • D.1.2

答案:B。S=1/((1−P)+P/N)=1/(0.2+0.8/8)=1/0.3≈3.33。即使有 8 个核心,串行的 0.2 也无法被并行摊薄,所以加速比远达不到 8。这正说明串行比例决定加速上限。

Q10. 关于 fork 和 spawn 两种进程启动方式,正确的说法是?

多选题 · extreme

  • A.fork 从父进程复制内存快照,启动更快← 正确答案
  • B.spawn 会重新导入模块,更安全但更慢← 正确答案
  • C.fork 在多线程程序里可能造成未知状态← 正确答案
  • D.Windows 默认使用 fork

答案:ABC。fork 在 Linux 上复制父进程地址空间,启动快,但若父进程是多线程,复制后的状态可能不一致;spawn 重新解释模块,安全但开销大。Windows 默认没有 fork,而是使用 spawn,所以 D 错误。