目录项缓存:别每次 open 都把路径从头解析一遍
你
open("/etc/passwd"),内核不是一步到位拿到文件的——它得从根目录开始,一层层拆开这条路径:"根"下找etc、etc下找passwd,每找一层都可能要去磁盘上读那个目录的内容。更要命的是,你过一会儿再open("/etc/passwd"),内核又把这条路径从头拆一遍、又可能把刚读过的目录再读一遍。明明第一次解析完,答案就不变了(除非有人删了或改名了),何苦每次重算?目录项缓存(directory entry cache,简称 dentry cache 或 dcache)就是为省这笔重复功夫设计的——它把"解析过的路径段"记下来,下次直接查答案。这一篇咱们讲它记什么、怎么记才不出错,以及一个看着不起眼却要命的规矩:缓存项要钉住它指向的 inode。下一篇看 Cinux 怎么实现。
先垫底:路径解析是一层一层拆的
要理解为什么需要缓存,先得看清"打开一个文件"在内核里到底发生了什么。一条路径 /etc/passwd,内核不是拿来就能用的,它得逐段解析:从根目录的 inode 出发,先在根目录里找名字叫 etc 的那一项,拿到 etc 的 inode;再在 etc 目录里找 passwd,拿到 passwd 的 inode——这才是你要打开的文件。每"找一层"叫一次目录查找,而对一个真实的文件系统(ext2 这种),每一次目录查找都可能意味着去磁盘上把那个目录的数据块读进来、在里头逐项比对名字。
更要命的是路径往往不止两层,而且同一个目录在一次运行里会被反复翻——/etc 下你打开 passwd、又打开 hosts、又打开 resolv.conf,每次都得先把 /etc 这个目录读进来扫一遍。磁盘读是慢的、是数量级上比内存操作慢的操作,重复读同一个目录,亏得明明白白。
缓存记什么:从"父目录 + 名字"到"子目录项"
目录项缓存的思路很直接:第一次解析 /etc/passwd,把"在根目录下找 etc 得到 etc_inode""在 etc 目录下找 passwd 得到 passwd_inode"这两个结果记下来;下次再解析 /etc/passwd、或任何经过 /etc 的路径,直接查这个记录,不用再去问磁盘。
记的映射长这样:(父目录, 下一级的名字) → 子目录项。一对"父目录 + 名字"唯一确定一个解析结果。注意它缓存的是路径上的一段段,不是整条路径——这样 /etc/passwd 和 /etc/hosts 能共享"/etc 这一段"的缓存,因为它们的"父目录 + 名字"前缀相同。
key 为什么不直接用整条路径字符串
一个直觉是:干脆拿整条路径字符串(/etc/passwd)当 key,简单。可路径字符串做 key 有两个麻烦。一是慢:每次都要拼字符串、逐字符比对。二是会漏:同一条逻辑路径,可能有好几种字符串写法——符号链接、.、..、挂载点都能让"同一个地方"对应不同的字符串。你缓存了 /etc/passwd,可用户走的是 /etc/./passwd,或者经过一个指向 /etc 的符号链接绕过来,字符串对不上,缓存就白存了。
所以更稳的 key 是 (父目录的 inode, 下一级的名字)。它不依赖路径怎么写,只依赖"父目录是哪个 inode + 子项叫什么名"——而同一个逻辑目录在一次运行里始终是同一个 inode(文件系统按 inode 号缓存,保证同一个号就是同一个对象)。这么一来,不管你从哪条字符串路径绕过来,只要最终落在同一个父目录、找同一个名字,缓存都认得。这个"用 inode 而不是字符串做锚点"的选择,是缓存正确性的根基。
pin:缓存项要钉住它指向的 inode
缓存里存着"这个父目录下的这个名字,解析到这个子 inode"。这里有个容易忽略、却绝不能省的规矩:缓存得钉住(pin)它指向的那个 inode——只要这个映射还在缓存里,这个 inode 就不能被释放。
为什么这么较真?因为缓存的目的是让 open 直接拿到答案、不用重算。设想缓存只存"指针",却不增加这个 inode 的引用:那么某次缓存命中,open 拿到这个 inode 指针正要用,可这时别的路径刚好把这个 inode 的引用计数减到了零、把它释放了——open 手里的指针瞬间成了悬空的,一用就崩。缓存承诺了"这个映射有效",就得对这个承诺负责,而负责的方式就是缓存自己持有一份引用:映射在,inode 就活着。
这条规矩在 Linux 里有个响亮的名字——「dcache 是 icache 的主人」(dcache is the master of the icache):目录项缓存是 inode 缓存的"主人",只要一个目录项还在,它指向的 inode 就被钉住、不能回收;只有先释放目录项,才可能连带释放 inode——inode 的生命周期,被它身上挂着的目录项托管着。下一篇你会看到 Cinux 的实现怎么落地这条规矩(给 child 加一份引用)。
失效:名字没了,缓存就得忘掉
缓存记的是"这个名字解析到这个 inode"。一旦这个名字不在了——文件被 unlink 删掉、目录被 rmdir 删掉、或者 rename 改了名、搬了家——这条记录就成了过期信息:名字还在缓存里,可它指向的东西已经变了或没了。要是忘了清掉,下次有人 open 这个名字,缓存命中、直接返回那个旧 inode——可那文件其实早删了,返回的是个不该再存在的对象。这就是为什么 unlink/rmdir/rename 这几个会动名字的操作,成功之后都必须让缓存把对应条目作废。失效和缓存是一对,缺了失效,缓存就从"省事"变成"撒谎"。
淘汰与 negative:这版没做的两件事
缓存还有两个进阶的玩法,值得知道,虽然 Cinux 这版都没做。
一个是淘汰(eviction)。上面说的缓存只增不减(除了失效),用得越久条目越多,内存只涨不落。一个 production 的缓存得有上限:挑那些最久没用到的条目扔掉,腾地方给新的——这套叫 LRU(least recently used,最近最少使用)淘汰。Linux 的 dcache 就有,内存吃紧时自动收缩。Cinux 这版不做,因为一个玩具 OS 一次启动解析的目录有限,无界也撑得住;要做成有界的,加 LRU + 上限就是后续工程。
另一个是 negative dentry(负向目录项缓存)。普通的缓存只记"找到了的"——查到一个名字,记下它指向哪个 inode。可一个目录下查一个不存在的名字,也是要花力气的(读目录、逐项比对、最后发现没有);如果这个名字反复被查(比如程序反复 open 一个配置文件,而它其实没装),每次都白查一遍。negative dentry 把"查过、确定没有"也记一笔,下次查同一个名字直接知道失败,省掉那趟白查。Cinux 不缓存失败——查不到就直接返回,不进表。
这一篇留下了什么
目录项缓存就这些道理:它把逐段解析过的路径记成"(父目录, 名字) → 子目录项",下次直接查、不重算磁盘;key 用 inode 不用字符串,锚住逻辑位置不被同义路径骗;缓存项要 pin 住它指向的 inode(dcache 是 icache 的主人),保证命中返回的指针永远活着;名字一变就得失效,否则缓存会撒谎;再进阶还有 LRU 淘汰和 negative 缓存,是 production 的标配。下一篇咱们就看 Cinux 的 DentryCache 怎么把这套道理实现出来、又砍掉了哪些进阶。