📊 第3章 分布式处理的基础知识
📄 一句话概括

一条四字段的 dict 就占 184 字节,一亿条要 17.1GB;换成紧凑结构体只要 1.5GB。 内存满了会换页——看起来没坏,就是慢,最容易误判成磁盘坏了。 8 个线程抢一把锁,墙钟 0.81 秒、CPU 时间 0;4 个进程才真能加速(1.92 倍)。

📝 本章和前面几章的关系

上一篇讲三种看得见的瓶颈。这一篇讲三种看不见的。 教材 3.2 节:上述三种瓶颈以外的与性能相关的问题——内存枯竭,以及没有瓶颈但性能仍然很差的原因。 这一节的标题很长,但它说的两件事都很具体:装不下和在排队。

一、内存是最容易被忽略的那一种

CPU、磁盘、网络都有使用率可以看,内存只有「用了多少」——看起来没满,就以为没事。 先算一笔账:一条四个字段的 dict,sys.getsizeof 量出来 184 字节。 一百万条 0.2GB,一亿条 17.1GB。 而且这 184 字节还没算值对象——那个 299.0 的浮点自己还要再占二十几字节。

同样一条数据,第 1.1 篇用紧凑结构体量过:16 字节。一亿条只要 1.5GB。 同一批数据,装法不同,内存差十几倍。 这就是为什么大数据系统这么在意「用什么结构存」—— 不是为了好看,是为了装得下。 Python 的 dict 贵,是因为它要支持任意键、要快速查找、要能动态增删。这些都是用空间换来的。

二、装不下会怎样

内存不够有三种结局,一种比一种糟。 还有空闲:直接分配,很快。 物理内存满了:系统把不用的页换到磁盘。这叫换页,或者叫 swapping。 这时候程序没报错,就是突然变慢,硬盘灯常亮。

连换页空间也没了:系统会杀掉占用最大的进程。你看到的是「进程莫名其妙消失了」,日志里什么都没有。 中间那一行最坑。 监控上表现为「CPU 不高、磁盘很忙」,和「磁盘坏了」的症状几乎一样, 很多人就这么换了一块好盘问题没解决。 区分办法:看换页次数。换页率高就是内存不够,不是盘不行。

这一段没有真榨内存——把机器搞崩了后面的演示就都跑不成了。上面那笔账是真算的,结论不依赖榨不榨。

三、资源都不忙,时间照样走

演示第三段开了 8 个线程,每个持锁 0.1 秒。 结果墙钟 0.81 秒,CPU 时间 0.000 秒。 CPU 一动没动,用户实打实等了 0.8 秒。时间花在排队等锁上。

真实系统里,「CPU 利用率 5% 但接口要等 3 秒」几乎都是这种:等锁、等数据库连接、等下游接口返回。 这类问题加 CPU 一点用没有,只能减少等待:换无锁结构、缩短持锁时间、把串行改成并行。 顺带说一句,这也是为什么「CPU 使用率」是个很差的健康指标——它低不代表系统健康,可能只是在集体排队。

四、线程和进程不是一回事

演示第四段跑 4 个纯计算任务:串行 4.826 秒、4 个线程 4.859 秒、4 个进程 2.520 秒。 线程几乎没加速(0.99 倍),进程有 1.92 倍。 原因是 Python 的全局解释器锁(GIL):同一时刻只有一个线程在执行字节码。 你开四个线程,它们轮流用同一个核。

多线程不是没用,它只对「等待型」任务有用——上一段那 8 个线程就是。 等的时候把 CPU 让出去,这才是线程的价值。 进程能真并行,因为每个进程有独立的解释器和独立的内存。代价是启动贵、通信贵。 演示第四段开头那行很关键:开 4 个进程本身就要 1.065 秒。任务再小一点, 省下的时间还不够付这笔启动税。

所以分布式系统都要求「任务粒度别太细」。一个任务跑 10 毫秒、调度它花 1 秒,那不如不并行。

五、这一篇要带走的东西

三句话:内存账要按条算;满了会换页,症状像磁盘坏;线程解决等待,进程解决计算。 还有一句:并行不是免费的。 启动、通信、协调都要花钱,任务太小的时候,并行比串行还慢。 下一篇讲三处瓶颈各自的解法:存储靠分片,计算靠并行,网络靠少搬。

六、动手 Demo:内存账、等锁、以及线程为什么不管用

先算一条记录占多少内存,再乘出一亿条要多少。 开 8 个线程抢一把锁,量墙钟和 CPU 时间。 最后拿 4 个纯计算任务,比较串行、线程、进程三种跑法。

import sys, threading, time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

rec = {'o': 100000, 'u': 5000, 'q': 3, 'a': 299.0}
print('一条 dict 占', sys.getsizeof(rec), '字节')

lock = threading.Lock()
def worker():
    with lock:
        time.sleep(0.1)

ths = [threading.Thread(target=worker) for _ in range(8)]
# 全部 start 再全部 join,量墙钟与 process_time

for pool in (ThreadPoolExecutor, ProcessPoolExecutor):
    with pool(max_workers=4) as ex:
        list(ex.map(cpu_task, [5000000] * 4))

实跑输出:

--------------------------------------------------------------------
瓶颈之外:内存装不下、时间花在等锁
--------------------------------------------------------------------

  ① 先算一笔内存账
  --------------------------------------------------------------------
  1000000 条 dict:184 字节/条 -> 0.2 GB
  100000000 条 dict:184 字节/条 -> 17.1 GB
  一条四个字段的 dict 就要 184 字节,因为字典本身有桶、有指针、有哈希。
  这 184 字节还没算值对象——299.0 那个浮点自己还要再占二十几字节。
  换成紧凑结构体(第 1.1 篇量过的 16 字节),一亿条只要 1.5GB。
  同一批数据,装法不同,内存差十几倍。

  ② 内存不够会怎样
  --------------------------------------------------------------------

| 情况 | 系统做什么 | 你看到什么 |
|---|---|---|
| 还有空闲内存 | 直接分配 | 快 |
| 物理内存满了 | 把不用的页换到磁盘 | 突然变慢,硬盘灯常亮 |
| 连换页空间也没了 | 杀掉占用最大的进程 | 进程莫名其妙消失 |

  中间那一行最坑:看起来没坏,就是慢。
  这种慢在监控上表现为「CPU 不高、磁盘很忙」,很容易被误判成磁盘坏了。
  这一步没有真榨内存——把机器搞崩了,后面的演示就都跑不成了。上面那笔账是真算的,结论不依赖榨不榨。

  ③ 资源都不忙,时间照样走
  --------------------------------------------------------------------
  8 个线程,每个持锁 0.1 秒
  墙钟 0.81 秒,CPU 时间 0.000 秒
  CPU 占用 0.0%
  CPU 几乎一动没动(process_time 只显示到毫秒,这里就是 0),可用户实打实等了这么多秒。
  时间花在排队等锁上。
  真实系统里,「CPU 利用率 5% 但接口要等 3 秒」几乎都是这种:等锁、等数据库、等下游接口。
  加 CPU 一点用没有。

  ④ 线程加不上去,进程才行
  --------------------------------------------------------------------
  先量一下「开 4 个进程」本身要多久:1.080 秒
  再拿 4 个纯计算任务,分别用单线程、多线程、多进程跑:

| 跑法 | 耗时 | 相对单线程 |
|---|---:|---:|
| 单线程串行 | 4.754 秒 | 1.00 倍 |
| 4 个线程 | 4.812 秒 | 0.99 倍 |
| 4 个进程 | 2.493 秒 | 1.91 倍 |

  线程几乎没加速——Python 有全局解释器锁(GIL),同一时刻只有一个线程在跑字节码。
  多线程只对「等待型」任务有用(上一段那 8 个线程就是)。
  进程能加速,因为每个进程有独立的解释器。
  这就是分布式计算的最小原型:把活儿分给几个互不干扰的执行体。
  但别忽略开头那行:开关进程本身要花钱。
  任务太小,省下的时间还不够付这笔启动税——这就是为什么分布式系统都要求「任务粒度别太细」。

184 字节这个数值得记一下。 它解释了为什么「把数据读进内存再处理」在数据量大时会撞墙——不是算法不好,是容器太胖。 中间那段内存三结局表,第二行最值得警惕。 换页的症状和磁盘故障几乎一样,但解法完全相反(一个加内存,一个换盘)。 8 个线程那段,CPU 时间显示 0.000 秒。 这不是测量错了,是 process_time 精度只到毫秒级,而 sleep 真的一点 CPU 都不占。 最后那张表里进程只有 1.92 倍,不是 4 倍。差的那些就是启动税(1.065 秒)。 这个比例才是真实世界的样子。

30 秒实验

  1. 把 sys.getsizeof 换成 sys.getsizeof(rec) + sys.getsizeof(299.0),看一条记录真正占多少。
  2. 把 8 个线程改成 8 个进程,持锁那段还是 0.8 秒吗?想想为什么。
  3. 把计算任务从 5000000 改成 100000,重跑进程那一行,看它是不是比串行还慢。

常见误区

  1. 「内存没满就不用管。」接近满的时候系统已经在换页了,这时候速度掉得比「满了还快」。 留 20% 余量是常识。
  2. 「多开线程就能变快。」对等待型任务有用,对计算型任务没用,因为 GIL 让它们排队。
  3. 「并行一定比串行快。」任务小的时候,调度和启动的钱比省下的多。先算一下每个任务要跑多久。
自测三问(点开对答案) 1. 一条四字段的 dict 占 184 字节,一亿条要多少内存?有没有可能放下? —— 约 17.1GB,还没算值对象。普通机器放不下。换成 16 字节的紧凑结构体,一亿条 1.5GB,就能放下。 2. 为什么 8 个线程抢锁那段,CPU 时间是 0? —— 因为 time.sleep 是在等,不占 CPU。 8 个线程全在排队等锁,谁都没在算,所以 CPU 时间接近零,墙钟却是 0.8 秒。 3. 4 个纯计算任务,用 4 个线程为什么没有 4 倍加速? —— Python 有 GIL,同一时刻只有一个线程能执行字节码。4 个线程在抢同一把解释器锁,等于串行。 要真并行得用多进程。

下一篇把三处瓶颈的解法摊开:分片、并行、少搬。

相关笔记