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 秒实验
- 把
ACCESS_READ换成ACCESS_COPY,试写一个字节,看文件变没变。 - 把切片大小从 4 KB 改成 64 KB,看差距缩小还是扩大。
- 先
mm.seek(0)再find,验证不用传起点也能找着。
常见误区
- 以为 mmap 省的是读盘。 省的是拷贝,盘该读还得读。
- 拿同一个 mmap 既喂 av.open 又自己 find。 忘了先复位指针。
- 把 12 倍当成普遍规律。 那是在页缓存里、随机切片的前提下测的。
自测三问(点开对答案)
1. mmap 比 read 快在哪一步? —— 省掉内核缓冲到用户缓冲的拷贝,也不产生系统调用。 2. 为什么 av.open 之后 `find` 就找不着了? —— mmap 的当前位置被推走了,`find` 默认从当前位置往后找。 3. 什么时候不该用 mmap? —— 只顺序读一遍的时候;以及文件会被删或截断的场合。- ☐ (本节完,第六章已完结,下一章 7.1 AVFormat 封装层总览)