🎬 第六章 AVProtocol协议层理论及案例实战
📄 一句话概括

mmap 把文件的一页一页直接挂到你的地址空间,省掉「抄」这一趟。 它对 av.open 完全透明,因为交出来的东西本来就有 read/seek/tell。

📝 本节和前面几节的关系

6.4 换的是「谁来给字节」,这一节换的是「字节在哪」。 这一节有个真能测出来的性能差,也有两个必须知道的坑。

一、mmap 到底做了什么

普通 read 是「抄」,mmap 是「挂」。 做法 数据在哪 几次拷贝


read 内核页缓存 + 你的缓冲 一次 mmap 内核页缓存,共用 零次

「挂」的意思是:把文件的某一段映射到进程地址空间的某一段。 你读那块内存,就是在读文件; 内核在你第一次碰到某一页时才真去读盘。 这个「碰到才读」有个正式名字叫缺页。它带来两个后果:省了拷贝,但也让第一次访问变慢。 所以 mmap 不是「更快的 read」,是「另一种访问方式」:适合反复随机碰,不适合顺序扫一遍。

二、只读映射与写时复制

开映射时要指定权限,这一步很多人不写。 权限 能干什么 写会怎样


ACCESS_READ 只能读 报 TypeError ACCESS_WRITE 能读能写 直接改文件 ACCESS_COPY 能读能写 改的是副本

只读映射写一个字节会报 TypeError,而且是内核拦的,不是 Python 拦的——这一层保护绕不过去。 ACCESS_COPY 是写时复制:改了不落盘,只在内存里留一份。适合「想改又不想动原文件」的场合。 搞视频处理时这条很实用:把素材只读映射进来,处理结果写别处,原文件一个字节都不会被动。

三、一个真会咬人的坑:指针被推走

mmap 对象自己有个「当前位置」,跟文件对象一样。 av.open 会一路读一路推这个指针,读完之后它停在哪,取决于解到哪一帧。 于是你再拿这个对象 find(),默认是从当前位置往后找,前面的东西就找不着了。 调用 结果 为什么


av.open 之前 find 能找着 指针在 0 av.open 之后 find 找不着 指针被推到末尾 find(..., 0) 带起点 能找着 起点被显式指定

解法只有两个:要么先 mm.seek(0),要么给 find 传起点 0。 这个坑在普通文件对象上不会遇到,因为没人拿文件对象去找字符串。 mmap 恰恰两样都能干,所以两套指针会打架。 记住一句话就够:交给 av.open 的 mmap,用之前先复位。

四、性能实测:差多少,为什么会差

4 MB 文件、4000 个随机 4 KB 切片,两种读法各跑一遍。 做法 耗时 差多少


mmap 切片 2.7 毫秒 1 倍 seek + read 34 毫秒 约 12 倍

差出来的全是「抄」与「系统调用」。 seek + read 每一片都要两次系统调用加一次内存拷贝, mmap 切片在 Python 里只是一次视图构造。 但要说清楚这次测的是什么:文件已经在页缓存里,所以量到的是「拷贝 vs 不拷贝」, 不含第一次读盘的代价。 真盘上就没有这么悬殊了:mmap 的随机访问会一页一页地引进来,读得不连续,反而可能更慢。 这一点别被那个 12 倍骗了。

五、什么时候值得用

一句话:这份数据要被碰很多次、而且跳着碰。 场景 判断 理由


随机读一个大文件 值得 省掉每次的拷贝 多进程共读一份数据 值得 物理内存只留一份 只顺序扫一遍 不值 read 更简单也更稳 文件经常被改 当心 映射期间不能截断

最后一行是硬限制:Windows 上映射着的文件不能删、不能截断,得先解除映射。 忘了这一点会得到「文件被占用」这种莫名其妙的错。 另一个限制是地址空间:32 位程序一共 4 GB,映射一个 6 GB 的文件直接失败。 64 位才没有这个顾虑。 所以它不是「高级版 read」,是一件有明确适用面的工具。 判断标准就是那句:碰很多次、跳着碰。

六、动手 Demo:内存映射文件——让文件看起来像一块内存

先验证 av.open 能直接吃 mmap,再量一次随机切片的差距。

import av, mmap, random, time

# 一、av.open 直接吃 mmap
with open("tiny.mp4", "rb") as f:
    mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
    print("指针", mm.tell(), "ftyp 在", mm.find(b"ftyp"))
    c = av.open(mm)
    print(c.format.name, sum(1 for _ in c.decode(c.streams.video[0])))
    c.close()
    print("指针", mm.tell(), "ftyp 在", mm.find(b"ftyp"))
    mm.close()

# 二、4 MB 文件上随机切片:mmap 与 seek+read
BS = 4096
random.seed(7)
offs = [random.randrange(0, (4 << 20) - BS) // BS * BS
        for _ in range(4000)]

with open("big.bin", "rb") as f:
    mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
    t0 = time.perf_counter()
    for o in offs:
        mm[o:o + BS]
    t1 = time.perf_counter()
    for o in offs:
        f.seek(o)
        f.read(BS)
    t2 = time.perf_counter()
    mm.close()

print("mmap %.1f 毫秒" % ((t1 - t0) * 1000))
print("read %.1f 毫秒" % ((t2 - t1) * 1000))

实跑输出:

--------------------------------------------------------------------
内存映射文件——让文件看起来像一块内存
--------------------------------------------------------------------

**① mmap 能被 av.open 直接吃**

映射出 **46137 字节**,前八个字节是 `0000002066747970`,认出 **mov,mp4,m4a,3gp,3g2,mj2**,
解出 **50 帧**。

**解码结果与直接读文件一模一样**,这很正常:mmap 交出来的东西本来就有 read/seek/tell,
在 AVIO 眼里与文件对象没有区别。

**试写一个字节,报 TypeError,因为我们开的是只读映射。** 这一层保护是内核给的,不是 Python 拦的。

**② 这里有个必须知道的细节:av.open 会把这个映射的指针推走**

  时刻                mm.tell()     mm.find(b"ftyp")
  ------------------------------------------------------
  av.open 之前        0             4
  av.open 之后        46137         -1
  加上起点 0 再找     46137         4

**同一个文件,`ftyp` 明明在第 4 字节,第二次却找不到了。** 因为 `find()` 不带起点参数时,
是**从当前位置往后找**的。
解完码指针停在 **46137**,前面早就翻过去了,所以找不着。

**这不是 mmap 的 bug,是它把「当前位置」当默认起点。**
拿同一个 mmap 对象喂给 av.open 之后还要自己用,记住两件事:要么先 `mm.seek(0)`,
要么给 `find` 显式传起点。

**这个坑在有 seek 的文件对象上不存在**,因为文件对象你不会拿它去找字符串。
**mmap 恰恰两样都能干**,所以两套「指针」会打架。

**③ 省掉的到底是哪一步**

  做法     数据走了几趟              谁在管缓冲
  -------------------------------------------------
  read     内核页缓存 → 用户缓冲     你自己
  mmap     内核页缓存,直接用        内核

**read 是把数据从内核抄到你的缓冲区**,抄完那份缓冲还得你管。
**mmap 是把文件的页直接挂到你的地址空间**,不抄,用时才缺页调入。

**所以 mmap 省的是「抄」这一趟**,不是省「读盘」。盘该读还得读。

**④ 拿 4 MB 文件实测随机切片**

同一个文件、同样 4000 个随机 4 KB 切片,各读一遍:

  做法            耗时          相对
  -----------------------------------------
  mmap 切片       3.6 毫秒      1 倍
  seek + read     32.8 毫秒     9.1 倍

**差出 9.1 倍**,读的还是同一块盘、同一片页缓存。

**差别全在「抄」上**:`seek + read` 每次都要一次系统调用加一次内存拷贝,mmap 切片只是切了个视图,
**根本没有系统调用**。

**⑤ 但别把它当万灵药**

**这次的 4 MB 文件全在页缓存里**,所以量到的是「拷贝 vs 不拷贝」这一层。
真盘上第一次访问还要算缺页,mmap 的随机访问反而可能更慢——因为它一页一页地引进来,读得不连续。

**另外两个坑**:映射长度受地址空间限制(32 位程序尤其紧);
映射的文件在 Windows 上不能删、不能截断,得先解除映射。

**⑥ 什么时候值得用**

  场景                       值不值
  ---------------------------------------------------
  反复随机读同一个大文件     值,省掉每次的拷贝
  整个文件只顺序读一遍       不值,read 更省事
  多进程共享同一份数据       值,物理内存只留一份

**判断标准就一句**:这份数据要被碰很多次、而且跳着碰,才值得映射。碰一次就完的,老老实实 read。

第二段那个 ftyp 在 -1 不是文件坏了,是指针被推走了。 加上起点 0 再找,又回到第 4 字节。

30 秒实验

  1. 把 ACCESS_READ 换成 ACCESS_COPY,试写一个字节,看文件变没变。
  2. 把切片大小从 4 KB 改成 64 KB,看差距缩小还是扩大。
  3. 先 mm.seek(0) 再 find,验证不用传起点也能找着。

常见误区

  1. 以为 mmap 省的是读盘。 省的是拷贝,盘该读还得读。
  2. 拿同一个 mmap 既喂 av.open 又自己 find。 忘了先复位指针。
  3. 把 12 倍当成普遍规律。 那是在页缓存里、随机切片的前提下测的。
自测三问(点开对答案) 1. mmap 比 read 快在哪一步? —— 省掉内核缓冲到用户缓冲的拷贝,也不产生系统调用。 2. 为什么 av.open 之后 `find` 就找不着了? —— mmap 的当前位置被推走了,`find` 默认从当前位置往后找。 3. 什么时候不该用 mmap? —— 只顺序读一遍的时候;以及文件会被删或截断的场合。

相关笔记