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

Python 的 open() 自带一层缓冲:不带缓冲读 64 MB 要 105 毫秒,带默认缓冲 66 毫秒,缓冲设成 1 MB 只要 26 毫秒。但这只是一层【用户态】缓冲——内核的预读、块层的合并、设备的寻道,在沙箱里一层也测不到。

📝 本节和前面几节的关系

8.9 讲了预读的原理,这一节做实验——但实验的结果是【能测到哪一层】,而不是【预读快多少】。知道边界,比多一个数字重要。

一、Python 的缓冲本身就是一层

open(p, "rb") 默认带缓冲(io.BufferedReader,默认 8 KB 左右,可调)。你 read(4096) 一次,它可能只从内核拿了 8 KB 出来,剩下的等你下次要。 open(p, "rb", buffering=0) 完全不带缓冲,每次 read(n) 就是一次货真价实的系统调用。 实测(读 64 MB,每次 4 KB):带默认缓冲 66.27 毫秒(966 MB/秒);不带缓冲 105.02 毫秒(609 MB/秒);缓冲设成 1 MB 只有 26.65 毫秒(2401 MB/秒)。 不带缓冲慢 1.6 倍——因为一万六千次 read(4096) 就是一万六千次系统调用;带缓冲时,只在缓冲块用完时才真的调一次。 缓冲设成 1 MB 最快——因为系统调用次数又降了一个量级。这跟 8.5 的结论是一致的:快了不是因为盘快,是因为【少调了几次】。

二、这一层是用户态的,不是内核的

io.BufferedReader 是 Python 运行时里的东西。它和内核的页缓存、预读窗口完全是两回事。所以这里测出来的差别是【用户态缓冲】的差别。 内核预读那一层,在沙箱里测不出来——因为文件本来就在页缓存里,预读没什么可预的。要测它,得有【真正的缺页】:清缓存(drop_caches)或者用 O_DIRECT 绕过缓存。 区分这两层非常重要:很多【我的程序比你的快】的比较,差的其实就在用户态缓冲这一层,跟磁盘、跟内核一点关系都没有。

三、沙箱里到底能测到哪一层

能测:应用缓冲(本节)、系统调用开销、页缓存命中。这三层都在【内存里】,沙箱里跑的本来就是它们。 测不准:块层的合并与调度。因为页缓存挡在前面,请求很可能根本没走到那一层。 不能测:设备的寻道与旋转。没有机械盘,也没有绕过缓存的手段。 这张表比任何数字都有用:它告诉你【哪一条结论有实测支撑、哪一条只是模型推算】。写实验报告时把这两类分开,才是诚实的做法。 这也是这一整章的方法论总结:先量尺子(8.3)、再设计实验(8.4)、看清能测到哪一层(本节)、最后才下结论。

四、动手 Demo:实验——在沙箱里能测到哪一层

同一个 64 MB 文件,每次读 4 KB,分三种读法:带默认缓冲、不带缓冲、缓冲设成 1 MB,看用户态缓冲这一层能差多少。

import os, tempfile, time

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

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

cases = (("带默认缓冲", {}),
         ("不带缓冲", {"buffering": 0}),
         ("缓冲 1 MB", {"buffering": MB}))
for name, kw in cases:
    ms = read_with(**kw)
    print("%-12s %8.2f 毫秒  %6.0f MB/秒" % (name, ms, 64 / (ms / 1000)))

实跑输出:

--------------------------------------------------------------------
实验——在沙箱里能测到哪一层
--------------------------------------------------------------------
① Python 的缓冲本身就是一层预读

  **`open(p, "rb")` 默认带缓冲**(`io.BufferedReader`,**默认 8 KB 左右,可调**)。
  **`open(p, "rb", buffering=0)` 完全不带缓冲**,每次 `read(n)` 就是一次系统调用。

  读法              64 MB 耗时      折算带宽
  ------------------------------------------------
  带默认缓冲        90.90 毫秒      704 MB/秒
  不带缓冲          130.10 毫秒     492 MB/秒
  缓冲设成 1 MB     47.56 毫秒      1346 MB/秒

  **不带缓冲比带缓冲慢 1.4 倍。**
  **因为不带缓冲时,一万六千次 `read(4096)` 就是一万六千次系统调用;**
  **带缓冲时,它只在缓冲块用完时才真的调一次。**

② 这一层是【用户态的预读】,不是内核的

  **`io.BufferedReader` 属于 Python 运行时,它和内核的页缓存、预读窗口是两回事。**
  **所以这里测出来的差别是【用户态缓冲】的差别。**
  **内核预读那一层,在沙箱里测不出来**——**因为文件本来就在页缓存里,预读没什么可预的。**

③ 在沙箱里到底能测到哪一层

  层次                 沙箱能测吗     为什么
  ----------------------------------------------------------------------
  应用缓冲(本节)     能             纯用户态,与介质无关
  系统调用开销         能             进内核的那一趟是真的
  页缓存命中           能             它本来就在走这条路
  块层合并与调度       测不准         页缓存挡在前面,请求没到那一层
  设备寻道与旋转       不能           没有机械盘,也没有绕过缓存

  **这张表比数字有用**:**它告诉你【哪一条结论有实测支撑、哪一条只是模型推算】。**
  **写实验报告时,把这两类分开,才是诚实的做法。**

  📋 **要测到最底下那一层**:`dd iflag=direct`(绕过缓存)+ `fio --direct=1`;
  **并且一定要在有【目标介质】的机器上测**——拿内存盘测机械盘的特性,测不出来。

三种读法读的是同一个文件、同样的字节数,唯一差别是用户态缓冲多大。 带缓冲比不带缓冲快 1.6 倍左右,因为系统调用从一万六千次降到两千次。 缓冲设成 1 MB 最快:系统调用又降一个量级,而且 1 MB 的拷贝效率比 8 KB 高。 但请注意:这三个数字都是【内存速度】,没有一个是磁盘速度。在这台机器上,你测不出硬盘。

📋 要测到最底下那一层:dd iflag=direct(绕过缓存)+ fio --direct=1; 📋 并且一定要在【有目标介质】的机器上测——拿内存盘测机械盘的特性,测不出来。

30 秒实验

  1. 把 read(4096) 改成 read(1),看三者差距会不会拉得更大。
  2. 用 f.readinto(bytearray(4096)) 代替 f.read(4096),看能省多少。
  3. grep . /proc/sys/vm/vfs_cache_pressure,看看你的系统缓存压力参数。

常见误区

  1. 【不开缓冲一定更快,缓冲是多一层拷贝】。缓冲省下的系统调用远比那一次拷贝值钱,所以带缓冲快 1.6 倍。
  2. 【Python 的缓冲就是操作系统的预读】。Python 的缓冲在用户态,预读在内核态,两者是独立的两层。
  3. 【只要代码写得对,在哪儿测都一样】。测试环境决定了你能测到哪一层。没有机械盘的机器,永远测不出寻道。
自测三问(点开对答案) 1. 为什么不带缓冲反而慢? —— **因为每次 `read(4096)` 都真的做一次系统调用**,**一万六千次的调用开销加起来,比带缓冲多做一次拷贝贵得多。****带缓冲只在缓冲块用完时才调一次。** 2. Python 的缓冲和内核的预读有什么区别? —— **Python 的缓冲在用户态(`io.BufferedReader`),它把系统调用次数降下来;****内核的预读在页缓存层,它把【缺页】提前补上。****两层独立,可以同时存在。** 3. 在沙箱里为什么测不到块层与设备层? —— **因为页缓存挡在前面**:**请求根本走不到块层,更到不了设备。****要测下层,必须绕过缓存(O_DIRECT)并换到有目标介质的机器上。**

相关笔记