码农高天
频繁跨进程通信带来的序列化与管道阻塞吞噬多核红利,开发者须借助Batch分块机制与Pool.map平衡通信成本与工作负载。
AI-assisted summary of 【python】我用了多进程怎么程序反而变慢了? — original source. Check the original for full context.
Source published:
Key ideas
- 使用 Python multiprocessing 容易因频繁小数据 IPC 导致性能急剧劣化;在 10 万素数筛选基准中,朴素多进程版耗时接近 3 秒,比单进程单线程的 80 多毫秒慢了数十倍。 (0:00)
- 通过 VS Code 的 VizTracer 插件能够直观发现,朴素多进程将绝大部分时间消耗在 queue.get() 的跨进程序列化与数据传输上,实际计算时间占比极低。 (2:50)
- 将任务粗粒度平分为 4 份能将耗时压缩至 37 毫秒,但随着数字变大,素数判定计算量骤增,导致各个 worker 负载严重不均、部分核心提前空闲。 (7:35)
- multiprocessing.Pool.map 通过将任务划分为 4 倍 worker 数量的 chunk 进行动态批处理分发,在百万级数据下跑出 0.64 秒,同时兼顾了减少 IPC 与负载均衡。 (10:00)
Detailed notes
## Background 该视频由 YouTube 频道“码农高天”于 2024 年 9 月 20 日发布。视频主要探讨许多开发者在使用 Python 的 multiprocessing 多进程库后程序不仅没有变快、反而出现性能倒退的原因,并通过 VSCode 的 VizTracer 插件直观展示各进程的底层执行细节。
## TL;DR 视频以判定素数的经典 CPU 密集型任务为切入点,展示了一个朴素的 4 进程队列版本耗时从单进程的 80 毫秒劣化至近 3 秒的反直觉现象。通过性能分析工具 VizTracer 的时间轴抓取,作者指出跨进程频繁通信(IPC)引发的序列化与阻塞开销是拖慢程序的首要元凶。随后视频演示了从一刀切连续切分到引入分批(Batch)思想的逐步优化过程,解释了负载不均衡(Load Imbalance)与额外开销(Overhead)的产生机理,并剖析了 multiprocessing.Pool.map 底层如何通过动态分块优雅平衡通信开销与算力利用率。
## Atmosphere & style 整期视频兼具硬核的技术探究与幽默接地气的讲授风格,高天以提问与排错为主线,带有一种抽丝剥茧的侦探解谜感。借助 VizTracer 的火焰时间轴,抽象的多进程调度被具象化为肉眼可见的“摸鱼”与阻塞,现场感极强。讲解过程中穿插着对“大聪明领导式分工”的诙谐调侃以及自夸工具作者是“活菩萨”的自嘲彩蛋,整体节奏明快、生动且充满启发性。
## Bulletin - [00:00](https://www.youtube.com/watch?v=xFtEg_e54as&t=0s) 使用 Python multiprocessing 容易因频繁小数据 IPC 导致性能急剧劣化;在 10 万素数筛选基准中,朴素多进程版耗时接近 3 秒,比单进程单线程的 80 多毫秒慢了数十倍。 - [02:50](https://www.youtube.com/watch?v=xFtEg_e54as&t=170s) 通过 VS Code 的 VizTracer 插件能够直观发现,朴素多进程将绝大部分时间消耗在 queue.get() 的跨进程序列化与数据传输上,实际计算时间占比极低。 - [07:35](https://www.youtube.com/watch?v=xFtEg_e54as&t=455s) 将任务粗粒度平分为 4 份能将耗时压缩至 37 毫秒,但随着数字变大,素数判定计算量骤增,导致各个 worker 负载严重不均、部分核心提前空闲。 - [10:00](https://www.youtube.com/watch?v=xFtEg_e54as&t=600s) multiprocessing.Pool.map 通过将任务划分为 4 倍 worker 数量的 chunk 进行动态批处理分发,在百万级数据下跑出 0.64 秒,同时兼顾了减少 IPC 与负载均衡。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=xFtEg_e54as&t=0s) 由于 Python GIL 的限制,多线程无法加速 CPU 密集型任务,开发者必须采用多进程方案利用多核算力。 - [00:00](https://www.youtube.com/watch?v=xFtEg_e54as&t=0s) 朴素地使用 Queue 逐个传递数据会引入巨大的进程间通信(IPC)开销,导致 4 进程运行速度反倒比单进程慢几十倍。 - [02:50](https://www.youtube.com/watch?v=xFtEg_e54as&t=170s) VizTracer 工具可以将所有进程与线程的执行调用可视化在同一时间轴上,快速定位 IPC 阻塞等黑箱瓶颈。 - [02:50](https://www.youtube.com/watch?v=xFtEg_e54as&t=170s) 多进程性能优化的首要原则是尽量减少进程间的通信次数并缩减传输的数据量。 - [05:00](https://www.youtube.com/watch?v=xFtEg_e54as&t=300s) 粗粒度切分任务(如只传区间首尾参数)可使耗时从近 3 秒骤降至 37 毫秒,使 CPU 重新专注于实际运算。 - [05:00](https://www.youtube.com/watch?v=xFtEg_e54as&t=300s) 多进程引入的启动与数据打包属于 Overhead,这类开销与工作量呈亚线性增长,数据量越大时多进程加速比越显著。 - [07:35](https://www.youtube.com/watch?v=xFtEg_e54as&t=455s) 简单的连续区间切分容易陷入“假平均”,在计算复杂度不均匀的场景下会导致严重的 worker 负载倾斜与算力浪费。 - [07:35](https://www.youtube.com/watch?v=xFtEg_e54as&t=455s) 一次性切大块会导致主进程必须阻塞等待整个区间完成,无法提前流水线化处理已就绪的中间结果。 - [10:00](https://www.youtube.com/watch?v=xFtEg_e54as&t=600s) Batch(批处理)思想是在高频通信与负载不均之间取得平衡的工程关键,切分粒度依赖工程权衡。 - [10:00](https://www.youtube.com/watch?v=xFtEg_e54as&t=600s) multiprocessing.Pool.map 内部采用 Batch 切片(默认 4 倍 worker 数量)与动态领取机制,有效平衡了 IPC 次数与各核利用率。 - [12:30](https://www.youtube.com/watch?v=xFtEg_e54as&t=750s) 多进程程序调度是异步非顺序的,依靠传统日志很难理清进程间时序关系,必须借助调用链可视化工具辅助排查。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=xFtEg_e54as&t=0s) 朴素多进程反变慢:从 80 毫秒到近 3 秒
为了探讨多进程程序的性能问题,高天首先给出了一个直观的基准程序:找出 1 到 10 万之间的所有素数。代码中自己实现了一个基础的素数判定函数 `is_prime`。在单进程单线程环境下运行这段程序,总耗时大约只有 80 多毫秒。若想在不改动 `is_prime` 算法的前提下进一步加速,自然的思路便是利用多核 CPU。然而,“由于 GIL 的存在,多线程对于 CPU 瓶颈的代码来说是没有帮助的”,因此许多开发者理所当然地转向了 `multiprocessing` 多进程方案——既然单进程算得慢,开 4 个进程并行计算理应更快。
高天随即展示了一个朴素的多进程版本:主进程创建了一个 `input_queue` 和一个 `output_queue`,并启动 4 个 worker 进程。每个 worker 不断从 `input_queue` 中取出一个数字,调用 `is_prime` 进行判断,如果是素数就放入 `output_queue`;取到 `None` 时则往 `output_queue` 回传一个 `None` 并退出。主进程负责把 1 到 10 万的所有数字塞入 `input_queue`,并在末尾塞入 4 个 `None`,随后从 `output_queue` 中收取素数结果,直到集齐 4 个 `None` 结束。令人大跌眼镜的是,这个 4 进程版本耗时将近 3 秒,整整比单进程版本慢了几十倍。面对小白的困惑或经验者的猜想,高天表示“眼见为实”,决定借助性能分析工具直观查看每个进程内部的具体开销。
```mermaid flowchart LR accTitle: 朴素多进程队列通信模式 accDescr: 展示主进程通过 input queue 和 output queue 逐个向四个 worker 进程分发数据并回收素数的拓扑结构。
subgraph S1["数据生成与分发"] M["Main Process"] -->|"put(num) 逐个放入"| IQ["Input Queue"] end
subgraph S2["并发运算节点"] IQ --> W1["Worker 1"] IQ --> W2["Worker 2"] IQ --> W3["Worker 3"] IQ --> W4["Worker 4"] end
subgraph S3["结果汇总与退出"] W1 -->|"put(prime) 逐个回传"| OQ["Output Queue"] W2 -->|"put(prime) 逐个回传"| OQ W3 -->|"put(prime) 逐个回传"| OQ W4 -->|"put(prime) 逐个回传"| OQ OQ -->|"get() 阻塞拉取"| M end ```
- 基准性能(单进程单线程):寻找 1 到 100,000 的素数耗时约 80 多毫秒。 - 朴素多进程(4 worker + Queue 逐个传递):耗时接近 3 秒,性能劣化数十倍。
---
[02:50](https://www.youtube.com/watch?v=xFtEg_e54as&t=170s) VizTracer 抓出元凶:进程间通信的沉重代价
为了彻底摸清程序卡顿的真相,高天推荐了 VS Code 插件 VizTracer(“工欲善其事,必先利其器”)。在 VS Code 插件市场安装 VizTracer 插件后,在文件树中右键点击代码文件即可选择 “Trace with VizTracer”。分析完成后,当前目录会生成一个 `result.json` 文件,右键该文件选择 “View with VizTracer”,即可在一个交互式的时间轴界面中审查各个进程和线程的详细调用。
打开跟踪图后,高天首先查看了主进程(Main Process)的耗时情况。放大视图后可以清楚地看到,主进程几乎所有时间都被阻塞在 `queue.get()` 调用上,对应代码中不断从 `output_queue` 读取结果的循环。随后切换到各个 Worker 进程,情况同样令人震惊:Worker 进程的绝大部分时间也全在执行 `get()`,真正用来做素数判定的 `is_prime` 函数在时间轴上只占了极小极窄的一点点。
高天严肃地指出了问题的根源所在:“我们程序的时间都浪费在通讯上了。通讯的代价是很多刚开始接触多进程的朋友很容易忽略的,他没有意识到我把一个简单的数从一个进程传到另一个进程需要消耗多少时间。”每传递一个数字都要经历进程间对象的序列化、管道传输和反序列化,频繁小数据量传递带来的开销彻底吞噬了多核并行带来的红利。解决这一瓶颈的关键在于:尽量减少通信的次数,并尽可能减少通信的数据量。
---
[05:00](https://www.youtube.com/watch?v=xFtEg_e54as&t=300s) 粗粒度切分任务与 Overhead 的影响
既然通信次数过多是瓶颈所在,高天修改了程序设计:不再一个数一个数地放进队列,而是把 1 到 10 万的任务均分为 4 份,每个 worker 进程只接收两个参数——对应区间的“起始”与“结束”。Worker 算完自己区间内的所有素数后,打包成一个 Python `list` 一次性返回给 `output_queue`。修改后再跑程序,耗时直接从近 3 秒骤降到了 37 毫秒。
虽然 37 毫秒已经明显优于单进程的 80 多毫秒,但按理说 4 核并发在理想状态下应有近 4 倍的加速(即达到 20 多毫秒),当前版本依然存在性能折损。再次使用 VizTracer 抓取分析发现,Worker 进程里的 `is_prime` 确实已经占满了执行时间(“说明我们的每个 worker 进程都在正儿八经着工作了,没有再摸鱼了”),但在最后一个 worker 结束之后,主进程仍耗费了相当长的一段时间去接收并解析数据。
高天借此剖析了“Overhead”(额外开销)的概念:“这种由于方法改变而引入的额外的支出我们叫做 overhead。”多进程带来的进程启动与数据传输是单进程所没有的固定成本。更多时候,Overhead 与工作量的关系呈现出亚线性增长(增长速度远小于工作量本身),即工作量越大,Overhead 在整体耗时中的相对占比就越微不足道。为了验证这个规律,高天把计算上限增大了一个数量级(数据末尾加了一个 0): - 增大 10 倍数据量后,多进程版本耗时为 0.74 秒。 - 单进程版本耗时约为 2.2 秒。 此时多进程版本的耗时缩减至单进程的三分之一左右,加速比明显高于数据量较小时的表现。
---
[07:35](https://www.youtube.com/watch?v=xFtEg_e54as&t=455s) 负载不均衡:连续切分的“假平均”
虽然数据量增加后加速比有所改善,但高天继续在 VizTracer 视图中指出了另一个显著问题:4 个 Worker 进程的存活时间长短不一。Worker 1 极快结束,Worker 2、Worker 3 依次递增,Worker 4 耗时最长。“这四个 process 有人还在辛苦的工作呢,有人就已经提前下班了,这显然是对计算资源的一种浪费。”
造成这种负载不均衡(Load Imbalance)的原因在于简单的区间切分机制。在最初的版本中,任务粒度细碎,各 worker 动态抢活,工作量天然均衡,代价是通信极度频繁;而简单粗暴地将区间一刀切成 4 份后,由于数值越大、内部因子越多、循环判断所需除法次数就越多,判定一个数是否为素数的难度随着数字变大而陡增。因此看似数量平均的切片,实际计算复杂度极度倾斜。
高天调侃道,此时可能会有“大聪明”提出交错取模分流的方案:“我们不用这种连续的切割,我们来取模除以 4,余 0 的给一个进程,余 1 的给一个进程,余 2 的给一个进程,余 3 的给一个进程。”这样各进程拿到的数字大小分布均匀,高天打趣称“未来在工作中一定是一个好领导”。然而,现实中的复杂业务很难有素数这样直观的规律,拍脑门想出来的切分往往非常不均匀。此外,一刀切式的分配还导致在第一个 Worker 算完之前,主进程只能完全卡住干等,无法提前处理已经就绪的部分结果。
| 实现方案 | 运行耗时(10万数据) | 优点 | 核心缺点 / 瓶颈 | |:--- |:--- |:--- |:--- | | **单进程单线程** | 约 80 ms | 无任何跨进程通信 Overhead,代码简单 | 无法利用多核 CPU 算力 | | **朴素多进程(逐个 Queue 通信)** | 约 3000 ms (3 秒) | 动态抢活,Worker 间计算负载完全均衡 | 每次判定均触发 IPC,通信耗时占据 95% 以上 | | **一刀切连续分块(4等分区间)** | 约 37 ms | 通信次数极低,IPC 占比大幅下降 | 大数字计算成本高,Worker 负载严重不均,“提前下班” | | **内置 `multiprocessing.Pool.map`** | 约 0.64 秒(百万数据基准) | 自动分批(Batch Chunking),负载与通信两全 | 需调用高阶封装,对底层机制理解要求高 |
---
[10:00](https://www.youtube.com/watch?v=xFtEg_e54as&t=600s) Batch 思想与 `Pool.map`:工程经验的最优解
为了同时解决频繁通信与负载不均这两大矛盾,程序必须引入分批(Batch)处理机制:既不能一个数一个数地传,也不能一次性将全部任务平分为 4 大份,而是在两者之间寻找折中点。每次传输多大的 Batch 大小并没有数学定论,更多依赖工程经验。
高天指出,Python 标准库中的 `multiprocessing.Pool` 实际上已经优雅地封装了这一方案。将代码改写为调用 `multiprocessing.Pool` 并执行 `pool.map(is_prime, range(1, max_number))`,在主进程中统一汇总素数列表。在扩大 10 倍数据量(100 万)的对比测试中,该方案仅耗时 0.64 秒,击败了此前手动粗分 4 块的 0.74 秒。
在 VizTracer 中可以观察到令人满意的调度状态: 1. 每一个 Worker 进程几乎在完全相同的时间点结束运行。 2. 每个 Worker 的时间轴上几乎被 `is_prime` 完全占满,没有任何“摸鱼”空档。 3. Worker 结束之后,主进程以极快速度收尾,随后的微小 Overhead 仅是在检查关闭子进程。
高天在 VS Code 中通过双击 VizTracer 跟踪图中的 `map` 函数,直接跳转到了 Python 标准库底层的源码实现。源码显示,`map` 内部调用了 `map_async`,其核心就是 Batch 思想:它默认把任务划分为 Worker 数量 4 倍的切片(4 个 Worker 对应 16 个 Task Chunk),由各进程在执行完毕后动态领取新 Chunk;同时在 `get_tasks` 中以元组(tuple)的形式批量打包传递数据,从而在减少 IPC 次数与保持动态均衡之间达成了极佳的工程平衡。
```mermaid flowchart TD accTitle: multiprocessing.Pool.map 批量分发与调度流程 accDescr: 展示 Pool.map 如何将任务打散为 4 倍 worker 数量的 chunk,并通过 Task Queue 动态分发给 Worker 并返回结果。
subgraph SG1["任务分块 (Chunking)"] A["总任务区间: 1 到 Max Number"] --> B["map_async 计算 chunksize"] B --> C["划分为 16 个 Task Chunks (4 × Worker 数)"] end
subgraph SG2["动态任务池调度"] C --> TQ["Pool 内部任务队列 (Task Queue)"] TQ -->|"抢占式分发 Chunk"| W1["Worker 1"] TQ -->|"抢占式分发 Chunk"| W2["Worker 2"] TQ -->|"抢占式分发 Chunk"| W3["Worker 3"] TQ -->|"抢占式分发 Chunk"| W4["Worker 4"] end
subgraph SG3["结果汇总与聚合"] W1 --> RQ["MapResult 回收缓冲"] W2 --> RQ W3 --> RQ W4 --> RQ RQ --> M["Main Process: 组装最终结果"] end ```
---
[12:30](https://www.youtube.com/watch?v=xFtEg_e54as&t=750s) 可视化利器 VizTracer 与多进程心智模型
在视频尾声,高天总结了多进程编程的核心难点。多进程不同于单进程单线程的代码——单线程中一切逻辑都是顺序发生的,哪怕调试困难也可以通过打印日志(print / logging)慢慢定位。但在多进程体系下,各个进程独立调度,不同进程与线程在时间轴上的绝对对齐是极其困难的,新的子进程往往像一个完全不透明的“黑箱”。开发者仅凭肉眼或打日志,很难精确估计 IPC 通信耗时以及特定时间点各个核到底是在运算还是在等待阻塞。
“而 VizTracer 的出现解决了这个问题,他把每一个进程每一个线程都可视化在同一个时间轴上,而现在你竟然只需要装一个 VSCode extension 就可以拥有它。”高天幽默地称赞该工具的作者(实际上正是高天本人开发的开源项目)“真是一个活菩萨”,并提醒大家在编写多进程程序时要善用性能分析工具,根据 Trace 图形避开跨进程通信与负载倾斜的深坑。
## Notable quotes > [02:50](https://www.youtube.com/watch?v=xFtEg_e54as&t=170s) 通讯的代价是很多刚开始接触多进程的朋友很容易忽略的,他没有意识到我把一个简单的数从一个进程传到另一个进程需要消耗多少时间。 — 高天
> [00:00](https://www.youtube.com/watch?v=xFtEg_e54as&t=0s) 由于 GIL 的存在,多线程对于 CPU 瓶颈的代码来说是没有帮助的 — 高天
## Mentioned 高天, Python, GIL, multiprocessing, VizTracer, VS Code
## Video description 这期视频我们聊一下为什么很多人用了multiprocessing之后,程序没有变得理想中那么快,甚至还变慢了。我们会用到VSCode的viztracer插件来直观地展示各个进程到底都做了什么。
[Source](https://www.youtube.com/watch?v=xFtEg_e54as)