shrinker:页 LRU 之外的 VFS(虚拟文件系统)缓存怎么回收

shrinker:页 LRU 之外的 VFS(虚拟文件系统)缓存怎么回收

这一篇第一次集中进入 Linux 文件系统。先认识三个会贯穿全文的名字:VFS(Virtual File System,Linux 在各种具体文件系统之上提供统一文件操作接口的内核层)、dentry(目录项缓存对象,记录一个路径名字的内存查找结果,不是磁盘目录项本身)和 inode(文件元数据对象,记录文件类型、权限、大小等信息,通常不保存普通文件的数据内容)。

例如进程打开 /home/alice/note.txt 时,可以先建立这张最小关系图:

text 复制代码
   open("/home/alice/note.txt")
        │
        ▼
   VFS 逐级解析路径名字
        │
        ├─ home     -> 一个 dentry
        ├─ alice    -> 一个 dentry
        └─ note.txt -> 一个 dentry
                           │
                           ▼
                         inode
                    (类型、权限、大小......)

dentry 回答"这个名字查到了什么",inode 回答"查到的这个文件是什么"。本文讨论的就是:访问结束后,这些内存对象为什么还能留下,以及内存紧张时谁有资格删掉它们。

主线第 6 篇讲过,buddy 管页,SLUB 把页切成 dentry、inode、vm_area_struct 等内核小对象。番外一又画过一条没有展开的分支:kswapd 和 direct reclaim 在扫描页 LRU 之外,还会进入 shrink_slab()。

前五篇番外已经把普通页的回收路径拆开了:

text 复制代码
   页 LRU
      ├─ 匿名 folio -> rmap -> swap -> 释放物理页
      └─ 文件 folio -> page cache(文件数据的内存缓存)
                       -> 必要时 writeback(把脏数据写回存储)-> 释放物理页

但执行一次大目录遍历后,/proc/meminfo 里可能增长的不只是 Cached,还有 Slab 和 SReclaimable;即使文件没有打开,内核也可能继续保留大量 dentry 和 inode。它们不在 inactive_file 或 inactive_anon 上,Multi-Gen LRU 也不负责判断一个 dentry 能不能删。

这一篇不枚举 Linux 里的所有 shrinker,也不重新泛讲所有 slab cache。全文只追一条具体路径:

text 复制代码
   路径 lookup(把 /a/b/c 逐级拆开查找)留下 dentry / inode
        │
        ▼
   内存压力触发 VFS superblock shrinker
   (superblock:文件系统接入目录树后,内核对该挂载的总描述对象)
        │
        ▼
   先 prune dentry,再 prune inode
        │
        ▼
   对象回到各自 kmem_cache
        │
        ▼
   SLUB 把全空 slab 的页还给 buddy

ovl_inode(OverlayFS 的 inode 对象;OverlayFS 是容器常用的分层文件系统)和 ext4_inode_cache(ext4 的 inode 对象缓存;ext4 是常见的磁盘文件系统)会出现在后文,不是为了横向介绍更多 slab,而是因为具体文件系统通常把 struct inode 嵌在自己的私有 inode 对象里;同一次容器文件访问真实会同时产生这些对象。

全文要证明的核心结论是:

VFS(Linux 统一文件接口层)先判断哪些 dentry/inode 已经没有语义上的使用者并结束其对象生命周期;SLUB 只负责管理对象槽位,并在整个 slab 变空后把页还给 buddy。

先用一张图把它和前文接起来:

text 复制代码
   内存压力:kswapd 或当前分配者进入 reclaim
                         │
                         ▼
                      vmscan
                         │
             ┌───────────┴───────────┐
             │                       │
             ▼                       ▼
      shrink_lruvec              shrink_slab
             │                       │
      anon/file folio          找到 VFS superblock shrinker
             │                       │
      swap/page cache          super_cache_count(统计候选对象)
             │                       │
      释放 folio 页             super_cache_scan(按额度扫描)
                                     │
                                     ▼
                              prune_dcache_sb(收缩 dentry cache)
                                     │
                                     ▼
                              prune_icache_sb(收缩 inode cache)
                                     │
                                     ▼
                       dentry / inode 对象被释放
                                     │
                                     ▼
                            某个 slab 全部为空?
                               │             │
                              否             是
                               │             │
                          留给后续复用       ▼
                                       SLUB 释放 slab folio
                                             │
                                             ▼
                                           buddy

本文所有源码结论以 Linux stable v6.12.65 为准,对应本文实际检出的 commit 是 39cb076c7dc7e44e3cab5c82ffda16a550ed8436。shrinker API 在近年发生过变化,阅读旧资料时尤其不要把旧版 register_shrinker() 的写法直接套到 6.12。

一、先分清四个层次:dentry 对象、VFS 缓存、slab 和物理页

"回收 dentry"至少跨过四层,不能把它简化成"SLUB 把 dentry 删掉"。

1.1 dentry 是 VFS 对象,不是一个独立物理页

struct dentry 描述一个路径分量(例如 /usr/bin/bash 中的 usr、bin、bash 各是一段)的缓存结果。正 dentry(positive dentry,已经找到文件并指向 inode)和负 dentry(negative dentry,已经确认这个名字不存在,因此不指向 inode)都能避免下次路径解析重新访问具体文件系统。

对象通常来自 dentry 这个专用 kmem_cache:

text 复制代码
   dentry kmem_cache
        │
        ├─ slab A: [dentry][dentry][dentry] ...
        ├─ slab B: [dentry][dentry][dentry] ...
        └─ slab C: [dentry][dentry][dentry] ...

VFS 的 dcache(dentry cache,目录项缓存)由 dcache_init() 初始化;v6.12.65 创建这个 cache 时明确带了:

text 复制代码
   SLAB_RECLAIM_ACCOUNT | SLAB_PANIC | SLAB_ACCOUNT

SLAB_RECLAIM_ACCOUNT 会让这些 slab 页计入 reclaimable slab 统计,但这个标志只表达"这类 cache 的内存存在回收路径",不等于 cache 中每个对象此刻都可以删。

1.2 SLUB 不知道 dentry 是否仍被 VFS 使用

SLUB 知道的是:

text 复制代码
   这个 object slot 是 allocated 还是 free;
   这个 slab 还有多少 inuse 对象;
   这个 slab 属于哪个 kmem_cache;
   整个 slab 是否已经空了。

它不知道的是:

text 复制代码
   dentry->d_lockref.count 是否仍大于 0;
   某个 inode 是否 dirty(内存中的元数据已修改但尚未写回)、
       正在 writeback(把修改写回存储)或还有引用;
   某项文件系统元数据(权限、大小、时间等管理信息)是否能从磁盘重新构造;
   释放对象会不会破坏子系统自己的树、哈希表或锁关系。

这些是 VFS 或具体文件系统才知道的语义。因此必须由子系统先挑出对象,再调用对象释放路径。

1.3 释放对象不等于立刻释放页

假设一个 4KB slab 有 21 个 192 字节的 dentry 槽位:

text 复制代码
   回收前
   ┌────┬────┬────┬────┬────┬────┬────────┐
   │用  │空  │用  │空  │用  │空  │ ...    │
   └────┴────┴────┴────┴────┴────┴────────┘
          partial slab,物理页还不能归还 buddy

   shrinker 又释放两个对象
   ┌────┬────┬────┬────┬────┬────┬────────┐
   │空  │空  │用  │空  │空  │空  │ ...    │
   └────┴────┴────┴────┴────┴────┴────────┘
          仍有一个 live object,页仍不能释放

   最后一个对象也释放
   ┌────┬────┬────┬────┬────┬────┬────────┐
   │空  │空  │空  │空  │空  │空  │ ...    │
   └────┴────┴────┴────┴────┴────┴────────┘
          empty slab,SLUB 才可能把页还给 buddy

这解释了两个常见现象:

  1. shrinker 报告释放了很多对象,MemFree 不一定按 对象数 × objsize 立刻增长。
  2. /proc/slabinfo 的 active_objs 可以明显下降,但 num_slabs 下降较少,因为剩余对象分散在多个 slab 中。

SLUB 的 __free_slab() 才是最后归还物理页的地方。它清除 slab 标记、更新统计,然后调用 __free_pages():

text 复制代码
   subsystem frees objects
        │
        ▼
   slab->inuse reaches 0
        │
        ▼
   SLUB __free_slab
        ├─ __folio_clear_slab
        ├─ mm_account_reclaimed_pages(1 << order)
        ├─ unaccount_slab
        └─ __free_pages

这里也回答了主线 Slab 篇留下的问题:kmem_cache_free() 只是把对象还给 cache;对象语义由子系统处理,整页何时回 buddy 由 SLUB 处理。

1.4 shrinker 不等于 kmem_cache_shrink()

内核里还有一个名字很像的 allocator 接口 kmem_cache_shrink(cache)。两者职责不同:

text 复制代码
   subsystem shrinker
      └─ 理解对象语义,主动让 allocated object 结束生命周期

   kmem_cache_shrink()
      ├─ flush_all(),收拢 per-CPU 状态
      ├─ 释放已经全空的 slab
      └─ 重排 partial list,让更满的 slab 优先继续分配

kmem_cache_shrink() 不会凭空删除仍 allocated 的 dentry 或 inode;如果子系统没有先释放对象,它最多整理 allocator 已知的 free slot 和 empty slab。反过来,子系统 shrinker 释放对象后,也仍需要 SLUB 把空 slab 页真正归还 buddy。

二、SReclaimable 和 SUnreclaim 到底是什么意思

用户态最容易看到的是:

bash 复制代码
grep -E '^(Slab|SReclaimable|SUnreclaim):' /proc/meminfo

例如:

text 复制代码
Slab:             177164 kB
SReclaimable:     128364 kB
SUnreclaim:        48800 kB

2.1 Slab 是两个 slab 分类之和

v6.12.65 的 fs/proc/meminfo.c 直接从两个 node 统计项读取:

text 复制代码
   SReclaimable = NR_SLAB_RECLAIMABLE_B
   SUnreclaim   = NR_SLAB_UNRECLAIMABLE_B
   Slab         = SReclaimable + SUnreclaim

SLUB 在给某个 kmem_cache 记账时,通过 cache 是否带 SLAB_RECLAIM_ACCOUNT 选择前一个还是后一个统计项。

因此它们首先是 cache/slab 页的记账分类,不是一次扫描后保证能释放的字节数。

2.2 SReclaimable 不是"现在立刻可以 free 的内存"

一个 reclaimable cache 里仍可能有:

  • 正在使用、引用计数不为零的对象;
  • 最近使用、第一次扫描只会被旋转到队尾的对象;
  • dirty 或 writeback 中的 inode;
  • 因锁竞争而本轮跳过的对象;
  • 分散在 partial slab 中、还不能凑出完整空 slab 的 free object;
  • per-CPU 状态和 RCU 延迟释放造成的短时统计差异。

所以 SReclaimable=500MB 不能推出"现在一定可以拿回 500MB"。它更像是"这些 slab 页属于设计上可通过相应回收路径缩减的 cache"。

2.3 SUnreclaim 也不是"直到重启永远不能释放"

SUnreclaim 表示这些 slab 页没有按 SLAB_RECLAIM_ACCOUNT 分类。对象仍可能因为正常生命周期结束而释放,cache 也可能缩小。它不承诺内存永久驻留;它只表示普通 slab reclaim 不能把这整个数字当成同类可回收供应。

这里只补一个术语边界:shrink_slab() 和 shrinker 是历史命名,框架能力并不限于 slab 对象;但本文后续不展开非 slab shrinker,所有 count/scan 和实验分析都回到 VFS 的 dentry/inode 主线。

三、VFS 接入回收框架需要什么 shrinker 协议

shrinker 不是一个单独的回收线程,也不是一种新的内存分配器。对本文的 VFS 案例来说,它是 struct super_block(superblock 在 Linux 源码中的结构名)注册给回收框架的一组回调:先报告有多少 dentry/inode 候选,再按框架给出的额度扫描。

在 v6.12.65 中,核心接口可以概括为:

text 复制代码
   struct shrinker
      ├─ count_objects(shrinker, shrink_control)
      ├─ scan_objects(shrinker, shrink_control)
      ├─ batch
      ├─ seeks
      ├─ flags
      ├─ private_data
      └─ nr_deferred

   struct shrink_control
      ├─ gfp_mask
      ├─ nid
      ├─ nr_to_scan
      ├─ nr_scanned
      └─ memcg

3.1 count_objects() 回答"候选大约有多少"

它不是必须做一次带锁的精确盘点。内核头文件明确允许计数在并发下变化。

返回值语义是:

返回值 含义
正整数 当前大约有这么多 freeable item,可据此计算扫描量
0 数量无法确定,或者本轮希望跳过
SHRINK_EMPTY 当前没有可回收对象

这里返回的是该 shrinker 定义的 item 数,不一定是页数,也不一定是字节数。VFS superblock shrinker 返回的是 dentry、inode 和文件系统私有对象(具体文件系统实现自己维护的额外缓存对象)数量的组合。

3.2 scan_objects() 回答"请扫描这些候选"

框架把目标写入 sc->nr_to_scan。回调应该:

  1. 尽量检查这么多对象;
  2. 用 sc->nr_scanned 记录实际处理量;
  3. 返回真正释放的对象数。

如果当前上下文可能死锁,回调返回 SHRINK_STOP。这不是"cache 已空",而是"当前 reclaim 上下文不要再调用我"。本次没做完的压力可以通过 nr_deferred 留给后续。

3.3 gfp_mask 是死锁边界的一部分

shrinker 可能在一次文件系统内部的内存分配中被间接调用。如果该分配使用了禁止再次进入文件系统的 GFP 上下文,VFS shrinker 不能反过来重入文件系统清理 inode。

super_cache_scan() 的第一道判断就是:

text 复制代码
   sc->gfp_mask 没有 __GFP_FS
        -> return SHRINK_STOP

这就是为什么"有可回收对象"不等于"每次 direct reclaim 都能收它"。分配上下文决定当前允许调用哪些释放路径。

3.4 NUMA_AWARE 和 MEMCG_AWARE

SHRINKER_NUMA_AWARE 表示框架传入的 sc->nid 有意义,回调可以只处理目标 node 上的对象。

SHRINKER_MEMCG_AWARE 表示回调能够按 sc->memcg 收缩目标 cgroup 的缓存。没有这个能力的 shrinker 在非 root memcg reclaim 中不会被当成可精确归属的目标调用。

6.12 通过 shrinker_alloc()、shrinker_register() 和 shrinker_free() 管理回调生命周期;旧资料常见的静态 shrinker 注册 API 已经不同。本文只需要知道:superblock 初始化时建立并注册 s_shrink,卸载时要等正在运行的回调结束;具体组织放到第六节和 VFS 结构一起看。

四、do_shrink_slab() 怎样决定扫描多少 VFS 对象

shrink_slab() 选中一个 shrinker 后,会进入 do_shrink_slab()。这层不是简单地把所有候选一次扫完,而是把回收压力、重建成本、批量大小和上次欠下的工作合起来。

4.1 核心变量

text 复制代码
   freeable  = count_objects() 返回的候选数
   priority  = 当前 reclaim 优先级;越接近 0,压力越强
   seeks     = 重建对象的相对成本启发值
   nr        = 上次未完成、保存在 nr_deferred 的扫描额度
   batch     = 每次交给 scan_objects 的批量,默认 128

若 seeks != 0,6.12 的主公式是:

text 复制代码
   delta = (freeable >> priority) * 4 / seeks
   total_scan = (nr >> priority) + delta
   total_scan <= 2 * freeable

如果 seeks == 0,内核认为重建不需要 I/O,使用更积极的:

text 复制代码
   delta = freeable / 2

seeks 不是实时测出来的磁盘寻道次数,而是一个历史性的相对成本权重。shrinker_alloc() 默认设为 DEFAULT_SEEKS=2。

4.2 priority 越低,扫描越凶

假设某个 VFS superblock shrinker 报告 freeable=1,000,000、seeks=2,忽略 deferred:

text 复制代码
   priority = 12
   delta ≈ (1,000,000 >> 12) * 2 ≈ 488

   priority = 4
   delta = (1,000,000 >> 4) * 2 = 125,000

   priority = 0
   delta = 1,000,000 * 2,刚好到 2 * freeable 上限

所以普通回收刚开始时不会一口气清掉全部文件元数据缓存;随着回收优先级下降,扫描量才明显增大。这和页 LRU 的"压力越大,扫描越深入"是一致的。

4.3 为什么要有 batch 和 nr_deferred

逐个调用 shrinker 会产生大量锁和函数调用开销,所以默认累计到 SHRINK_BATCH=128 左右再做一批。某次算出的额度不够一批,或者 SHRINK_STOP 提前终止,未完成部分会重新记入 nr_deferred。

text 复制代码
   本轮计算 80 个
        │
        └─ 低于 batch,先延期

   下轮又计算 70 个
        │
        ├─ 加上 deferred 后达到批量
        └─ scan_objects(nr_to_scan=...)

这避免小压力导致过于频繁的 cache 扫描,也避免每次被死锁边界跳过后永远丢失回收压力。

4.4 扫描对象数和释放页数是两套账

scan_objects() 返回对象数,/proc/vmstat 的 slabs_scanned 统计 nr_scanned。真正有 slab folio 变空时,SLUB 的 __free_slab() 调用 mm_account_reclaimed_pages() 记录页数。

随后 vmscan 的 flush_reclaim_state() 才把这些"在页 LRU 之外释放的页"计入全局 reclaim 的 sc->nr_reclaimed。

text 复制代码
   shrinker 账:扫描/释放了多少对象
                         │
                         ▼
   SLUB 账:是否因此出现完整空 slab,释放了多少页
                         │
                         ▼
   vmscan 账:本轮 reclaim 实际得到多少页

这里先抓住 VFS 主线:shrinker 的返回值说明释放了多少 dentry/inode 对象,只有 SLUB 真正释放全空 slab 后,vmscan 才得到可用于新分配的物理页。memcg 的归属问题只在第十节作为容器实验的观察边界补充。

五、VFS shrinker 怎样接回 kswapd 和 direct reclaim

传统 LRU 路径中,shrink_node_memcgs() 对每个目标 lruvec 做:

text 复制代码
   shrink_lruvec(lruvec, sc)
        │
        └─ 回收 anon/file folio

   shrink_slab(sc->gfp_mask, pgdat->node_id, memcg, sc->priority)
        │
        └─ 回收该 node / memcg 的子系统缓存

如果启用了 Multi-Gen LRU,root reclaim 会走另一套 lruvec 调度,但 shrink_one() 中同样是在尝试驱逐 folio 后调用 shrink_slab()。因此不能把 shrinker 理解成"只有传统 active/inactive LRU 才有的补丁"。

完整关系是:

flowchart TD A[&#34;分配路径发现水位不足&#34;] --> B{&#34;谁执行 reclaim?&#34;} B -->|&#34;后台&#34;| C[&#34;kswapd / balance_pgdat&#34;] B -->|&#34;同步&#34;| D[&#34;direct reclaim / try_to_free_pages&#34;] C --> E[&#34;shrink_node&#34;] D --> E E --> F{&#34;页 LRU 实现&#34;} F -->|&#34;传统 LRU&#34;| G[&#34;shrink_lruvec&#34;] F -->|&#34;Multi-Gen LRU&#34;| H[&#34;lru_gen shrink_one&#34;] G --> I[&#34;shrink_slab&#34;] H --> I I --> J[&#34;count_objects&#34;] J --> K[&#34;do_shrink_slab 计算扫描量&#34;] K --> L[&#34;scan_objects&#34;] L --> M[&#34;释放 dentry / inode / 私有缓存&#34;] M --> N[&#34;空 slab 页回 buddy&#34;]

这也修正番外一中的简化图:shrink_lruvec 和 shrink_slab 都属于 node/memcg 回收循环要做的工作,但 shrink_slab 不是 shrink_lruvec() 内部专门处理 dentry 的分支;在 6.12 的传统路径里,它们是相邻调用。

六、VFS 的真实实现:每个 superblock 一个 shrinker

"dentry shrinker"和"inode shrinker"作为口头说法容易让人误以为有两条全局回调。v6.12.65 的真实组织方式更精细:每个 super_block 有一个 s_shrink,同一个回调统一协调这个 superblock 的 dentry、inode 和文件系统私有缓存。

文件系统私有缓存是 super_operations(具体文件系统交给 VFS 的一组操作回调)提供的可选侧枝,源码上不能删掉,但本文不沿它继续展开;从这一节开始,仍只追 s_dentry_lru -> prune_dcache_sb() 和 s_inode_lru -> prune_icache_sb() 两条 VFS 路径。

6.1 静态结构

text 复制代码
   struct super_block
      │
      ├─ s_shrink ───────────────► struct shrinker
      │                              ├─ count_objects = super_cache_count
      │                              ├─ scan_objects  = super_cache_scan
      │                              ├─ batch = 1024
      │                              ├─ private_data = this super_block
      │                              └─ NUMA_AWARE | MEMCG_AWARE
      │
      ├─ s_dentry_lru ───────────► unused dentries
      │
      ├─ s_inode_lru ────────────► unused clean inodes
      │
      └─ s_op
          ├─ nr_cached_objects ──► FS 私有对象计数(可选)
          └─ free_cached_objects ► FS 私有对象扫描(可选)

superblock 分配时,把错误处理折叠后,源码里的主干赋值关系是:

text 复制代码
   s->s_shrink = shrinker_alloc(
       SHRINKER_NUMA_AWARE | SHRINKER_MEMCG_AWARE,
       "sb-%s", type->name)
   s->s_shrink->scan_objects  = super_cache_scan
   s->s_shrink->count_objects = super_cache_count
   s->s_shrink->batch         = 1024
   s->s_shrink->private_data  = s
   list_lru_init_memcg(&s->s_dentry_lru, s->s_shrink)
   list_lru_init_memcg(&s->s_inode_lru,  s->s_shrink)

这段初始化不是在回收对象,也没有在此刻创建 dentry 或 inode。它是在给当前 superblock: s 安装一套将来内存紧张时使用的回收能力。抽象过程如下:

text 复制代码
   创建一个 superblock:s
        │
        ▼
   为 s 创建 shrinker:s->s_shrink
   (这个文件系统实例的回收入口)
        │
        ▼
   绑定两个动作
        ├─ super_cache_count:先数有多少候选对象
        └─ super_cache_scan:再按给定额度扫描并释放
        │
        ▼
   private_data = s
   (回调被调用时,能找回"正在回收哪个 superblock")
        │
        ▼
   初始化两个空的候选池
        ├─ s_dentry_lru:以后放 unused(没有活跃引用的)dentry
        └─ s_inode_lru:以后放 unused clean(无活跃引用且无待写回修改的)inode
        │
        ▼
   挂载成功后注册 shrinker
        │
        ▼
   将来发生内存压力时,vmscan 才会调用 count -> scan

逐项看,它建立的是下面五层关系。

第一,shrinker_alloc() 创建的是"回收器描述对象",不是被回收的缓存本身。NUMA_AWARE | MEMCG_AWARE 告诉通用回收框架:这套 VFS 缓存可以按 NUMA node 和 memory cgroup(容器或进程组的内存记账与限制单位)定位,而不必每次全局乱扫。

第二,count_objects 和 scan_objects 把"先估算、后执行"拆开。回收框架先调用 super_cache_count() 获取候选规模,计算本轮压力下应该扫描多少,再把额度交给 super_cache_scan()。所以 count 不是释放,scan 才真正进入 dentry/inode 回收。

第三,所有 superblock 都复用相同的 super_cache_count() / super_cache_scan() 函数,private_data = s 用来区分实例。回调拿到 shrinker 后,通过 private_data 找回对应的 super_block,从而只查看这个挂载实例的 dentry/inode 候选池。

第四,batch = 1024 表示这个 shrinker 偏好的批量扫描粒度。它不是"每次固定释放 1024 个对象",也不是"候选不足 1024 就永远不收";最终扫描量仍由 freeable、priority、deferred work 等共同决定。

第五,list_lru_init_memcg() 只是把 s_dentry_lru 和 s_inode_lru 初始化成支持 memcg 的空候选池。以后 dentry/inode 变成 unused 时才会入队;初始化完成并不表示里面已经有对象。

一句话概括这段初始化:

text 复制代码
   给每个文件系统挂载实例预先准备一个回收入口,
   让它知道去哪里数 dentry/inode、怎样扫描、以及自己属于哪个 superblock。

挂载(把一个文件系统接到 Linux 目录树的某个目录上)完成后才注册 shrinker;卸载时必须等正在运行的 shrinker 退出,避免回调还在访问已经释放的 super_block。

6.2 list_lru 不是前文的页 LRU

s_dentry_lru 和 s_inode_lru 使用通用 list_lru(按近似冷热顺序组织可回收内核对象的链表框架)。名字里虽然也有 LRU,但链的是对象,不是 folio:

text 复制代码
   page LRU / MGLRU
      └─ folio:anon/file/unevictable

   list_lru
      └─ list_head:dentry、inode 等子系统对象

list_lru 能按 NUMA node 切分;启用 memcg 后,还能在每个 node 下按 memcg 切子列表。它为回调定义了几种扫描结果:

结果 动作
LRU_REMOVED 对象从 LRU 隔离
LRU_ROTATE 最近引用,移到队尾再给一次机会
LRU_SKIP 锁拿不到,本轮跳过
LRU_RETRY 状态变化,重启遍历
LRU_STOP 停止本次 walk

因此它也不是数学上精确排序的全局 LRU,而是一套支持并发、延迟更新和第二次机会的对象扫描框架。

七、dentry 怎样从"正在用"变成"shrinker 候选"

7.1 最后一个临时引用放下后,VFS 往往不立即删对象

路径解析拿到 dentry 时会增加引用。使用结束走 dput()(VFS 放下一个 dentry 引用的函数)。如果引用计数降到零,但 dentry 仍 hashed(仍在 dentry 哈希表中,路径查找还能找到它)、没有 DCACHE_DONTCACHE(明确要求不要缓存该 dentry 的标志)等必须立即删除的条件,retain_dentry() 会保留它:

text 复制代码
   dput(dentry)
        │
        ▼
   引用计数降到 0
        │
        ├─ 不值得缓存 / 已 unhashed(已从查找哈希表摘下)
        │      └─ 直接 kill
        │
        └─ 值得缓存
               ├─ 第一次:d_lru_add -> sb->s_dentry_lru
               └─ 已在 LRU:设置 DCACHE_REFERENCED

这里的"引用为零"不是对象内存已经 free,而是当前没有活跃使用者持有引用,它可以作为路径缓存继续存在。

7.2 负 dentry 也会进入缓存

访问一个不存在的名字:

text 复制代码
   stat("dir/not-found"):stat 是查询文件元数据的系统调用
        │
        ▼
   文件系统确认 ENOENT(文件不存在)
        │
        ▼
   缓存 negative dentry:d_inode == NULL
        │
        ▼
   下次同名 lookup 可以快速返回不存在

负 dentry 对构建系统、包管理器、容器镜像扫描很有价值,因为这些工作负载经常探测大量候选路径。但恶意或无界的唯一缺失名字也会制造很多缓存对象,所以它们同样必须可缩。

7.3 扫描时先给最近用过的 dentry 第二次机会

dentry_lru_isolate() 的核心判断如下。其中 d_lockref.count 是 dentry 的使用引用数,DCACHE_REFERENCED 是"最近访问过"的标记:

text 复制代码
   拿不到 dentry->d_lock
        -> LRU_SKIP

   d_lockref.count != 0
        -> 已重新活跃,从 unused LRU 移除

   DCACHE_REFERENCED 已设置
        -> 清 referenced,LRU_ROTATE

   否则
        -> 移到本地 dispose list(本轮待销毁对象的临时链表)
        -> shrink_dentry_list
        -> 从哈希和父子关系中摘除
        -> 解除对 inode 的关系
        -> dentry_free

这不是只看一个时间戳,也不是发现 count==0 就无条件 free。锁、引用、最近访问和 dentry 树关系都会影响结果。

八、inode 为什么必须在 dentry 之后收

dentry 通常指向 inode。只要缓存 dentry 还维持 inode 关系,inode 就可能无法成为真正的 unused 候选。

super_cache_scan() 因此明确按顺序做:

text 复制代码
   prune_dcache_sb()
        │
        └─ 先解除 dentry 对 inode 的钉住
              │
              ▼
   prune_icache_sb()
        │
        └─ 再尝试释放 unused clean inode
              │
              ▼
   具体文件系统 ->free_cached_objects()(可选私有缓存)

源码注释直接说明:先 prune dcache(dentry cache,目录项缓存),因为 icache(inode cache,inode 缓存)被它 pin 住(仍被引用,不能释放)。

8.1 inode 进入 LRU 的门槛更严格

__inode_add_lru() 会拒绝:

  • I_DIRTY_ALL:inode 仍脏;
  • I_SYNC:正在同步;
  • I_FREEING / I_WILL_FREE:生命周期正在结束;
  • i_count != 0:仍有引用;
  • superblock 不活跃;
  • mapping(这个 inode 所关联的文件数据页集合)不可 shrink。

所以前一篇 writeback 和本篇不是两条无关路径:dirty inode 不能像 clean unused inode 一样直接被 shrinker 回收,写回让它变干净后,才可能重新进入 inode LRU。

8.2 inode 扫描也有第二次机会

inode_lru_isolate() 遇到 I_REFERENCED(inode 最近被使用过的标记)会清标记并 LRU_ROTATE。遇到重新被引用、重新变脏或 mapping 不可缩的 inode,会把它从当前候选 LRU 中移除,等 iput(放下 inode 引用)、sync(把脏数据同步到存储)或最后一页 page cache 删除后再决定是否重新入队。

若 inode 仍挂有 buffer(块设备 I/O 使用的缓存元数据)或 page cache,在允许的场景下还可能先尝试释放 buffer、invalidate mapping(从 page cache 移除该 inode 的缓存页),再重试 inode 本身。由此也能看出:页缓存和 inode 对象缓存是两层结构,但生命周期确实会交叉。

8.3 文件系统 inode 往往不在 inode_cache 这一行

很多文件系统把 VFS inode 嵌在更大的私有结构中:

text 复制代码
   struct ext4_inode_info
      ├─ ext4 私有字段
      └─ struct inode vfs_inode

   struct ovl_inode
      ├─ overlayfs 私有字段
      └─ struct inode vfs_inode

这些对象分别从 ext4_inode_cache、ovl_inode 等专用 cache 分配。实验只盯着 /proc/slabinfo 的 inode_cache,很可能误以为 inode 没增长。应该根据当前文件系统一起观察它的私有 inode cache。

九、同一个 VFS shrinker 怎样协调 dentry 和 inode

9.1 count 阶段

对一个 superblock,候选数来自:

text 复制代码
   total_objects
      = list_lru_count(s_dentry_lru, nid, memcg)
      + list_lru_count(s_inode_lru, nid, memcg)
      + s_op->nr_cached_objects(sb, sc)   // 如果文件系统实现

然后通过:

text 复制代码
   vfs_pressure_ratio(total_objects)
      = total_objects
        * vm.vfs_cache_pressure(控制 VFS 缓存回收倾向的内核运行参数)
        / 100

返回给通用 shrinker 框架。

把公式翻译成抽象流程,就是:

text 复制代码
   通用 shrinker 框架调用 super_cache_count(shrinker, sc)
        │
        ▼
   shrinker->private_data 找回目标 superblock
   sc->nid / sc->memcg 确定"数哪个 node、哪个 cgroup"
        │
        ▼
   这个 superblock 已完成挂载、可以安全访问吗?
        │
        ├─ 否 -> 返回 0,本轮先不碰
        │
        └─ 是
             │
             ▼
          分别询问三个候选来源
             ├─ s_dentry_lru:有多少 unused dentry
             ├─ s_inode_lru:有多少 unused clean inode
             └─ 文件系统私有缓存:有多少可回收对象(可选)
             │
             ▼
          total_objects == 0?
             │
             ├─ 是 -> 返回 SHRINK_EMPTY,表示当前确实没有候选
             │
             └─ 否
                  │
                  ▼
             用 vfs_cache_pressure 调整报告值
                  │
                  ▼
             把"加权后的候选规模"返回给 do_shrink_slab()

这里有三个关键点。

第一,count 阶段只是在估算候选池规模 ,不会摘链表、不会调用 dentry_free(),也不会释放 slab 页。真正改变对象状态的是下一节的 scan 阶段。

第二,它数的不是这个文件系统当前拥有的全部 dentry/inode,而是目标 nid + memcg 范围内已经进入 s_dentry_lru / s_inode_lru 的候选。对象入队后仍可能并发地重新被引用或变脏,因此 count 里也可能暂时混有已经不适合回收的旧候选;scan 阶段必须逐个重新检查,不能把 count 返回值当成"保证能释放的对象数"。

第三,vfs_cache_pressure 调整的是"向通用回收框架报告得多积极",不会凭空创造或删除对象。例如真实候选合计为 10,000:

text 复制代码
   vfs_cache_pressure =  50 -> 向框架报告  5,000
   vfs_cache_pressure = 100 -> 向框架报告 10,000
   vfs_cache_pressure = 200 -> 向框架报告 20,000

最后一个 20,000 不表示内核突然多出 10,000 个 dentry/inode;它是一个回收权重。do_shrink_slab() 还会结合 priority、seeks、batch 和 deferred work,把这个报告值换算成本轮真正交给 super_cache_scan() 的 nr_to_scan。

源码没有为了得到绝对精确数字而锁住整个 superblock。count 与对象入队、重新被引用可以并发发生,因此它是低开销的瞬时估计;这也解释了为什么后面的 scan 必须重新检查每个对象此刻是否仍能释放。

9.2 scan 阶段

sc->nr_to_scan 会按候选对象在总量中的比例拆开。先看没有文件系统私有对象时的 dentry/inode 主线:

text 复制代码
   假设请求扫描 900 个对象:

   dentry candidates = 6000
   inode candidates  = 3000
   fs private        = 0
   total             = 9000

   则大约:
      dentry 扫 600
      inode  扫 300

如果具体文件系统实现了 nr_cached_objects() / free_cached_objects(),它的候选也参加同一比例计算,但这不改变"先 dentry、再 inode、最后可选私有缓存"的顺序。真实源码会给 dentry/inode 的扫描量额外加 1;有私有候选时也会给它加 1,以帮助 memcg cache 最终清空。扫描结果受并发变化影响,count 和 scan 之间不要求完全一致。

9.3 vm.vfs_cache_pressure 控制什么

默认通常是 100。高于 100 会放大 VFS shrinker 报告给通用框架的候选量,使 dentry/inode 更积极参与回收;低于 100 会保护它们。

bash 复制代码
cat /proc/sys/vm/vfs_cache_pressure

不要仅因为 SReclaimable 看起来大就长期设置极高值。过于积极会让路径 lookup 和 inode 读取频繁回到文件系统;设置为 0 又可能让 VFS cache 在内存压力下几乎不参与回收。它是性能与内存占用之间的策略旋钮,不是"清缓存按钮"。

十、容器实验需要知道的 memcg 边界

这一节只解释后文 Docker 实验为什么不能把 /proc/slabinfo 当成容器私有账本,不展开 memcg 回收算法。

superblock shrinker 带 NUMA_AWARE | MEMCG_AWARE。list_lru_add_obj() 会根据 dentry/inode 所在的 slab page 确定 NUMA node 和 memcg,把对象放进对应子列表;容器发生 memcg reclaim 时,VFS 因而可以只扫描归属于目标 cgroup 的 dentry/inode 候选。

但容器内读取 /proc/meminfo、/proc/slabinfo 和 /proc/sys/fs/dentry-state,常常看到的是宿主内核或 Docker VM 的全局统计。后文实验因此只比较前后 delta,并明确把 drop_caches=2 视为 Docker VM 全局操作。

cgroup v2 下,目标 cgroup 的 slab 分类应优先看:

bash 复制代码
grep -E '^(slab|slab_reclaimable|slab_unreclaimable) ' /sys/fs/cgroup/memory.stat

这已经足够解释本文实验边界;更细的 memcg shrinker 调度和页级记账不再展开。

十一、如何观察 dentry/inode 回收,而不只看一行 Slab

11.1 /proc/meminfo:看总量分类

bash 复制代码
grep -E '^(Slab|SReclaimable|SUnreclaim):' /proc/meminfo

适合回答"reclaimable slab 总体是否增长",不适合回答"哪个 cache 增长"。

11.2 /proc/slabinfo:看具体 cache

bash 复制代码
grep -E '^(dentry|inode_cache|ovl_inode|ext4_inode_cache) ' /proc/slabinfo

前几列是:

text 复制代码
   name active_objs num_objs objsize objperslab pagesperslab

后面的 slabdata active_slabs num_slabs sharedavail 中,num_slabs 能帮助判断对象下降是否真的变成整 slab 页下降。最后的 sharedavail 是格式兼容字段,不要误读成 empty/free slab 数;在 SLUB 上 active_slabs 也通常等于 num_slabs。

11.3 VFS 自己的对象计数

bash 复制代码
cat /proc/sys/fs/dentry-state
cat /proc/sys/fs/inode-state

dentry-state 的 6 个字段在 6.12 中是:

text 复制代码
   nr_dentry nr_unused age_limit want_pages nr_negative dummy

inode-state 保持兼容性的 7 个字段中,前两个最有用:nr_inodes 和 nr_unused。

这些是全局、并发更新的近似统计。它们和 slabinfo 的 active_objs 不要求逐项严格相等:VFS 生命周期、SLUB slot 状态、RCU 延迟释放和每 CPU 计数的观察时点都不同。

11.4 /proc/vmstat:看扫描事件

bash 复制代码
grep -E '^(slabs_scanned|pginodesteal|kswapd_inodesteal|drop_slab) ' /proc/vmstat
  • slabs_scanned:shrinker 回调报告实际扫描的 item 数,不是释放页数;
  • pginodesteal / kswapd_inodesteal:inode 回收中顺带清掉 mapping 页的相关事件;
  • drop_slab:显式执行 slab drop 的次数。

判断一次负载影响时应取前后 delta,不能把累计值当本进程独占结果。

11.5 shrinker debugfs

如果内核启用 CONFIG_SHRINKER_DEBUG 并挂载 debugfs(内核暴露调试信息使用的虚拟文件系统):

bash 复制代码
ls /sys/kernel/debug/shrinker
find /sys/kernel/debug/shrinker -maxdepth 1 -type d -name 'sb-*'
cat /sys/kernel/debug/shrinker/<shrinker-name>/count

目录名类似 sb-ext4:sda1-36。count 每行先是 cgroup inode id,后面是各 NUMA node 的对象数。

scan 文件允许手工触发某个 shrinker:

text 复制代码
   <cgroup inode id> <numa node id> <number to scan>

这是调试接口,会真实改变内核缓存;生产系统不要把它当日常观测命令。

十二、实验:制造 dentry/inode,并观察 VFS shrinker 的结果

这个实验做四件事:

  1. 创建 30,000 个空文件,制造正 dentry 和文件系统 inode cache;
  2. 查询 30,000 个互不相同的不存在名字,制造负 dentry;
  3. syncfs()(请求把该文件系统的脏数据和元数据写回存储)让刚创建的 inode 元数据变干净,避免 dirty 状态挡住 inode reclaim;
  4. 可选写入 drop_caches=2,通过 drop_slab() 调用 shrinker,然后再次观察 VFS cache。

它同时记录:

  • Slab、SReclaimable、SUnreclaim;
  • dentry 总数、unused 数、negative 数;
  • inode 总数和 unused 数;
  • 只列出 dentry、inode_cache、ovl_inode 和 ext4_inode_cache 四个与本案例直接相关的 slab,避免其他系统 cache 干扰主线。

12.1 完整代码

代码也保存在 assets/shrinker-vfs-cache-demo.c:

c 复制代码
#define _GNU_SOURCE

#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/utsname.h>
#include <unistd.h>

struct meminfo_snapshot {
    unsigned long slab_kb;
    unsigned long sreclaimable_kb;
    unsigned long sunreclaim_kb;
};

struct vfs_snapshot {
    unsigned long dentry_total;
    unsigned long dentry_unused;
    unsigned long dentry_negative;
    unsigned long inode_total;
    unsigned long inode_unused;
};

struct slab_row {
    char name[64];
    unsigned long active_objs;
    unsigned long num_objs;
    unsigned long objsize;
    unsigned long objperslab;
    unsigned long pagesperslab;
    unsigned long active_slabs;
    unsigned long num_slabs;
};

static const char *const watched_caches[] = {
    "dentry",
    "inode_cache",
    "ovl_inode",
    "ext4_inode_cache",
};

static void usage(const char *program)
{
    fprintf(stderr,
            "usage: %s [--files N] [--misses N] [--drop-caches]\n"
            "  --files N       create N empty files (default: 30000)\n"
            "  --misses N      look up N unique absent names (default: 30000)\n"
            "  --drop-caches   write 2 to /proc/sys/vm/drop_caches\n",
            program);
}

static unsigned long parse_count(const char *text, const char *option)
{
    char *end = NULL;
    unsigned long value;

    errno = 0;
    value = strtoul(text, &end, 10);
    if (errno != 0 || end == text || *end != '\0') {
        fprintf(stderr, "invalid value for %s: %s\n", option, text);
        exit(EXIT_FAILURE);
    }
    return value;
}

static bool cache_is_watched(const char *name)
{
    size_t i;

    for (i = 0; i < sizeof(watched_caches) / sizeof(watched_caches[0]); i++) {
        if (strcmp(name, watched_caches[i]) == 0)
            return true;
    }
    return false;
}

static int read_meminfo(struct meminfo_snapshot *out)
{
    FILE *fp;
    char line[256];
    char key[64];
    unsigned long value;
    int found = 0;

    memset(out, 0, sizeof(*out));
    fp = fopen("/proc/meminfo", "r");
    if (fp == NULL)
        return -1;

    while (fgets(line, sizeof(line), fp) != NULL) {
        if (sscanf(line, "%63[^:]: %lu", key, &value) != 2)
            continue;

        if (strcmp(key, "Slab") == 0) {
            out->slab_kb = value;
            found |= 1;
        } else if (strcmp(key, "SReclaimable") == 0) {
            out->sreclaimable_kb = value;
            found |= 2;
        } else if (strcmp(key, "SUnreclaim") == 0) {
            out->sunreclaim_kb = value;
            found |= 4;
        }
    }

    fclose(fp);
    return found == 7 ? 0 : -1;
}

static int read_numbers(const char *path, unsigned long *values, size_t count)
{
    FILE *fp;
    size_t i;

    fp = fopen(path, "r");
    if (fp == NULL)
        return -1;

    for (i = 0; i < count; i++) {
        if (fscanf(fp, "%lu", &values[i]) != 1) {
            fclose(fp);
            return -1;
        }
    }

    fclose(fp);
    return 0;
}

static int read_vfs_snapshot(struct vfs_snapshot *out)
{
    unsigned long dentries[6];
    unsigned long inodes[7];

    if (read_numbers("/proc/sys/fs/dentry-state", dentries, 6) != 0)
        return -1;
    if (read_numbers("/proc/sys/fs/inode-state", inodes, 7) != 0)
        return -1;

    out->dentry_total = dentries[0];
    out->dentry_unused = dentries[1];
    out->dentry_negative = dentries[4];
    out->inode_total = inodes[0];
    out->inode_unused = inodes[1];
    return 0;
}

static int parse_slab_row(const char *line, struct slab_row *row)
{
    char *slabdata;
    unsigned long shared_avail;

    memset(row, 0, sizeof(*row));
    if (sscanf(line, "%63s %lu %lu %lu %lu %lu",
               row->name, &row->active_objs, &row->num_objs,
               &row->objsize, &row->objperslab, &row->pagesperslab) != 6)
        return -1;

    slabdata = strstr(line, ": slabdata");
    if (slabdata == NULL)
        return -1;

    if (sscanf(slabdata,
               ": slabdata %lu %lu %lu",
               &row->active_slabs, &row->num_slabs,
               &shared_avail) != 3)
        return -1;

    (void)shared_avail;
    return 0;
}

static void print_slab_rows(void)
{
    FILE *fp;
    char *line = NULL;
    size_t capacity = 0;
    long page_size = sysconf(_SC_PAGESIZE);

    fp = fopen("/proc/slabinfo", "r");
    if (fp == NULL) {
        printf("  /proc/slabinfo unavailable: %s\n", strerror(errno));
        return;
    }

    printf("  %-20s %10s %10s %10s %10s\n",
           "cache", "active", "total", "slabs", "slab_kB");

    while (getline(&line, &capacity, fp) >= 0) {
        struct slab_row row;
        unsigned long slab_kb;

        if (line[0] == '#' || strncmp(line, "slabinfo", 8) == 0)
            continue;
        if (parse_slab_row(line, &row) != 0 || !cache_is_watched(row.name))
            continue;

        slab_kb = row.num_slabs * row.pagesperslab *
                  (unsigned long)page_size / 1024;
        printf("  %-20s %10lu %10lu %10lu %10lu\n",
               row.name, row.active_objs, row.num_objs,
               row.num_slabs, slab_kb);
    }

    free(line);
    fclose(fp);
}

static void print_snapshot(const char *label)
{
    struct meminfo_snapshot mem;
    struct vfs_snapshot vfs;

    printf("\n== %s ==\n", label);
    if (read_meminfo(&mem) == 0) {
        printf("meminfo_kB: Slab=%lu SReclaimable=%lu SUnreclaim=%lu\n",
               mem.slab_kb, mem.sreclaimable_kb, mem.sunreclaim_kb);
    } else {
        printf("meminfo: unavailable\n");
    }

    if (read_vfs_snapshot(&vfs) == 0) {
        printf("vfs: dentry_total=%lu dentry_unused=%lu dentry_negative=%lu "
               "inode_total=%lu inode_unused=%lu\n",
               vfs.dentry_total, vfs.dentry_unused, vfs.dentry_negative,
               vfs.inode_total, vfs.inode_unused);
    } else {
        printf("vfs counters: unavailable\n");
    }

    print_slab_rows();
}

static int create_files(int dirfd, unsigned long count)
{
    unsigned long i;

    for (i = 0; i < count; i++) {
        char name[64];
        int fd;

        snprintf(name, sizeof(name), "file-%08lu", i);
        fd = openat(dirfd, name,
                    O_CREAT | O_EXCL | O_WRONLY | O_CLOEXEC, 0600);
        if (fd < 0) {
            fprintf(stderr, "openat(%s) failed: %s\n", name, strerror(errno));
            return -1;
        }
        if (close(fd) != 0) {
            fprintf(stderr, "close(%s) failed: %s\n", name, strerror(errno));
            return -1;
        }
    }
    return 0;
}

static int do_negative_lookups(int dirfd, unsigned long count)
{
    unsigned long i;

    for (i = 0; i < count; i++) {
        char name[64];
        struct stat st;

        snprintf(name, sizeof(name), "missing-%08lu", i);
        if (fstatat(dirfd, name, &st, 0) == 0) {
            fprintf(stderr, "unexpected lookup result for %s: entry exists\n",
                    name);
            return -1;
        }
        if (errno != ENOENT) {
            fprintf(stderr, "fstatat(%s) failed: %s\n", name, strerror(errno));
            return -1;
        }
    }
    return 0;
}

static int drop_slab_caches(void)
{
    static const char command[] = "2\n";
    int fd;
    ssize_t written;

    fd = open("/proc/sys/vm/drop_caches", O_WRONLY | O_CLOEXEC);
    if (fd < 0)
        return -1;

    written = write(fd, command, sizeof(command) - 1);
    if (written != (ssize_t)(sizeof(command) - 1)) {
        int saved_errno = written < 0 ? errno : EIO;
        close(fd);
        errno = saved_errno;
        return -1;
    }

    if (close(fd) != 0)
        return -1;
    return 0;
}

static int cleanup_files(int dirfd, unsigned long count)
{
    unsigned long i;
    int failed = 0;

    for (i = 0; i < count; i++) {
        char name[64];

        snprintf(name, sizeof(name), "file-%08lu", i);
        if (unlinkat(dirfd, name, 0) != 0) {
            if (!failed)
                fprintf(stderr, "unlinkat(%s) failed: %s\n",
                        name, strerror(errno));
            failed = 1;
        }
    }
    return failed ? -1 : 0;
}

int main(int argc, char **argv)
{
    unsigned long file_count = 30000;
    unsigned long miss_count = 30000;
    bool should_drop = false;
    char directory[PATH_MAX];
    struct utsname uts;
    int dirfd = -1;
    int exit_code = EXIT_FAILURE;
    int i;

    for (i = 1; i < argc; i++) {
        if (strcmp(argv[i], "--files") == 0 && i + 1 < argc) {
            file_count = parse_count(argv[++i], "--files");
        } else if (strcmp(argv[i], "--misses") == 0 && i + 1 < argc) {
            miss_count = parse_count(argv[++i], "--misses");
        } else if (strcmp(argv[i], "--drop-caches") == 0) {
            should_drop = true;
        } else {
            usage(argv[0]);
            return EXIT_FAILURE;
        }
    }

    snprintf(directory, sizeof(directory),
             "/tmp/shrinker-vfs-cache-demo.%ld", (long)getpid());
    if (mkdir(directory, 0700) != 0) {
        fprintf(stderr, "mkdir(%s) failed: %s\n", directory, strerror(errno));
        return EXIT_FAILURE;
    }

    dirfd = open(directory, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
    if (dirfd < 0) {
        fprintf(stderr, "open(%s) failed: %s\n", directory, strerror(errno));
        goto out;
    }

    if (uname(&uts) == 0) {
        printf("machine=%s sysname=%s release=%s\n",
               uts.machine, uts.sysname, uts.release);
    }
    printf("page_size=%ld files=%lu misses=%lu drop_caches=%s\n",
           sysconf(_SC_PAGESIZE), file_count, miss_count,
           should_drop ? "yes" : "no");
    printf("test_directory=%s\n", directory);

    print_snapshot("before");

    if (create_files(dirfd, file_count) != 0)
        goto out;
    if (do_negative_lookups(dirfd, miss_count) != 0)
        goto out;

    /* Make created inode metadata clean so the VFS shrinker can reclaim it. */
    if (syncfs(dirfd) != 0) {
        fprintf(stderr, "syncfs failed: %s\n", strerror(errno));
        goto out;
    }

    print_snapshot("after populate + syncfs");

    if (should_drop) {
        if (drop_slab_caches() != 0) {
            fprintf(stderr,
                    "drop_caches=2 failed: %s "
                    "(run a disposable container with --privileged)\n",
                    strerror(errno));
            goto out;
        }
        usleep(500000);
        print_snapshot("after drop_caches=2");
    }

    exit_code = EXIT_SUCCESS;

out:
    if (dirfd >= 0) {
        if (cleanup_files(dirfd, file_count) != 0)
            exit_code = EXIT_FAILURE;
        close(dirfd);
    }
    if (rmdir(directory) != 0 && errno != ENOENT) {
        fprintf(stderr, "rmdir(%s) failed: %s\n", directory, strerror(errno));
        exit_code = EXIT_FAILURE;
    }
    return exit_code;
}

12.2 在 x86_64 容器中编译运行

只观察增长、不触发全局 drop 时,可以去掉 --privileged 和 --drop-caches:

bash 复制代码
docker run --rm --platform linux/amd64 \
  -v "$PWD/os/memory/assets:/work:ro" \
  -w /work \
  gcc:14-bookworm \
  sh -c 'gcc -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror \
      -o /tmp/shrinker-vfs-cache-demo shrinker-vfs-cache-demo.c && \
    /tmp/shrinker-vfs-cache-demo --files 30000 --misses 30000'

本文为了验证 shrinker,使用一次性 Docker VM 中的 privileged 容器:

bash 复制代码
docker run --rm --privileged --platform linux/amd64 \
  -v "$PWD/os/memory/assets:/work:ro" \
  -w /work \
  gcc:14-bookworm \
  sh -c 'gcc -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror \
      -o /tmp/shrinker-vfs-cache-demo shrinker-vfs-cache-demo.c && \
    /tmp/shrinker-vfs-cache-demo \
      --files 30000 --misses 30000 --drop-caches'

--platform linux/amd64 保证用户态程序在 x86_64 容器环境运行。--privileged 只为允许写 Docker VM 的 /proc/sys/vm/drop_caches;这项操作作用于整个 Docker VM 的内核缓存,不是只作用于本容器。不要在承载真实业务的宿主机上照抄。

12.3 本文真实输出

text 复制代码
machine=x86_64 sysname=Linux release=6.12.65-linuxkit
page_size=4096 files=30000 misses=30000 drop_caches=yes
test_directory=/tmp/shrinker-vfs-cache-demo.12

== before ==
meminfo_kB: Slab=177164 SReclaimable=128364 SUnreclaim=48800
vfs: dentry_total=6102 dentry_unused=1795 dentry_negative=1090 inode_total=30602 inode_unused=0
  cache                    active      total      slabs    slab_kB
  ovl_inode                  1278       1449         63       1008
  ext4_inode_cache          26578      37828       1351      43232
  inode_cache                2886       2886        111       1776
  dentry                     6918       7056        336       1344

== after populate + syncfs ==
meminfo_kB: Slab=243528 SReclaimable=190764 SUnreclaim=52764
vfs: dentry_total=96104 dentry_unused=61798 dentry_negative=31090 inode_total=90603 inode_unused=0
  cache                    active      total      slabs    slab_kB
  ovl_inode                 31165      31165       1355      21680
  ext4_inode_cache          56560      56560       2020      64640
  inode_cache                2886       2886        111       1776
  dentry                    96968     112350       5350      21400

== after drop_caches=2 ==
meminfo_kB: Slab=177200 SReclaimable=128340 SUnreclaim=48860
vfs: dentry_total=3941 dentry_unused=44 dentry_negative=4 inode_total=30040 inode_unused=0
  cache                    active      total      slabs    slab_kB
  ovl_inode                   952       1357         59        944
  ext4_inode_cache          26504      37856       1352      43264
  inode_cache                2886       2886        111       1776
  dentry                     4957       6783        323       1292

十三、怎样读这组真实结果

13.1 30,000 次不存在查询确实留下了 30,000 个负 dentry

text 复制代码
   dentry_negative:
      before                 1,090
      after populate        31,090
      delta                +30,000

这证明一次失败的路径 lookup 也可能制造可复用的内核缓存对象。程序没有创建 missing-* 文件,所以这部分没有对应 inode。

13.2 overlayfs 让一个用户文件牵动多层 inode cache

创建 30,000 个文件后:

text 复制代码
   ovl_inode active:
      1,278 -> 31,165,约 +30,000

   ext4_inode_cache active:
      26,578 -> 56,560,约 +30,000

容器根文件系统是 overlayfs,下面还有 ext4,因此同一批用户可见文件会牵动 overlay 层和底层文件系统的 inode 对象。inode_cache 一行完全没变,正好验证"不能只盯通用 inode_cache"。

全局 VFS inode_total 也从 30,602 增到 90,603,恰好增加 60,001,和"两层各约 30,000 个 inode"的现象吻合。与此同时 inode_unused 在采样点仍为 0:syncfs() 只解决 dirty 状态,不会解除缓存 dentry 等结构对 inode 的引用。这也从实验侧面解释了 super_cache_scan() 为什么必须先 prune dentry,再 prune inode。

13.3 SReclaimable 的增长来自真实 slab 页,不只是对象计数

text 复制代码
   SReclaimable:
      128,364 kB -> 190,764 kB
      delta = +62,400 kB

   dentry slab_kB:
      1,344 -> 21,400,delta = +20,056 kB

   ovl_inode slab_kB:
      1,008 -> 21,680,delta = +20,672 kB

   ext4_inode_cache slab_kB:
      43,232 -> 64,640,delta = +21,408 kB

三项 slab 页增长合计 62,136kB,和 SReclaimable 的 62,400kB 增量几乎对上。它们不是用 active × objsize 猜出来的,而是程序用 num_slabs × pagesperslab × page_size 计算实际分配给 cache 的 slab 页容量。

13.4 drop_caches=2 走的确实是 shrinker 路径

v6.12.65 的 drop_caches_sysctl_handler() 对 bit 2 执行:

text 复制代码
   echo 2 > /proc/sys/vm/drop_caches
        │
        ▼
   drop_slab()
        │
        ├─ 遍历 memcg
        ├─ 遍历 online node
        └─ shrink_slab(GFP_KERNEL, nid, memcg, priority=0)
              │
              └─ 重复到释放进展很小

它没有执行 bit 1 对应的 iterate_supers(drop_pagecache_sb),所以这里不是靠 drop_caches=3 把普通 page cache 和 slab 一锅端。

结果中 dentry cache 的 slab 容量从 21,400kB 降到 1,292kB,negative dentry 从 31,090 降到 4,说明 VFS shrinker 先销毁了可丢对象,SLUB 随后真的释放了大量全空 slab。

13.5 文件还在,但 dentry 可以不在内存里

执行 drop_caches=2 时,程序还没有 unlink(删除文件名与文件的目录关系)那 30,000 个真实文件;清理发生在打印之后。然而:

text 复制代码
   dentry_total: 96,104 -> 3,941

磁盘上的目录项(directory entry,持久化保存的"文件名到 inode 编号"关系;inode 编号是文件系统内定位 inode 的号码)仍然存在,内存里的 dentry 路径缓存却可以消失。下次访问文件时,VFS 会重新 lookup、重建 dentry,并按需读回 inode。这正是"缓存可回收"的含义,不是删除用户文件。

13.6 这次下降不全是本程序贡献

drop_caches=2 是 Docker VM 全局操作,仍可能同时回收实验之外的缓存,所以不能只凭 drop 前后的总差值给本程序记账。本次较干净的基线中:

text 复制代码
   populate 前:128,364 kB
   populate 后:190,764 kB,增加 62,400 kB
   drop 后:    128,340 kB,减少 62,424 kB

两组 delta 很接近,说明本次下降主要来自刚制造的 VFS cache;但 /proc/meminfo 仍是全局并发统计,严谨结论依然应以制造阶段的前后 delta 判断本程序增长,drop 阶段只用来验证这些对象确实能通过 VFS shrinker 缩小。

同理,SUnreclaim 的小幅变化也可能来自全局并发活动和对象生命周期,不能解释成 drop_slab 保证只改一行统计。

13.7 为什么 drop 后仍有 SReclaimable

剩下的可能包括:

  • 仍有活跃引用的 dentry/inode;
  • 文件存在但仍被其他内核结构暂时引用的对象;
  • 同一 Docker VM 中不属于本实验的可回收对象;
  • 无法凑成全空 slab 的内部碎片;
  • 本轮因锁或上下文条件跳过的对象;
  • 实验之外同时发生的系统活动。

这再次证明 SReclaimable 不是"按一下按钮必然归零"的承诺。

十四、drop_caches 不是正常生产回收策略

drop_caches=2 在本文只用来把实验路径和源码确定地对上。真实内存压力下,kswapd 或 direct reclaim 会按 priority、batch、cost 和 memcg/node 目标渐进扫描,不会每次都像 priority 0 的 drop_slab() 一样激进。

手工 drop 有几个副作用:

  1. 丢掉原本能加速路径 lookup 和 inode 读取的热缓存;
  2. 后续请求会发生 refault/relookup,延迟和 I/O 可能上升;
  3. 它是全局操作,容器内执行也可能影响整个宿主内核或 Docker VM;
  4. 测试"系统正常回收是否工作"时,手工 drop 会绕过真实压力演化,反而掩盖问题。

分析线上 slab 增长,更好的顺序是:

text 复制代码
   meminfo 看总量
      -> slabinfo 找 cache
      -> VFS/cgroup 计数判断归属
      -> vmstat/tracepoint 看是否扫描
      -> shrinker debugfs 看回调 count
      -> 最后才在隔离环境手工 scan/drop 验证

十五、常见误解

误解一:所有 slab 都能由 shrinker 回收

不是。只有子系统提供了可丢对象的语义和回收路径,相关 cache 才能在压力下缩。SUnreclaim 也不是 shrinker 的候选总池。

误解二:shrinker 直接释放 slab 页

通常不是。shrinker 回调先释放 dentry、inode 等对象;SLUB 发现整 slab 为空,才释放页。

误解三:count_objects() 返回多少,就一定释放多少

不是。count 是并发下的估计;scan 还会遇到引用、recently referenced、dirty、锁竞争和 GFP 死锁边界。

误解四:slabs_scanned 是释放页数

不是。它统计 shrinker 扫描的 item 数。真正归还的 slab 页通过 mm_account_reclaimed_pages() 进入另一套 reclaim 记账。

误解五:dentry 引用为 0 就已经 free

不是。引用为 0 的 hashed dentry 常被保留在 s_dentry_lru,以便下次快速 lookup;它只是具备成为回收候选的前提。

误解六:dirty inode 也能直接从 inode LRU 丢掉

不是。dirty/sync inode不会按普通 unused clean inode路径直接入队回收。writeback 和 shrinker 在这里前后衔接。

误解七:Multi-Gen LRU 会给 dentry/inode 分代

不是。MGLRU 管 folio 的冷热;dentry/inode 使用各子系统的 list_lru 和 shrinker。两条路径在 vmscan 中协同,但数据结构不同。

误解八:文件没删,dentry/inode 就不能回收

不是。文件在磁盘上的存在与内存缓存对象的存在是两回事。缓存可以丢,下次从文件系统重新构造。

误解九:容器里看到的 /proc/slabinfo 就是本容器账单

通常不是。它往往是宿主内核全局视角;应结合 cgroup v2 memory.stat 和前后 delta。

十六、把整个番外系列收束起来

从番外一到番外六,Linux 内存压力下的主线现在可以完整画成:

text 复制代码
   alloc_pages 发现 watermark 紧张
        │
        ├─ 唤醒 kswapd:后台恢复水位
        └─ 必要时 direct reclaim:当前分配者同步付出延迟
        │
        ▼
   shrink_node / memcg reclaim
        │
        ├─ 页 LRU / Multi-Gen LRU
        │      │
        │      ├─ 匿名 folio
        │      │      ├─ rmap 找 PTE
        │      │      ├─ swap entry / swap cache
        │      │      └─ 换出后释放页
        │      │
        │      └─ 文件 folio
        │             ├─ clean:从 page cache 移除
        │             └─ dirty:先 writeback,再回收
        │
        └─ shrink_slab
               │
               ▼
          VFS superblock shrinker
               │
               ├─ prune dentry list_lru
               └─ prune inode list_lru
                      │
                      ▼
               VFS 释放缓存对象
                      │
                      ▼
               SLUB 释放全空 slab 页
                      │
                      ▼
                    buddy

最后需要记住六个结论。

第一,页 LRU 不是 Linux 回收的全部。

匿名页和文件页由 folio LRU/MGLRU 管;本文的 dentry/inode 使用 VFS list_lru,通过 per-superblock shrinker 接入 vmscan。shrinker 框架还能承载其他缓存,但不属于本文展开范围。

第二,allocator 和对象所有者职责不同。

SLUB 管对象槽位和 slab 页,不知道 dentry/inode 能不能丢;VFS 判断语义可回收性,SLUB 负责最终归还空 slab。

第三,shrinker 是 count/scan 协议,不是一键清空。

priority、seeks、batch、deferred、GFP 上下文、NUMA 和 memcg 共同决定本轮扫谁、扫多少。

第四,dentry 必须先于 inode 收。

dentry 会钉住 inode;dirty inode 又依赖 writeback 先变干净。这把本篇与文件页回收和番外五 writeback 真正接了起来。

第五,SReclaimable 是分类统计,不是保证可立即释放的容量。

必须结合具体 slab、对象引用、slab 利用率和真实扫描结果判断。

第六,回收最终仍要回到 buddy 才能满足新的页分配。

无论起点是 page cache folio、swap 出去的匿名页,还是 shrinker 释放的 dentry,最终目标都是让物理页重新成为 buddy 可分配的空闲页。

参考源码

本文源码判断基于 Linux stable v6.12.65:

相关推荐
我爱cope5 天前
【操作系统 | 计算机硬件:CPU、内存与 I/O 如何支撑操作系统?】
学习·操作系统
NineData6 天前
NineData 与统信服务器操作系统完成兼容认证
数据库·oracle·操作系统·软件·数据库管理工具·ninedata·统信
DataScience_20196 天前
系统|dup2:文件描述符复制的艺术
操作系统
Tairitsu_H6 天前
[Linux系统] 自动化构建make / Makefile | 进度条
linux·操作系统·makefile·make
我爱cope7 天前
【操作系统 | 开篇:什么是操作系统?从裸机到多任务系统】
学习·操作系统
流浪0017 天前
Linux系统篇36——线程(一) 线程的概念、本质和Linux的实现方式
linux·面试·操作系统·线程
OpenPomeloxCommunity8 天前
Linux驱动基础(三):firmware的声明与加载
linux·操作系统
云计算练习生8 天前
什么是系统调用?为什么程序访问硬件必须经过它
linux·windows·操作系统·系统调用·操作系统原理
玄芯散人8 天前
【筑基·059】Linux命令行入门:工程师的操作系统
linux·操作系统·命令行
云计算练习生10 天前
什么是内核?操作系统内核到底管哪些事
linux·windows·操作系统·内核·shell