Page Cache:读过的文件内容,凭什么要留在内存里
程序读一个文件,数据在磁盘上,得搬进内存才能用——这一搬就是一次磁盘 I/O,慢。可程序常常反反复复读同一个文件:同一段配置读好几遍、好几个进程读同一个库、
mmap映射了又read()读一遍。要是每次都老老实实跑一趟磁盘,光 I/O 就把时间吃光了。操作系统有个简单却收效巨大的办法:把读过的文件内容留在内存里,下次再读同一段,直接从内存拿,不碰磁盘。这留着的部分叫 Page Cache(页缓存)。上一篇讲了按需分页——缺页时补一页;这一篇接着讲:文件映射缺页时,补的那一页内容从哪来、为什么要专门为文件建一层缓存。
先垫底:读文件,本质是把数据从磁盘搬进内存
程序要读一个文件,文件的内容并不在内存里(内存条和磁盘是两块地方),它在磁盘上。所谓"读文件",本质是把数据从磁盘搬进内存,程序才能碰到它。这一搬就是一次磁盘 I/O——而磁盘 I/O 是慢的,慢到和内存操作不在一个数量级(内存纳秒级,磁盘毫秒级,差了十万倍量级)。每多一次不必要的磁盘读,程序就多卡一会儿。
为什么不能每次都老老实实读盘
麻烦在于,程序读文件常常是重复的。同一段配置,程序启动读一遍、运行中再查又读一遍;好几个进程启动都要读同一个动态链接库;一个进程既 mmap 映射了一个文件、又用 read() 读它——两种读法各自去读盘,读回来的是同一份内容。这些场景有个共同点:同一个文件的同一块内容被读了很多遍,而它压根没变。每次都跑磁盘,把同一个答案反复搬运,纯属浪费。
缓存:读过的,就留在内存里
破解办法很朴素:读过的内容,先别扔,留在内存里。下次再要读同一段,先看留着的这部分里有没有——有,直接拿来用,一次磁盘都不用跑;没有,再去磁盘读,读完照样留下来供下次用。这"留着的部分"就是缓存。它用一小块内存,换来了大把省下的磁盘 I/O,几乎稳赚不赔——因为程序访问文件有强烈的"局部性":读过的很可能再读、读这一段很可能接着读下一段。
为什么按"页"缓存,键是 (inode, 偏移)
文件缓存有几个设计要定。第一是粒度:缓存以多大单位存?内存管理的基本单位是页(4 KiB 一页),所以缓存也按页来——文件内容被切成一页一页地缓存。这跟上一篇按需分页的粒度天然对上:缺页时补的就是一页,正好从缓存里取一页。
第二是键:缓存里怎么区分"这是哪个文件的哪一块"?用两个东西拼成键——这个文件是哪个 inode(文件的唯一身份)、要的是文件内的第几页(偏移)。(inode, 页偏移) 这对组合,在整台机器上唯一确定"哪个文件的哪一页内容"。两个进程读同一个文件的同一页,键相同,就命中同一份缓存——这就是后面"多个读者共享"的基础。
Page Cache 和按需分页怎么接上
上一篇讲了按需分页:程序访问一个还没映射的虚拟页,缺页,内核补一页。那匿名内存(纯粹的新内存)补的就是一页空白物理页。可文件映射补的不能是空白页——你 mmap 了一个文件再读,读到的该是文件的内容,不是一片零。
这就轮到 Page Cache 出场。文件映射缺页时,内核算出"这一页对应文件的哪个 (inode, 偏移)",去 Page Cache 里取:命中了,直接把这页缓存映射进进程的页表,程序读到的是文件内容;没命中,就从磁盘把这一页读进 Page Cache,再映射给进程。这样一来,文件内容只在第一次被访问时读一次盘,之后无论谁再访问这一页(同一个进程也好、别的进程也好),都从缓存里拿。
mmap 和 read() 为什么该共用同一份缓存
一个文件既能用 mmap(映射进地址空间,像访问内存一样读),也能用 read()(显式读到缓冲区)。要是这两种读法各走各的、各自直接读盘,就会出现怪事:同一个文件,你 mmap 读到的和 read() 读到的可能对不上(一边读了新内容、另一边还是旧的),而且同一份内容被读了两遍盘。
正确的做法是让它们共用同一份 Page Cache:不管 mmap 还是 read(),要读文件的某一页,都先查 Page Cache,命中就用、没命中读盘填进去。于是整个系统对"这个文件的这一页内容"只有一份权威拷贝,所有读法都看它——既省了重复 I/O,又保证了一致性。上一篇按需分页把 mmap 的缺页接进了 Page Cache,后续 read() 也会接进来,读路径最终统一到这一层缓存。
代价:占内存,但可回收
Page Cache 不是白来的,它占着物理内存——读过的文件页都赖在内存里。好在这些页是"缓存",丢了也能从磁盘重新读回来,所以当系统内存紧张、需要腾地方给真正不能丢的东西时,内核可以回收缓存页(直接扔掉干净的、把脏的写回磁盘再扔)。也就是说 Page Cache 是"用完即弃"的内存占用:内存富余时尽量缓存、紧张时让位。这是它能放心占用大片内存的原因。下一篇咱们就看 Cinux 的 Page Cache 怎么实现——哈希表怎么组织、读盘的时机,以及它怎么跟按需分页的缺页路径接上。