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,还有 SlabSReclaimable;即使文件没有打开,内核也可能继续保留大量 dentry 和 inode。它们不在 inactive_fileinactive_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 中的 usrbinbash 各是一段)的缓存结果。正 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/slabinfoactive_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。

二、SReclaimableSUnreclaim 到底是什么意思

用户态最容易看到的是:

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

例如:

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

2.1 Slab 是两个 slab 分类之和

v6.12.65fs/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_AWAREMEMCG_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,000seeks=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 为什么要有 batchnr_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/vmstatslabs_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_lruvecshrink_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_objectsscan_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_lrus_inode_lru 初始化成支持 memcg 的空候选池。以后 dentry/inode 变成 unused 时才会入队;初始化完成并不表示里面已经有对象。

一句话概括这段初始化:

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

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

6.2 list_lru 不是前文的页 LRU

s_dentry_lrus_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_cacheovl_inode 等专用 cache 分配。实验只盯着 /proc/slabinfoinode_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、seeksbatch 和 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_AWARElist_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_inodesnr_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-36count 每行先是 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。

它同时记录:

  • SlabSReclaimableSUnreclaim
  • dentry 总数、unused 数、negative 数;
  • inode 总数和 unused 数;
  • 只列出 dentryinode_cacheovl_inodeext4_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.65drop_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

相关推荐
liang_jy20 小时前
文件管理(一)—— 初识文件管理
面试·操作系统
liang_jy20 小时前
文件管理(二)—— 文件结构
面试·操作系统
liang_jy2 天前
内存管理(七)—— 内存映射
面试·操作系统
liang_jy2 天前
内存管理(六)—— 进程的内存映像
面试·操作系统
纵有疾風起2 天前
处理机调度算法深度对比:FCFS到多级反馈队列
操作系统·进程与线程·调度算法·408考研·处理机调度·fcfs·sjf
liang_jy2 天前
内存管理(四)—— 传统物理内存非连续分配管理方式
面试·操作系统
liang_jy3 天前
内存管理(二)—— 覆盖与交换
面试·操作系统
liang_jy3 天前
内存管理(一)—— 内存管理的概念
面试·操作系统
^yi3 天前
【Linux系统编程】对操作系统的理解
linux·运维·服务器·操作系统·系统调用·os