Python多进程性能反降 高天指IPC通信与负载不均吞噬算力

【python】我用了多进程怎么程序反而变慢了? — 码农高天

码农高天

频繁跨进程通信带来的序列化与管道阻塞吞噬多核红利,开发者须借助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)

中文简报

Python多进程性能反降 高天指IPC通信与负载不均吞噬算力 频繁跨进程通信带来的序列化与管道阻塞吞噬多核红利,开发者须借助Batch分块机制与Pool.map平衡通信成本与工作负载。 视频以判定素数的经典 CPU 密集型任务为切入点,展示了一个朴素的 4 进程队列版本耗时从单进程的 80 毫秒劣化至近 3 秒的反直觉现象。通过性能分析工具 VizTracer 的时间轴抓取,作者指出跨进程频繁通信(IPC)引发的序列化与阻塞开销是拖慢程序的首要元凶。随后视频演示了从一刀切连续切分到引入分批(Batch)思想的逐步优化过程,解释了负载不均衡(Load Imbalance)与额外开销(Overhead)的产生机理,并剖析了 multiprocessing.Pool.map 底层如何通过动态分块优雅平衡通信开销与算力利用率。 使用 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)

Reading summary

码农高天指出,由于 Python GIL 的存在,开发者常转用多进程加速 CPU 密集型任务,但若忽视进程间通信开销与任务切分粒度,程序性能反而可能大幅劣化甚至比单进程慢数十倍。 频繁跨进程通信吞噬并发收益 (0:00) 在 1 到 10 万的素数筛选基准测试中,单进程单线程耗时仅约 80 毫秒。然而采用朴素多进程方案时,主进程通过 input_queue 逐个向 4 个 worker 进程传递数字,worker 判定后再通过 output_queue 逐个回传结果,总耗时却激增至近 3 秒。通过 VizTracer 时间轴追踪分析发现,主进程与 worker 进程的绝大部分时间均阻塞在 queue.get() 的等待上,真正执行 is_prime 计算的时间占比极低。高天指出,跨进程传递每个对象都要经历序列化、管道传输和反序列化,频繁微小的数据传递导致通信成本彻底吞噬了多核算力,多进程优化的首要原则是减少通信次数与数据量。 粗粒度切分引发负载倾斜与通信延迟 (5:00) 为了压缩通信开销,将 1 到 10 万的区间等分为 4 份传递给 worker,并在计算完成后打包成列表一次性回传,10 万数据耗时骤降至 37 毫秒。然而该方案暴露出明显的负载不均衡(Load Imbalance):素数判定的计算难度随着数值增大而显著增加,负责大数区间的 worker 耗时最长,负责小数区间的 worker 提前结束空转。此外,一刀切分配导致主进程在 worker 完成整个区间前必须完全阻塞等待,无法提前流式处理结果。尽管扩大到 100 万数据后多进程因亚线性 Overhead 的稀释展现出加速效果(0.74 秒对比单进程的 2.2 秒),但算力空置依然制约了并行效率。 Batch 机制与 Pool.map 的平衡解法 (10:00) 工程上解决通信与均衡矛盾的核心在于分批(Batch)处理,既不单条传递也不粗暴均分。Python 标准库的 multiprocessing.Pool.map 正是该机制的实现范式。在 100 万数据测试中,Pool.map 耗时压缩至 0.64 秒,优于手动 4 分块的 0.74 秒。底层源码显示,map_async 默认将任务划分为 4 倍于 worker 数量的 chunk(4 个进程对应 16 个切片),worker 执行完当前切片后动态拉取下一批,并通过元组批量传输,在抑制 IPC 序列化次数的同时维持了进程间的动态负载均衡,各 worker 几乎同时平稳收尾。 时间轴可视化打破多进程调试黑箱 (12:30) 多进程与顺序执行的单线程不同,各子进程独立调度且缺乏透明度,仅凭代码日志难以精确定位跨进程阻塞点与算力闲置。借助 VizTracer 这类性能追踪工具,开发者可以在统一时间轴上观察主进程与各 worker 的实际调用开销,直观排查由队列争用、通信延迟或负载倾斜引起的性能倒退,从而针对性地调整任务分块策略。 编写 Python 多进程程序时,盲目使用队列分发往往因进程间通信代价适得其反;通过结合性能分析工具观察时间轴开销,并采用如 Pool.map 的批处理动态切分,是在通信成本与负载均衡之间取得最优解的关键路径。

Source quotes

[2:50] 高天: 通讯的代价是很多刚开始接触多进程的朋友很容易忽略的,他没有意识到我把一个简单的数从一个进程传到另一个进程需要消耗多少时间。 [0:00] 高天: 由于 GIL 的存在,多线程对于 CPU 瓶颈的代码来说是没有帮助的

Original transcript

[0:00] 我们今天来聊聊多进程的事儿首先呢我们看这段程序这段程序的核心很简单就是找素数我们要把1到10万之间的所有素数都找出来我们自己实现了一个非常简单的判断一个数是不是素数的函数然后我们看一下这段程序用了多长时间运行一下 这点程序一共用了80多毫秒那现在假设我想让这个程序跑得再快一点如果我们不修改S'的算法我们如何做到呢那肯定就是利用我们的多核CPU但是由于GIL的存在,多线程对于CPU平静的代码来说是没有帮助的所以很多小伙伴就想到我们用多进程来解决这个问题一个进程算的慢啊我上四个进程他不就算的快了吗于是我们把这个程序变成一个多进程的版本每一个进程跑一个worker函数这个Worker函数就不断的从这个input queue里面拿到数判断它是不是素数如果是速数的话放到AlcoQ里在input queue拿到num之后 也在output queue输出一个num 然后结束 代表自己完成了这里我们建立一个input queue 建立一个output queue然后我们开4个进程 每个进程都运行这个worker启动所有的worker把这些数都塞进input q最后往input queue里面塞4个nm然后就是从alcocular拿素数直到接收到四个难为止代表全部结束刚才单进程单线程版本用了80多毫米大家猜一下这个多进程的版本会用多长时间没错 这个多进程的版本花了将近3秒比我们单建成的版本慢了几十倍那这是为什么呢?在这里有可能你有很多多进程的经验啊有大量的猜想或者已经知道为什么了也有可能你是个多建成小白的吧,完全不知道怎么回事,甚至怀疑我程序写错了。我们经常说眼见为实我们今天就来见一见这个多进程程序到底怎么了俗话说:公欲善其事,必先利其器我们首先来安装一个工具也就是Vistracer的VSCode extension在extension这里搜索Vistracer,然后选择install安装好之后回到File Explorer,右键点击Example,然后选择Trace with Viz Tracer这里如果你当前的python环境没有装vstracer的话 它会让你先装vstracer好 那我们trace完毕了啊 trace完毕之后呢 会在你当前文件夹下产生一个result.json文件右肩这个文件选择view with visitorator我们这个trace文件比较大啊所以读取的时候快信了一下另外为了给大家做视频 我的VSCode是放大过的在你实际用的时候整个UI会更结调一些好,我们先来点开main process这是我们的主进程啊前面的部分呢由于数据太多已经被扔掉了我们就来观察一下后面我们随便zoom进来可以看到基本上所有的时间都是被get函数所占据的而这个get函数对应的就是33行这项代码我们在不断的从output queue里拿数我们再去看一下我们的worker进程可以看到绝大部分时间依然是花费在了这个get函数上而真正的is prime函数只有这么点大家看这个是1'函数我的这个zoom in 倍数太大了所以点开的时候呢就直接把上面覆盖掉了通过这个图我们就能清楚的意识到问题的所在就是我们在多进程版本的程序里面并没有把时间都花在我们想计算的e's prime上我们程序的时间都浪费在通讯上了通讯的代价是很多刚开始接触多进程的朋友很容易忽略的他没有意识到我把一个简单的数从一个进程传到另一个进程需要消耗多少时间解决这个问题最好的方式就是尽量减少通讯的次数当然如果可能的话我们也要减少通讯的数据那对于我们现在的程序,我们是一个数一个数把它们放进Q里实际上我们想一下,我们只需要把这些任务分配好 [5:00] 可以了我们可以把所有的数分成四份而我们需要传的只是每一份的开头和结尾就足够了我们根本不需要一个数一个数的把它放进q里同时对于返回值,我们也不需要一个数一个数的返回我们可以把它打包成一个list一次返回好我们就照这个输入来修改一下我们的程序我们现在这个worker每接到一个任务都会把它理解成一个起始和结束他会算出来起始到结束之间所有的速术把他们放到一个list里返回回去在下面给数据的部分和接收的部分我们也做了相应的修改那我们再来跑一下这个多进程版本大家可以看变成37毫秒了对不对如果大家还记得的话我们的单进程版本跑了80多毫秒起码我们现在多进城已经比单进城要快了那我们知道我们开了四个进程对吧在理想情况下呢这个程序应该快四倍一个80多毫秒的程序,4个进程应该能跑20多毫秒那我们这个程序还有什么问题呢同样的方式我们再去trace一下我们首先观察在worker进程里,is'确实已经占据了绝大部分的时间了这说明我们的每个worker进程都在正儿八经着工作了,没有再摸鱼了但是我们也可以看到从最后一个worker进程结束到main process结束依然有一段比较长的时间这段时间是main process正在读数据那我们知道单进程程序是不需要进程间数据传递的而我们在改为多进程之后这件事就是不可避免的这种由于方法改变而引入的额外的支出我们叫做overhead有的时候我们的overhead和我们的工作量是正比关系而更多的时候呢我们的overhead和工作量的关系呢是一个比正比小的关系也就是说工作量越大overhead的相对占比就越小你可以把overhead理解为一个准备工作那如果正式工作很多的话这个准备工作就显得微不足道但是如果正式工作很少的话这个准备工作花的时间呢就明显了为了验证这个事儿呢我们可以尝试把我们的数据增加一个数量级我们看我们把数据后面加了一个0先来跑一下多进程的版本多进程的版本用了0.74秒我们再快速的跑一下单进程版本单进程版本差不多用了2.2秒对比一下就知道在数据量多了一个零之后我们的多进程版本耗时差不多是单进程版本的三分之一明显还是比刚才数据少的时候提升更多的那三倍的速度我们就满意了吗我们还有没有更好的提升空间呢我们观察一下可以发现我们现在多进程版本的一个问题很明显的是,几个worker process 持续的时间是不一样长的。也就是说,这四个process有人还在辛苦的工作呢 有人就已经提前下班了这显然是对计算资源的一种浪费这个原因很简单在我们最开始实现的那个版本中是一个process往里面玩命塞东西我们每一个任务都非常小然后四个process抢活干干完了一个活就再去拿一个活这样我们就可以保证每一个 process 干的活是几乎一样多的当然由于活分的太碎每个process拿活返回值都费了太多的时间在现在这个版本中我们把所有的活一分为四这样你们不用抢活了专心干自己的活就好了对吧但是很显然的事是你拿到的数越小 相对来说 你去判断它是不是速数就越快于是我们自认为平均分配的任务其实是非常不平均的我们也可以从这边看到 process 1的速度明显快于2 2快于3 3快于4数越大越难算嘛对吧这个时候肯定有大聪明说了我知道一个办法我们不用这种连续的切割我们来取摩除以4于0的给一个进程,于1的给一个进程,于2的给一个进程,于3的给一个进程。这样四个进程拿到的数不就差不多大了吗哎有这个想法的小伙伴啊你未来在工作中一定是一个好领导通过这个例子是想告诉大家有的时候我们拍脑门想出来的平均分配它其实非常不平均判断素数是一个比较简单的事我们可能还有一个直观的概念但是对于其他比较复杂的任务我们可能确实不知道如何分配才更加合理而这种一刀砍似块式的分配除了不均匀之外还有其他的问题我们可以看到在第一个worker process完成之前这个主进程就只能在这等着什么也干不了而这个时候其实已经有 [10:00] 可以被传输了所以我们在这里也浪费了一些时间在大量的结果已经准备好的情况下主进程没有办法拿到它因此我们需要一个更合理的任务分配方式也就是所谓的batch我们既不想把任务直接分成四份也不想一个树一个树的船我们需要在这之间找到一个比较好的折中点说白了就是每次传多少数据合适这个事更多的就是工程经验 没有定论了但是 Python 的内置库实际上帮我们一定程度上解决了这个问题我们看我们的多进程程序其实可以写成这样我们使用自在的这个multiprocessing pool然后直接用这个pool.map对1到max number之间的数使用is'再把所有的prime number合并起来看起来它的通讯量其实更大对不对因为我们给的这个函数是is'他并没有帮我们摘掉不是素数的值我们是在主进城里自己摘的但是我们运行一下这个程序可以看到这个程序只花了0.64秒是比我们刚才写的那个多进程程序更快的我们来看一下它的trace首先可以观察到的是每个worker process花费的时间几乎是一样的我们往里看每一个worker process基本上把大部分的时间都花在了A's prime这个函数上说明他们一直在工作没有摸鱼最后在worker进程结束之后主进程也很快就结束了实际上主进程后面这部分的overhead还是在检查其他的进程有没有关好如果你的数据量更大的话这个体证会更加的明显那他究竟是如何做到的呢?肯定有一些神奇的事情发生在了这个卖盘术里我们现在对这个卖盘数感兴趣我们应该怎么办呢很简单双击这个卖盘数可以看到在左边的Editor里面我们就直接定位到了这个Map函数而这个函数第二个是 map async点进去观察一下你就会发现,他这里呢就是采用了batch的思想他把工作分成了四倍的worker数量这么多份也就是有16份工作谁先做完了就可以领新的而在这个get tasks里面是把真正的这些工作以一个tupel的形式返回回去的而不是一个一个传的因此在任务的分配上他就找到了比我们更好的平衡点也就取得了相对来说更快的速度那如果你对这一段运行感兴趣的你也可以在这个trace report 里面zoom in你就可以看到这个map函数调用了这个map asin函数然后这个map async函数呢做了一大堆事后面有大量的时间在使用这个init的弹数这个init的弹数是map result的弹数当然如果你租帽就会发现这段任务分配的算法在实际的好事中微不足道我相信只要接触过多进程编程的朋友都会发现理解每个进程什么时候在干什么是一个很困难的事情它还不像单进程单线程的程序一切都是顺序的实在不行还给打日志一点点研究在多进程中时间对齐的难度会导致程序更加难以理解很多时候一个新的进程尤其这种worker进程就像一个黑箱一样他的通讯耗时可能你很难估计那在每一个特定的时间点不同进程不同县程究竟在干什么就更是无从谈起而Vistracer的出现解决了这个问题他把每一个进程每一个线程都可视化在同一个时间轴上而现在你竟然只需要装一个VSCode extension就可以拥有它这个工具的作者真是一个活菩萨好那这就是影片的影片 喜欢的小姐点赞 关注 别忘了一键三连哦!字幕志愿者 杨茜茜