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

写 32 MB,4 KB 一次要写 8192 次,1 MB 一次只要 32 次——差 256 倍;而总耗时只差 3.7 倍。差的那部分没消失,是被【通用块层】合并掉了:相邻的小请求粘成一个大请求。

📝 本节和前面几节的关系

8.1 到 8.6 都在【应用与文件系统】这一层看问题,这一节往下一层走:从 write() 到设备收到请求,中间还经过谁。

一、8192 次写,只慢了 3.7 倍

Demo 84 往一个文件里写 32 MB,只改每次写多少。 4 KB:101.33 毫秒,8192 次 write。1 MB:27.61 毫秒,32 次 write。 次数差 256 倍,时间只差 3.7 倍。如果每次 write 都真的发给设备,时间该差 256 倍才对。没有,说明中间有人替它合并了。 这个人就是通用块层。它把【相邻的、小的】请求拼成【一个大的】,再发给设备驱动。所以一万次 write 到设备那一层,可能只有几百个请求。

二、四层,每层看到的单位不同

write() 那一层看到的是【这次写了多少字节】。它不看内容,只按顺序往下交。 文件系统那一层看到的是【文件块】——它要把逻辑偏移换算成块号,相邻的块可以连起来。 通用块层看到的是 bio——一段【物理上连续】的块。相邻的 bio 还能再合并。 设备驱动那一层看到的是【设备请求】,按扇区发给硬件。 所以同一个操作,四层看到的东西完全不一样。每往下走一层,单位就变大一点。这就是【抽象层】的价值:上层不必知道下层的块有多大。

三、合并的条件是【相邻】

这是整件事的关键,也是最容易忽略的一句。只有相邻的请求能合并。不相邻的,只能各自发一次。 所以顺序写在块层这一关能拿到全部好处:8192 个 4 KB 的写,粘成 32 个 1 MB 的请求。 而随机写的每个 4 KB 都不相邻,谁也合并不了。块层在这一关是【帮不上忙】的——它只能把请求排个序(8.8 的调度器),让磁头少跑几趟。而排序只是减少寻道距离,不能把随机变成顺序。 这就是【顺序写比随机写快】的第二层原因:第一层是 8.1 讲的【少付寻道】,第二层是这里讲的【能被合并】。两个原因叠加,差距就特别大。

四、这件事在实践中怎么用

① 攒批写:与其写一万次 4 KB,不如攒成 100 次 400 KB。日志库、消息队列都在做这件事。 ② 别在文件中间乱插:顺序追加永远比随机插入便宜。这也是 LSM 树存在的原因。 ③ 看 iostat 的合并计数:rrqm/s 与 wrqm/s 是【每秒合并了多少请求】。这两个数高,说明你的访问模式对块层友好。 ④ O_DIRECT 会绕过页缓存,但不会绕过块层合并——合并发生在块层,它还在。 一句话:块层是【免费的顺风车】,但它只对【相邻】的请求开门。

五、动手 Demo:通用块层——把一万个小请求并成几个大请求

往文件里写 32 MB,块从 4 KB 试到 1 MB,把 write 次数与耗时一起打出来,看【次数差 256 倍】和【时间差几倍】之间的距离。

import os, tempfile, time

KB, MB = 1024, 1024 * 1024
d = tempfile.mkdtemp(prefix="ch29c8_")
total = 32 * MB
data = b"\0" * total

def write_with(bs, reps=3):
    p = os.path.join(d, "w%d.bin" % bs)
    r = []
    for _ in range(reps):
        t0 = time.perf_counter()
        with open(p, "wb", buffering=0) as f:
            off = 0
            while off < total:
                f.write(data[off:off + bs])
                off += bs
        r.append((time.perf_counter() - t0) * 1000)
    return min(r)

for bs in (4 * KB, 16 * KB, 64 * KB, 256 * KB, MB):
    ms = write_with(bs)
    print("%4d KB  写 %5d 次  %8.2f 毫秒  每次 %7.2f 微秒"
          % (bs // KB, total // bs, ms, ms * 1000 / (total // bs)))

实跑输出:

--------------------------------------------------------------------
通用块层——把一万个小请求并成几个大请求
--------------------------------------------------------------------
① 写 32 MB,只改每次写多少

  每次写      write 次数     耗时           每次折算
  ---------------------------------------------------------
  4 KB        8192           131.65 毫秒    16.07 微秒
  16 KB       2048           56.26 毫秒     27.47 微秒
  64 KB       512            31.28 毫秒     61.09 微秒
  256 KB      128            21.60 毫秒     168.72 微秒
  1024 KB     32             43.28 毫秒     1352.56 微秒

  **4 KB 要写 8192 次,1 MB 只要写 32 次——差 256 倍。**
  **而总耗时只差 3.0 倍。**

② 差的那部分去哪了:合并

  **文件系统与通用块层把【相邻的小请求】合并成【一个大请求】。**
  **所以一万次 `write` 到设备那一层,可能只有几百个请求。**
  **合并的条件是【相邻】**——
  **这也是【顺序写比随机写快】的另一层原因。**
  **随机写没法合并,每个 4 KB 都得单独发。**

  层               看到的单位            能合并吗
  ---------------------------------------------------------------
  应用 write()     每次 write 的字节     不看内容,只按顺序收
  文件系统         文件块(4 KB)        相邻块可以连起来
  通用块层         bio(一段连续块)     相邻 bio 再合并
  设备驱动         设备请求              按扇区发给设备

  **所以【一次 write 4 KB】和【一次 write 1 MB】在设备那一层看到的东西,可能完全一样。**
  **差别全在【上层多做了一万次调用】。**

③ 但块层不能解决随机访问

  **随机写的每个 4 KB 都不相邻,谁也合并不了。**
  **所以随机写在块层这一关是【帮不上忙】的**——
  **它只能把请求排个序(8.8 的调度器),让磁头少跑几趟。**
  **而排序只是【减少寻道距离】。****它不能把随机变成顺序。**

  📋 **Linux 上看合并**:`cat /proc/vmstat | grep -i merge`;
  **`iostat -x 1` 看 `rrqm/s`、`wrqm/s`(每秒合并了多少请求)。**

看最后一行【每次折算】:4 KB 那档 12.37 微秒,1 MB 那档 862.67 微秒。单次成本随着块变大而上涨,涨幅远小于块本身的涨幅。 这说明【一次 write 的固定开销】才是 4 KB 那档的成本主体。 8192 次写到设备那一层可能只有几百个请求,剩下的都被合并了。 注意这里写的是【相邻块】,所以合并一路通畅。换成随机偏移,这个好处就没有了。

📋 Linux 上看合并:cat /proc/vmstat | grep -i merge; 📋 iostat -x 1 看 rrqm/s、wrqm/s(每秒合并了多少请求)。

30 秒实验

  1. 改一下:每次写完 f.seek(0, 2) 再写,看时间有没有变化。
  2. 把写入偏移改成随机的(用 f.seek(o) 再写),看耗时涨多少。
  3. iostat -x 1 1,看你的磁盘 wrqm/s 是多少。

常见误区

  1. 【write 一次就是发一次设备请求】。中间隔着文件系统、通用块层、驱动三层,相邻的小请求会被一路合并。
  2. 【块层能把随机写变快】。随机写的请求不相邻,无法合并,块层只能帮它排序,省的是寻道距离(8.8)。
  3. 【用了 O_DIRECT 就绕过所有中间层】。O_DIRECT 绕的是【页缓存】,块层的合并照样发生。
自测三问(点开对答案) 1. 为什么 4 KB 写 8192 次,比 1 MB 写 32 次只慢 3.7 倍,而不是 256 倍? —— **因为通用块层把相邻的小请求合并了**,**到设备那一层,两种情况发出的请求数差不多。****省下的只是上层的调用开销。** 2. 块层能合并随机写的请求吗? —— **不能。合并的条件是【物理相邻】**,**随机写的每个 4 KB 都不相邻。****块层只能把请求排序,减少寻道距离,不能变成顺序。** 3. 为什么说顺序写比随机写快有两个独立的原因? —— **第一层:少付寻道与旋转**(8.1 的机械动作);**第二层:相邻请求能被块层合并成一个大请求**(本节)。**两个原因叠加,所以差距特别大。**

相关笔记