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
这解释了两个常见现象:
- shrinker 报告释放了很多对象,
MemFree不一定按对象数 × objsize立刻增长。 /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。回调应该:
- 尽量检查这么多对象;
- 用
sc->nr_scanned记录实际处理量; - 返回真正释放的对象数。
如果当前上下文可能死锁,回调返回 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 才有的补丁"。
完整关系是:
这也修正番外一中的简化图: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 的结果
这个实验做四件事:
- 创建 30,000 个空文件,制造正 dentry 和文件系统 inode cache;
- 查询 30,000 个互不相同的不存在名字,制造负 dentry;
syncfs()(请求把该文件系统的脏数据和元数据写回存储)让刚创建的 inode 元数据变干净,避免 dirty 状态挡住 inode reclaim;- 可选写入
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 有几个副作用:
- 丢掉原本能加速路径 lookup 和 inode 读取的热缓存;
- 后续请求会发生 refault/relookup,延迟和 I/O 可能上升;
- 它是全局操作,容器内执行也可能影响整个宿主内核或 Docker VM;
- 测试"系统正常回收是否工作"时,手工 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:
include/linux/shrinker.h:struct shrink_control、struct shrinker、返回值与 flagsmm/shrinker.c:do_shrink_slab()的 priority/seeks/batch/deferred 算法mm/shrinker.c:shrink_slab()、shrinker_alloc()、shrinker_register()、shrinker_free()mm/vmscan.c: 传统 LRU 路径shrink_node_memcgs()中的shrink_lruvec()/shrink_slab()mm/vmscan.c: Multi-Gen LRU 路径shrink_one()中的shrink_slab()mm/vmscan.c:flush_reclaim_state()对 slab 页的回收记账mm/slub.c:__free_slab()最终调用__free_pages()mm/slub.c:__kmem_cache_shrink()释放 empty slab、重排 partial listmm/slab.h:SLAB_RECLAIM_ACCOUNT选择 slab vmstat 分类mm/slab_common.c:/proc/slabinfo的字段格式与slabdata输出fs/proc/meminfo.c:Slab、SReclaimable、SUnreclaim的计算fs/super.c:super_cache_count()/super_cache_scan()fs/super.c: per-superblock shrinker 和两个 memcg-awarelist_lru的初始化fs/dcache.c:d_lru_add()、retain_dentry()、dput()fs/dcache.c:dentry_lru_isolate()/prune_dcache_sb()fs/dcache.c:dentrycache 的SLAB_RECLAIM_ACCOUNTfs/inode.c:__inode_add_lru()/iput_final()fs/inode.c:inode_lru_isolate()/prune_icache_sb()mm/list_lru.c: 按 NUMA node / memcg 把 VFS 对象加入list_lrufs/drop_caches.c:drop_caches=2 -> drop_slab()mm/vmscan.c:drop_slab()以 priority 0 调用shrink_slab()Documentation/admin-guide/mm/shrinker_debugfs.rst: shrinker debugfs 的count/scan接口