🐧 第八章 外部存储器
📄 一句话概括

一个能重复用的 IO 测量器要做三件事:造数据、预热、重复取最小。64 MB 顺序读,块从 4 KB 加到 256 KB 快了 5.6 倍,再往上反而慢——因为成本从【调用次数】换成了【拷内存】。

📝 本节和前面几节的关系

8.3 量好了尺子,这一节把测量器写出来:一个能反复用的最小实验程序,后面 8.5、8.6 的结论都从它来。

一、测量器的三件事

① 造数据:先写一个固定大小的文件,别每次重新生成。否则你测的是【生成数据】而不是【读数据】。 ② 预热:先完整读一遍,把页缓存填上。这一条决定了你测的是【盘】还是【缓存】。想测盘就要反着做:清缓存(drop_caches)或用直读。 ③ 重复取最小:跑三到五次,取最小值。为什么取最小不取平均?因为噪声只会让时间变长,不会让时间变短——最小值最接近【没有干扰时的真实值】。 这三条是所有性能实验的通用套路,换成 CPU、网络、数据库都一样。

二、真跑:只改每次读多少

Demo 81 读 64 MB,块从 4 KB 试到 4 MB。 4 KB:108.63 毫秒(589 MB/秒);256 KB:19.23 毫秒(3328 MB/秒)。快了 5.6 倍。 这一段的收益全部来自【少做了一万多次系统调用】:64 MB ÷ 4 KB = 16384 次,64 MB ÷ 256 KB = 256 次。 但 1 MB 之后又慢回去了:1 MB 46.19 毫秒,4 MB 45.31 毫秒,都比 256 KB 慢。因为大块 read 每次都要新分配一块内存、再拷进去,块越大这笔拷贝越贵。 两笔成本一涨一落,中间就有个最优点。在页缓存命中的情况下,这个点落在几十 KB 到 256 KB 之间。

三、三个坑

① 没预热就测:第一次读到的是【冷】的那一侧,数字会大一圈;预热之后测得的是【热】的那一侧,只能代表缓存。两边都不是【盘的真实速度】。 ② 只跑一次:一次可能正好赶上后台回写、GC、或者别的进程抢 IO。多跑几次取最小值。 ③ 边写边读:回写线程会和读抢设备,所以读写要分开测,中间留点间隔。这一点在 SSD 上尤其明显(写会触发垃圾回收)。 这三条在 4.13 讲过一遍,IO 实验的噪声比 CPU 实验大一个量级,所以这里再强调一次。

四、换更专业的工具

自己写的测量器适合【理解原理】,真做工程要用 fio。 fio 能控的东西:块大小、顺序还是随机、队列深度、几线程、直读还是过缓存、跑多久。 一条标准写法:fio --name=t --rw=read --bs=4k --size=1G --direct=1。--direct=1 是绕过缓存——不加它,你测的就是内存。 iostat -x 1 看实时指标:r/s、w/s 是每秒请求数,rkB/s 是带宽,aqu-sz 是平均队列长度。队列长度长期大于 1,说明设备是瓶颈。

五、动手 Demo:实验程序——一个能重复用的顺序读测量器

造一个 64 MB 的文件,预热一遍,然后只改每次 read 的字节数,跑五档,每档取三次里的最小值。

import os, tempfile, time

KB, MB = 1024, 1024 * 1024
d = tempfile.mkdtemp(prefix="ch29c8_")
p = os.path.join(d, "seq.bin")
size = 64 * MB
open(p, "wb").write(b"\0" * size)

def read_with(bs, reps=3):
    r = []
    for _ in range(reps):
        t0 = time.perf_counter()
        with open(p, "rb", buffering=0) as f:
            n = 0
            while n < size:
                b = f.read(bs)
                n += len(b)
        r.append((time.perf_counter() - t0) * 1000)
    return min(r)

read_with(MB, 1)          # 预热

for bs in (4 * KB, 64 * KB, 256 * KB, MB, 4 * MB):
    ms = read_with(bs)
    print("%4d KB  %8.2f 毫秒  %6.0f MB/秒"
          % (bs // KB, ms, size / MB / (ms / 1000)))

实跑输出:

--------------------------------------------------------------------
实验程序——一个能重复用的顺序读测量器
--------------------------------------------------------------------
① 测量器的三件事

  **① 造数据**:**写一个固定大小的文件,别每次重新生成。**
  **② 预热**:**先读一遍,让它在缓存里(或先清缓存)。**
  **这一条决定了你测的是【盘】还是【缓存】。**
  **③ 重复取最小**:**取最小值压掉系统噪声,而不是取平均。**

② 真跑:64 MB 顺序读,只改每次读多少

  每次读     读 64 MB 耗时     折算带宽
  -------------------------------------------
  4 KB       108.63 毫秒       589 MB/秒
  64 KB      24.39 毫秒        2624 MB/秒
  256 KB     19.23 毫秒        3328 MB/秒
  1 MB       46.19 毫秒        1386 MB/秒
  4 MB       45.31 毫秒        1412 MB/秒

  **从 4 KB 到 256 KB,快了 5.6 倍。**
  **再往上就不快了,反而慢一点。**
  **因为大块 `read` 每次都要新分配一块内存、再把数据拷进去**,
  **块越大,这一笔拷贝越贵。**
  **两笔成本一涨一落,所以中间有个最优点。**

③ 这个测量器要注意的三个坑

  **① 没预热就测**:**第一次读到的是【冷】的那一侧**,**数字会大一圈。**
  **预热之后测的是【热】的那一侧,只能代表缓存。**
  **② 只跑一次**:**一次可能正好赶上后台回写或者 GC。****要多跑几次取最小值。**
  **③ 边写边读**:**回写线程会和读抢设备**,**所以读写要分开测,中间留一点间隔。**

  **这三条在 4.13 讲过一遍**,**这里再强调一次是因为 IO 实验的噪声比 CPU 实验大得多。**

  📋 **Linux 上更专业的工具**:`fio`(能控队列深度、块大小、顺序/随机、直读);
  **`fio --name=t --rw=read --bs=4k --size=1G --direct=1` 是标准写法。**

先看 4 KB 那一行:589 MB/秒。这个速度已经接近内存带宽了——说明整份文件都在页缓存里,一次设备读都没发生。 从 4 KB 到 256 KB 快 5.6 倍,因为系统调用从 16384 次降到 256 次。 1 MB 之后反而慢:大块 read 要新分配内存再拷贝,这笔拷贝成本压过了省下的调用开销。 所以【块越大越好】只在【调用开销是瓶颈】的区间成立。这个区间在哪里,取决于你的机器和缓存状态。

📋 Linux 上更专业的工具:fio(能控队列深度、块大小、顺序/随机、直读); 📋 fio --name=t --rw=read --bs=4k --size=1G --direct=1 是标准写法。

30 秒实验

  1. 把 buffering=0 去掉,看 4 KB 那一档快多少。
  2. 把文件加大到 512 MB(比内存小一点),看 4 KB 那档怎么变。
  3. iostat -x 1,一边跑这个 Demo 一边看 aqu-sz。

常见误区

  1. 【块越大越快,所以永远用最大的块】。大块要占内存、要拷一份、首字节延迟也变长,过了最优点就只是更慢。
  2. 【取最小值是作弊,应该取平均】。噪声只会加时间不会减时间,最小值最接近无干扰的真实值,平均值反而被偶发的卡顿污染。
  3. 【预热只是为了快一点,可有可无】。预热决定了你测的是【缓存】还是【盘】,这是【测什么】的问题,不是【快慢】的问题。
自测三问(点开对答案) 1. 为什么重复测要取最小值而不是平均? —— **因为干扰只会让耗时变长**,**最小值最接近【没有干扰时的真实值】。****平均值会被偶发的卡顿拉高,反映的是一个不存在的【典型情况】。** 2. 64 MB 顺序读,块从 4 KB 加到 256 KB 为什么快了 5.6 倍? —— **因为系统调用从 16384 次降到 256 次**,**每次调用的固定开销被摊薄了。****这一段的速度完全由【调用次数 × 单次开销】决定。** 3. 为什么 1 MB 之后反而变慢? —— **因为成本换了主角**:**调用开销已经摊薄到可忽略,剩下的是【每次分配一大块内存再拷进去】的成本,****块越大这一笔越贵。**

相关笔记