业务进程退出卡住:一次 FUSE readdir page lock 热点排查
这篇记录一次排障过程:从业务进程退出卡住、线程栈落到 __lock_page 开始,先用函数级 SystemTap 看这个 page lock 热点像哪类问题,再用字段级 SystemTap 往 cached readdir 里面看,最后回到 FUSE 源码解释为什么会卡住。
太长不看版
这个案例的表面现象是:业务进程退出时卡住,相关线程迟迟不能被完整回收。继续看线程状态和栈,卡点落在 Nydus/FUSE 的目录读取路径上。
可以先用一个图书馆类比理解:nydusd 像原始馆藏,目录项最初从这里来;第一次没有缓存时,FUSE 像抄写员,从 nydusd 拿目录项,同时写进内核里的公共抄本;后续使用缓存时,FUSE 像读抄本的人,拿着自己的书签从公共抄本里继续读。
正常情况下,第一次读目录时,FUSE 从 nydusd 拿目录项,同时把目录项写进公共抄本;后续再读时,reader 拿着自己的书签从这份抄本里继续读,读完一条记录,书签就往前挪。
这次卡住的过程可以这样理解:内核认为公共抄本是可用的,但 reader 一翻到抄本开头,就遇到一条读不下去的空记录;书签没有往前挪,下一轮又翻回同一页,表现成目录遍历线程一直卡住。
从修复方向看,它和上游后来修过的 FUSE 目录缓存竞争问题能对上。工程上更稳妥的处理,是升级到包含该修复的内核版本,比如较新的 Ubuntu 22.04 默认内核或 Ubuntu 24.04;旧 Ubuntu 20.04 / 5.4 内核线需要把这个问题当成目录遍历卡死风险来评估。
现象:进程退出后,仍有目录遍历线程卡在 D 状态
从进程层面看,业务进程已经进入退出后的 <defunct> 状态;从线程层面看,同一线程组里还有目录遍历线程处在 D 状态,也就是这些 task 还没有退出,只是卡在内核不可中断等待里。
这类线程的代表性栈是:
1 | __lock_page |
这条栈给了下一步方向:目录遍历线程已经卡在 overlay/FUSE 读目录这条内核调用链里。接下来先补一点读目录的背景知识,再回到 SystemTap 输出看它到底像哪类问题。
背景知识:读 overlay/FUSE 目录会经过哪些层
下面只补这次排障需要的三块背景:应用和 VFS 怎么读目录,FUSE directory cache 怎么写入和重放,以及这条 overlay 调用链怎么落到 lower FUSE。
目录读取的三层视角
同一次目录读取,在不同位置看到的入口和术语不一样。如果把这些层次混在一起看,就很容易误以为 readdir()、getdents64()、FUSE READDIR 是一一对应的。分成三层看会更清楚。
第一层是应用/C 库视角。业务进程或它的运行时库,通常只是像下面这样读一个目录流:
1 | DIR *dir = opendir(path); |
这里的 DIR *dir 可以先理解成“这次打开目录后的读取状态”。readdir(dir) 每次向用户代码返回一条目录项,但它可能只是消费 C 库已经缓存好的目录项,不一定每次都进入内核。
第二层是 Linux/VFS 视角。C 库目录流底下有一个目录 fd;当 C 库自己的目录缓冲区不够时,Linux/glibc 才会通过这个 fd 向内核补一批目录项,常见入口是 getdents64(fd, buf, count)。更准确地说,getdents64() 是 Linux 上补充目录项的内核入口;一次 readdir(dir) 调用不一定对应一次 getdents64()。
进入内核后,VFS 会把这个 fd 找回对应的 struct file。struct file 可以先理解成“内核里的这次打开目录实例”。它里面的 file->f_pos 记录这个目录流在内核看来读到哪里了;VFS 调文件系统 readdir 回调前,会把它放进 ctx->pos,回调结束后再写回去。源码位置是 fs/readdir.c:63-68;getdents64 入口在 fs/readdir.c:353-371。
1 | 应用/C 库: |
下面先把后面会用到的 fd、struct file、ff 和 fi 的关系讲清楚。
fd、struct file、ff 和 fi 的关系
前面说 getdents64(fd, ...) 会找到这个 fd 对应的 struct file。这里要多分清一个点:struct file 表示一次打开目录实例,不等于目录 inode 本身。
先看同一个 fd。VFS 通过 fd 找到这次打开目录对应的 struct file 后,后面几个 FUSE 变量都是顺着这个 file 取出来的:
1 | getdents64(fd, ...) |
再看同一个目录被打开多次。这里保留路径名是为了强调:不同 fd 来自同一个目录,所以它们可以共享 inode 级状态。
1 | opendir("/mnt/x") |
所以同一个目录的多次打开会共享 inode 级的目录 cache 和 fi->rdc,但每个打开实例都有自己的 ff->readdir 状态。
第三层:FUSE readdir 视角
到了 FUSE readdir 这一层,先不用急着记住每个字段,先看它从 VFS 进入后会分成哪两条路:uncached 路径向 nydusd 请求目录项,cached 路径从已有目录 cache 里读目录项。这里的 nydusd 是这次 FUSE 文件系统的用户态服务端;内核里的 FUSE readdir 需要向它请求真实目录项。
cache 写入、重放和偏移推进,后面会用 "a.py" / "long_name_b.py" 的例子再展开。这里先看 cached / uncached 两条分支的大概关系:
1 | file->f_op->iterate_shared(file, ctx) |
这张图里的几个对象,可以用图书馆类比来记:
nydusd:像原始馆藏,目录项最初从这里来。- FUSE uncached readdir:像抄写员,从
nydusd拿目录项,一边交给 VFS,一边写进公共抄本。 - FUSE cached readdir:像后续读抄本的人,直接从公共抄本里重放目录项。
file->f_mapping:对应公共抄本,也就是这个目录 inode 的 page cache,里面保存 FUSE directory cache 字节流。fi->rdc.cached/size/pos:对应抄本状态,记录这份 cache 是否有效、已有多大、构建到哪个目录逻辑位置。ff->readdir.cache_off:对应 cached readdir 的个人书签,也就是 FUSE directory cache 字节流里的缓存字节偏移。ff->readdir.pos:对应这次 cached readdir 在目录逻辑位置上的进度,和 VFS 传下来的ctx->pos属于同一个坐标系。
图里标签写不下完整字段名,所以把 ff->readdir.cache_off 简写成了 cache_off。
再把这个类比放回前面两层,就能看到完整链路里每一层分别维护什么位置、读写哪一份状态:
1 | 应用/C 库: |
有了这张全链路图,下面再单独拆开“公共抄本”:FUSE directory cache 到底存在哪里、按什么偏移写入和重放。
FUSE readdir 的目录缓存模型
一个贯穿例子:第一次读写入 cache,后续读重放 cache
接下来把上面图里的“公共抄本”和“个人书签”用一个小目录具体展开。假设目录里有两个文件:
1 | 目录: |
用户代码连续调用 readdir(dir) 时,看到的是一条条目录项:
1 | DIR *dir = opendir(path) |
这两次 readdir(dir) 不一定对应两次内核读取。更常见的情况是:第一次 readdir(dir) 发现 C 库目录缓冲区不够,于是通过 getdents64(fd, ...) 向内核补一批目录项;第二次 readdir(dir) 只是从 C 库自己的缓冲区里拿下一条。
1 | 应用/C 库: |
第一次读这个目录时,FUSE directory cache 可能还没有准备好。如果目录打开时启用了 FOPEN_CACHE_DIR,uncached readdir 会一边把目录项交给 VFS/getdents,一边把同一批有效 struct fuse_dirent 写进 file->f_mapping 对应的目录 inode page cache。请求 offset 来自 ctx->pos,源码位置是 fs/fuse/readdir.c:341-344;写入 cache 的路径在 fs/fuse/readdir.c:117-143。
1 | 第一次读,目录缓存还没准备好: |
这一步同时形成三类状态:应用拿到一批目录项;FUSE directory cache 字节流写进 file->f_mapping 对应的目录 inode page cache;fi->rdc.size / fi->rdc.pos / fi->rdc.cached 记录这份 cache 的大小、构建进度和是否完整。fuse_readdir_cache_end() 标记 cache 完整的源码位置是 fs/fuse/readdir.c:84-105。
后续再读同一个目录,如果这份 cache 已经有效,cached readdir 会直接重放 file->f_mapping 对应的目录 inode page cache 里的 FUSE directory cache 字节流,不再问 nydusd:
1 | 后续读,目录缓存已经有效: |
对应到类比中,第一次读是在形成公共抄本,后续 cached readdir 是拿着个人书签从这份抄本里继续读。具体读到 byte 几、两个位置怎么一起往前走,放到下一节展开。
两个坐标:目录逻辑位置 ctx->pos 和缓存字节偏移 cache_off
现在把 "a.py"、"long_name_b.py" 映射到 FUSE cached readdir 的内部字段。这里要分清两个坐标:
| 位置 | 代码字段 | 怎么理解 |
|---|---|---|
| 目录逻辑位置 | file->f_pos / ctx->pos / ff->readdir.pos / dirent->off |
这个目录流下一次应该从哪个逻辑位置继续读。dirent->off 会告诉内核读完当前目录项后,逻辑位置应该变成多少。 |
| 缓存字节偏移 | ff->readdir.cache_off |
FUSE directory cache 字节流里读到第几个字节。它是 cached readdir 成功解析一条目录项后推进的内部位置。 |
reclen 表示一条 cached dirent 在 FUSE directory cache 字节流里占用的字节数。Linux 5.4 源码用 FUSE_DIRENT_SIZE(dirent) 计算,可以先理解成 struct fuse_dirent 固定头部长度 + namelen,再向 8 字节对齐。所以它不是固定值,名字越长通常占用越多。
把前面的两条目录项放进缓存后,可以从两个视角看:page cache 里按页存放,FUSE directory cache 逻辑上是一条连续字节流。
1 | 这个目录 inode 的 page cache: |
一次正常的 cached 重放里,两个坐标会一起往前走:
1 | VFS 进入文件系统前: |
看到这里就能回答一个小问题:VFS 已经有 ctx->pos,FUSE 为什么还要记录 ff->readdir.pos?
ff->readdir.pos 的作用,是给当前 ff->readdir.cache_off 贴一个目录逻辑位置标签。VFS 每次进入 readdir 时只带来 ctx->pos,FUSE 需要判断旧的 ff->readdir.cache_off 还能不能沿用。判断方法就是比较:
1 | ff->readdir.pos == ctx->pos |
如果相等,说明当前缓存字节偏移正好对应这次 VFS 想继续读的位置,可以继续沿用旧 ff->readdir.cache_off。如果不相等,比如用户态做了 rewinddir(dir) / seekdir(dir, pos),源码会把 FUSE 自己的缓存游标重置到开头。源码位置是 fs/fuse/readdir.c:443-446。
1 | ff->readdir.pos = 0 |
所以这个例子里最重要的关系是:dirent->off 推进目录逻辑位置,reclen 推进缓存字节偏移。两者都会往前走,但记录的是两件事。在类比里,目录逻辑位置更像这次目录读取要继续读的语义位置,ff->readdir.cache_off 才是个人书签落在公共抄本字节流里的位置。
回到这次线程栈,中间的 overlayfs 会把目录读取转到 lower FUSE 的真实目录文件上;所以后面 SystemTap 里看到的 fuse_readdir() / fuse_readdir_cached(),看的就是这一路 lower FUSE 调用。overlayfs 的真实路径转接细节放到附录 B:overlayfs 如何转到 lower FUSE file。
函数级 SystemTap:新的 readdir 请求有没有刷屏
有了前面的地图,现在回到 __lock_page 热点本身。函数级 SystemTap 先用来回答一个更粗的问题:这个 page lock 热点到底像哪类问题?相关输出见附录 A.2:函数级 SystemTap:先看方向的输出,完整脚本和运行环境见附录 A.1:复现入口。
| SystemTap 现象 | 能先说明什么 | 不能单独说明什么 |
|---|---|---|
__lock_page 一秒十万级命中,热点是目录遍历线程。 |
业务进程的线程在 page lock 路径上高频活动。 | 还不能说明这个 page lock 属于哪个文件系统路径。 |
unlock_page() / wake_up_page_bit() 同样高频。 |
page lock 在反复释放和唤醒,单个 owner 永久持有 page lock 的可能性低。 | 还不能说明为什么释放后又马上回到同一个热点。 |
同一段采样时间里没看到 fuse_readdir() 函数入口反复触发。 |
page lock 热点很高的时候,没有看到新的 fuse_readdir() 调用一起刷屏。 |
还不能说明已经卡在 cached readdir 的哪一段。 |
同一段采样时间里没看到 fuse_readdir_uncached() 函数入口反复触发。 |
cached 路径没有持续退回 fuse_readdir_uncached()。 |
还不能直接说明 cached 路径已经在里面卡住打转。 |
虽然还看不到 cached readdir 里的字段,但这一步已经能先看出几件事:
- page lock 很热;
- lock/unlock/wake 都很热,说明 page lock 一直在被拿起、释放、唤醒;
fuse_readdir()入口没有跟着刷屏;fuse_readdir_uncached()没有持续出现。
这里看的是函数入口。如果线程已经进入 fuse_readdir_cached(),并在里面反复 retry,函数入口不会每一轮都重新触发。
按前面的类比,这一步还没看到个人书签,只能先排除持续新进 readdir 和持续回原始馆藏抄写。
所以下一步直接看已经进入 cached readdir 的线程,弄清楚它为什么总是回到同一个位置。
继续往里看:cached readdir 当时读到哪里
函数级 SystemTap 之后,剩下的问题更具体:已经进入 cached readdir 的线程,当时到底读到哪段 cache?
这里最要紧的是两个问题:
1 | 当时读到哪个缓存位置? |
字段级 SystemTap 的代表性输出如下。这里的 comm 和 pid 已脱敏,readdir_worker 代表目标业务进程里的目录遍历线程。L502、L539、L388、L553 是脚本自己打的标签,意思是“在源码对应行附近读到的字段”。
1 | L502 cached_state comm=readdir_worker pid=545450 ctx_pos=0 ff_pos=0 cache_off=0 index=0 fi_cached=1 fi_size=5264 ff_ver=14 fi_ver=14 |
先把这段输出里的字段逐个翻译一下:
| 字段 | 说明 |
|---|---|
fi_cached=1 |
也就是 fi->rdc.cached=1。内核认为这个 FUSE directory cache 字节流已经有效,可以走 cached readdir。 |
fi_size=5264 |
也就是 fi->rdc.size=5264,大于 PAGE_SIZE=4096。目录缓存范围至少覆盖 page index 0,读 page index 0 是在有效缓存范围内。 |
cache_off=0 |
也就是 ff->readdir.cache_off=0。当前 reader 正从 FUSE directory cache 字节流的开头重放。 |
ff_ver=14, fi_ver=14 |
也就是 ff->readdir.version == fi->rdc.version。这个样本没有命中 “cache version changed -> reset cache_off” 分支。 |
index=0 |
ff->readdir.cache_off >> PAGE_SHIFT 的结果。这台机器 PAGE_SIZE=4096,所以 PAGE_SHIFT=12;字段级 SystemTap 脚本里的 cache_off >> 12 和源码里的 cache_off >> PAGE_SHIFT 等价。 |
size=4096 |
当前按完整 page 解析,后面的 retry 分支会走 size == PAGE_SIZE。 |
offset=0, namelen=0, ino=0, off=0, type=0 |
当前 offset 读到的 struct fuse_dirent 字段值。 |
合起来看,这段输出说的是:当前 reader 从 cache_off=0 开始重放,读的是 page index 0 offset 0,而这个位置上的 struct fuse_dirent 是 namelen=0。
对应到类比中,公共抄本被认为可用,cached readdir 的个人书签在抄本开头,但书签指到的第一条记录是空的。
这里的 dirent 指向当前 page 里的一段缓存字节。cached readdir 先把 page index 0 映射成 addr,再用 ff->readdir.cache_off 算出 page 内 offset;源码里的 dirent = addr + offset,就是把这页缓存字节里从 offset 开始的位置按 struct fuse_dirent 解释。字段级 SystemTap 读到的 dirent->namelen=0,就是从这段缓存字节里读出的字段值。
字段如何从 P1 到 P4 这几个 probe 读出来,见附录 A.4:字段级脚本的四个 probe到 A.8;完整命令和 dbgsym 准备过程见附录 A.1:复现入口。
struct fuse_dirent 的最小结构是:
1 | struct fuse_dirent { |
把这些字段和前面的存放模型对应起来:
1 | FUSE readdir cache 状态 |
到这里,我们已经知道它读到了什么:cache_off=0、index=0,page index 0 offset 0 的 struct fuse_dirent 是 namelen=0。index 来自 ff->readdir.cache_off >> PAGE_SHIFT;__lock_page 热点怎么接到这条 cached readdir 路径,放到附录 C:cache_off 为 0 为什么会表现成 __lock_page 热点。正文下面直接看死循环主线:namelen=0 为什么让 cache_off 不推进,retry 后为什么仍回到 page index 0。
看源码:cached readdir 为什么会死循环
现在把前面读到的字段带回源码看。
fuse_readdir() 一进来会先分两种情况:如果打开目录时有 FOPEN_CACHE_DIR,就先走 fuse_readdir_cached();只有 cached 路径返回 UNCACHED,才会继续调用 fuse_readdir_uncached()。这里的 UNCACHED 是 readdir.c 内部的 sentinel,表示 cached 路径放弃、让外层改走 uncached 路径,不是用户态看到的错误码。源码位置是 fs/fuse/readdir.c:429、fs/fuse/readdir.c:576-580。
1 | /* fs/fuse/readdir.c:565, func fuse_readdir() */ |
L578 进入 fuse_readdir_cached() 后,先按 ff->readdir.cache_off 算出的 page index 找到并锁住 page。锁住 page 之后,cached readdir 把这页映射成 addr,再调用 fuse_parse_cache() 解析当前页内容,随后释放 page lock。源码位置是 fs/fuse/readdir.c:514-543。
1 | /* fs/fuse/readdir.c:431, func fuse_readdir_cached() */ |
先看 fuse_parse_cache() 的几个返回值分别是什么意思。源码里的 enum 定义在 fs/fuse/readdir.c:370-374:
| 返回值 | 含义 | 这个案例是否命中 |
|---|---|---|
FOUND_NONE |
本轮没有解析出有效目录项。 | 命中。 |
FOUND_SOME |
至少成功 dir_emit() 过一个目录项。 |
未命中。 |
FOUND_ALL |
用户侧目录项 buffer 满了,先停下来。 | 未命中。 |
FOUND_ERR |
cached dirent 字节内容非法。 | 未命中。 |
fuse_parse_cache() 处理的是上一段代码里 addr 和 size 描述的那一页缓存字节。offset 是当前 reader 在这页里的缓存字节偏移,dirent = addr + offset 没有新建目录项,只是把这段缓存字节按 struct fuse_dirent 来解释。后面的 dirent->namelen 就是从这段缓存字节里读出的字段。源码位置是 fs/fuse/readdir.c:381-416。
1 | /* fs/fuse/readdir.c:377, func fuse_parse_cache() */ |
这个案例刚好命中这个分支:
1 | fi->rdc.cached = 1 |
接着看 fuse_readdir_cached() 的 retry 路径。源码位置是 fs/fuse/readdir.c:460-562。
1 | /* fs/fuse/readdir.c:431, func fuse_readdir_cached() */ |
把字段值放回前面的 cache 布局里看:
1 | 这个目录 inode 的 page cache: |
对应到类比中,个人书签没有被推进,按页对齐后仍然留在抄本开头,所以 retry 又读回同一页。
把这个过程放回目录读取的三层视角,循环发生在第三层的 cached readdir 内部:
1 | 应用/C 库: |
这解释了为什么 SystemTap 会同时看到:
__lock_page很热:cached readdir 反复去锁 page index 0。unlock_page()/wake_up_page_bit()很热:page lock 一直在被释放、唤醒、再被拿。fuse_readdir()函数入口没有刷屏:这些活动发生在已经进入的 cached readdir 内部,没有不断新进函数入口。fuse_readdir_uncached()为空:没有持续退回 uncached 路径去问nydusd。
空 dirent 可能来自哪里:FUSE readdir cache race
到这里,这一轮现场字段是这样的:线程进入了 FUSE cached readdir,读 page index 0 offset 0 时拿到空 struct fuse_dirent,ff->readdir.cache_off 没有推进,于是 retry 后仍然回到 page index 0。
如果前面的问题是“为什么一直读回同一处”,这里问的是“公共抄本开头为什么会留下空记录”。
page index 0 offset 0 为什么会是一条全 0 的空 dirent,还要继续看 cache 写入阶段发生了什么。目前最像的是 Linux 上游后来修复过的 FUSE readdir cache race。
上游修复的标题是 fuse: fix readdir cache race,对应 upstream commit 9fa248c65bdbf5af0a2f74dd38575acfc8dfd2bf。这条修复描述的是:两个 fuse_add_dirent_to_cache() 并发构建 readdir cache 时,可能留下一个没有正确填充的 page;后续 cached readdir 会读到这个 page。修复思路是:写满 page 后把 page 标记为 uptodate,读取时忽略 non-uptodate page。同一修复也进入了 5.15.79 stable,Red Hat 侧也有 Bug 2131391: fuse readdir cache sometimes corrupted 的发行版记录。
只看 Ubuntu 默认内核线,可以这样判断:
| Ubuntu 默认内核线 | 判断 |
|---|---|
| Ubuntu 20.04 默认 kernel 5.4 | 本文没有确认 5.4 默认内核线已经包含该修复;案例内核 5.4.0-48.52 明确早于上游修复。 |
| Ubuntu 22.04 默认 kernel 5.15 | Jammy linux changelog 在 5.15.0-65.72 里带入 v5.15.79 upstream stable,其中包含 fuse: fix readdir cache race;5.15.0-65.72 或更新版本可认为包含该修复。 |
| Ubuntu 24.04 默认 kernel 6.8 | 6.8 晚于 upstream 修复进入主线的时间,默认包含该修复。 |
所以,工程上更稳的说法是:从案例使用的 Ubuntu 20.04 / 5.4.0-48 升级到 Ubuntu 22.04,并确保内核至少是 5.15.0-65.72,或者升级到 Ubuntu 24.04,都可以覆盖这个已知修复。
它和本文这次读到的字段能对上的地方是:
1 | 上游 bug 形态: |
本地 Ubuntu 5.4.0-48 的 fuse_add_dirent_to_cache() 会通过 find_or_create_page() 拿 page,在新 page 的 offset 0 上 clear_page(),再 memcpy() 写入 dirent,并更新 fi->rdc.size / fi->rdc.pos。源码位置是 fs/fuse/readdir.c:32-88。这版源码里没有上游修复提到的 SetPageUptodate() / PageUptodate() 防护。
所以这里先停在一个保守说法:FUSE readdir cache race 和这些字段能对上。为什么 page index 0 offset 0 会变成空 dirent,还需要补丁验证或复现实验继续确认。
结论:这次排障每一步看到了什么
回头看,这次排障是一步一步往里查出来的。
函数级 SystemTap 这一段,主要拿来看几件事:
- page lock 热点落在哪些线程上。
- lock/unlock/wake 是否都在流转,还是某个线程一直拿着 page lock 不放。
fuse_readdir()有没有跟着刷屏。fuse_readdir_uncached()有没有跟着刷屏。
字段级 SystemTap 再把 cached readdir 里面的关键字段读出来:
fi->rdc.cached=1,目录缓存已经被内核认为有效。ff->readdir.cache_off=0,cached readdir 正从缓存字节流开头重放。- page index 0 offset 0 的 cached dirent 是
namelen=0。 - retry 前
ff->readdir.cache_off仍然是 0。
回到源码后,几个字段是这样串起来的:
- 源码里的 cached/uncached 分叉。
ff->readdir.cache_off -> page index -> FGP_LOCK -> __lock_page这条路径。namelen=0 -> FOUND_NONE -> 不推进 ff->readdir.cache_off。size == PAGE_SIZE -> ALIGN(ff->readdir.cache_off, PAGE_SIZE) -> retry。
这样看下来,这次卡住发生在 cached readdir 内部:字段级 SystemTap 直接打出了 cache_off=0 和空 dirent,FUSE cached readdir 的 retry 源码又解释了为什么下一轮还会回到同一页。函数级 SystemTap 的作用,是先把几个更普通的解释排掉。至于空 dirent 的来源,目前更像上一节的 FUSE readdir cache race。
按前面的类比看,SystemTap 看到的重点已经在公共抄本这边:cached readdir 在有效公共抄本里反复使用同一个停在原位的个人书签。
附录 A:SystemTap 复现入口、输出和字段读取方法
完整复现脚本已经放到 GitHub:tedcy/fuse-readdir-stap-lab。本文只保留运行入口、脚本索引和 P1-P4 字段读取方法;完整命令以 commit permalink 的形式引用,避免后续仓库更新导致行号漂移。
A.1 复现入口
这个 lab 基本也是按当时查看的顺序分两步:函数级 SystemTap 先看问题像什么,字段级 SystemTap 再直接读取 FUSE cached readdir 的关键字段。快速开始见 README.md L29-L65。
目标内核仍以 Ubuntu 20.04 5.4.0-48-generic / 5.4.0-48.52 为例。运行前需要满足这些条件:
- 运行内核、kernel headers、
System.map和 dbgsymvmlinux必须匹配。 - Secure Boot / kernel lockdown 需要允许加载 SystemTap 生成的内核模块。
- 字段级脚本依赖 Ubuntu dbgsym 里的 DWARF;函数级脚本不需要 dbgsym。
- 复现时需要把
TARGET_READER_COMM设置成实际卡在目录遍历里的线程名。
脚本入口如下:
| 步骤 | GitHub 链接 | 作用 |
|---|---|---|
| 初始化环境 | scripts/00-init-env.sh L1-L83 |
检查目标系统、安装基础依赖、创建复现实验目录并写入 repro-env.sh。 |
| 准备 headers | scripts/01-prepare-kheaders.sh L1-L26 |
下载 Ubuntu kernel headers,并从 /proc/kallsyms 写出 System.map。 |
| 构建工具链 | scripts/02-build-systemtap-toolchain.sh L1-L72 |
准备 gcc/binutils、elfutils 0.195 和 SystemTap 5.5。 |
| 最小自检 | scripts/03-self-test.sh L1-L33 |
用 __lock_page kprobe 验证 SystemTap 能编译并加载模块。 |
| 函数级统计 | scripts/04-run-function-level.sh L1-L34 |
运行 lock-page、lock-wake 或 readdir-entry 三组函数级脚本。 |
| 准备 dbgsym | scripts/05-prepare-dbgsym.sh L1-L63 |
从 vmlinuz 取 Build ID,下载并校验匹配的 dbgsym vmlinux。 |
| 检查变量可见性 | scripts/06-check-field-vars.sh L1-L29 |
用 stap -L 确认字段级脚本需要的源码变量能读到。 |
| 字段级统计 | scripts/07-run-field-readdir.sh L1-L29 |
替换目标线程名后运行 P1-P4 字段级脚本。 |
| 残留检查 | scripts/08-cleanup-check.sh L1-L9 |
检查是否还有残留 stap_* 模块或 staprun 进程。 |
函数级 .stp 脚本也放在仓库里:function-lock-page.stp、function-lock-wake.stp、function-readdir-entry.stp。字段级完整脚本见 stap/field-readdir-15s.stp L1-L125。
A.2 函数级 SystemTap:先看方向的输出
__lock_page 热点集中在目录遍历线程。下面输出里的 readdir_worker 是脱敏后的线程名:
1 | __lock_page by comm: |
同一组输出里,unlock_page() 和 wake_up_page_bit() 也很热:
1 | __lock_page by comm: |
函数级阶段最有用的对比是 __lock_page 和 FUSE readdir 入口:
1 | __lock_page by comm: |
A.3 字段级 SystemTap:直接看到的关键字段
字段级 SystemTap 直接看到的字段如下。它依赖 Ubuntu linux-image-unsigned-5.4.0-48-generic-dbgsym 解出的 DWARF vmlinux;输出里的 comm 和 pid 已脱敏:
1 | L502 cached_state comm=readdir_worker pid=545450 ctx_pos=0 ff_pos=0 cache_off=0 index=0 fi_cached=1 fi_size=5264 ff_ver=14 fi_ver=14 |
A.4 字段级脚本的四个 probe
字段级脚本的四个 probe 对应本文的四个字段读取位置:
| probe | GitHub 行号 | 读什么 |
|---|---|---|
P1 / L502 cached_state |
field-readdir-15s.stp L20-L54 |
目录缓存是否有效、reader 从哪里开始、cache_off 对应哪个 page index。 |
P2 / L539 before_parse |
field-readdir-15s.stp L56-L69 |
进入 fuse_parse_cache() 前的 cache_off、page index 和解析大小。 |
P3 / L388 dirent |
field-readdir-15s.stp L71-L85 |
当前 page offset 上的 struct fuse_dirent 字段。 |
P4 / L553 retry_before_align |
field-readdir-15s.stp L87-L102 |
retry 前 ff->readdir.cache_off 有没有推进。 |
下面继续展开每个 probe 为什么能读到这些字段,以及这些字段如何对应到主文里的模型。
A.5 P1:目录缓存是否有效,reader 从哪里开始
P1 的 probe 是:
1 | kernel.statement("fuse_readdir@fs/fuse/readdir.c:502") |
完整脚本位置见 field-readdir-15s.stp L20-L54。这个 probe 字符串容易让人困惑:正文里 fs/fuse/readdir.c:500-502 对应的是 fuse_readdir_cached() 里的 index = ff->readdir.cache_off >> PAGE_SHIFT。字段级脚本这里以 stap -L 实际列出的可探测点为准,选择 fuse_readdir@...:502 是为了在这个源码位置读到 $file/$ctx,再从 $file->private_data 取回 ff。
它输出 L502 cached_state,核心脚本可以简化成:
1 | cache_off = |
这里有两条取字段路径:
| 脚本表达式 | 读出的对象 | 用途 |
|---|---|---|
$ctx->pos |
VFS 传入的目录逻辑位置 | 和 ff->readdir.pos 对比,看当前 reader 是否从目录开头重放。 |
$file->private_data cast 成 struct fuse_file |
这次打开目录对应的 FUSE file handle,也就是 ff |
读取 ff->readdir.cache_off/pos/version。 |
$file->f_inode cast 成 struct fuse_inode |
当前目录对应的 FUSE inode,也就是 fi |
读取 fi->rdc.cached/size/version。 |
$file->private_data 这条线对应前面出现过的 ff = file->private_data。比较容易漏的是 $file->f_inode 这条线:$file 是 struct file *,$file->f_inode 是 struct inode *。FUSE 在内存里实际分配的是一个更大的 struct fuse_inode,并把 VFS 通用的 struct inode inode 放在第一个字段:
1 | struct fuse_inode object |
换成地址看就是:
1 | struct fuse_inode object 地址: 0xffff888012340000 |
因为 struct inode inode 是第一个字段,file->f_inode 指向的地址也就是整个 struct fuse_inode 对象的起始地址。SystemTap 的 @cast($file->f_inode, "struct fuse_inode", "kernel") 可以理解成:按 struct fuse_inode 的布局解释这个地址,然后读取后面的 rdc 字段。
P1 输出里的关键字段是:
1 | ctx_pos=0 ff_pos=0 cache_off=0 index=0 fi_cached=1 fi_size=5264 ff_ver=14 fi_ver=14 |
这里能看到:目录缓存被认为有效,缓存范围超过一页,当前 reader 正从缓存字节偏移 0 重放,对应 page index 0;同时 ff_ver == fi_ver,这个样本没有命中 version mismatch reset 分支。
A.6 P2:准备解析的是哪一页
P2 的 probe 是:
1 | kernel.statement("fuse_readdir_cached@fs/fuse/readdir.c:539") |
完整脚本位置见 field-readdir-15s.stp L56-L69。它输出 L539 before_parse,核心脚本是:
1 | cache_off = $ff->readdir->cache_off |
这里 $ff 是 fuse_readdir_cached() 里的 struct fuse_file *ff,$size 是源码在进入 fuse_parse_cache() 前算出的这次解析大小。P2 不需要重新证明 page cache 机制;file->f_mapping + page index + FGP_LOCK 怎么接到 __lock_page,见附录 C:cache_off 为 0 为什么会表现成 __lock_page 热点。
P2 输出里的关键字段是:
1 | cache_off=0 index=0 size=4096 |
这里能看到:字段级样本里,内核已经拿到 page index 0,并准备按完整一页交给 fuse_parse_cache() 解析。
A.7 P3:当前 cached dirent 是否为空
P3 的 probe 是:
1 | kernel.statement("fuse_parse_cache@fs/fuse/readdir.c:388") |
完整脚本位置见 field-readdir-15s.stp L71-L85。它输出 L388 dirent,核心脚本是:
1 | printf("L388 dirent offset=%d namelen=%d ino=%d off=%d type=%d\n", |
在源码里,page 是 fuse_readdir_cached() 通过 find_get_page_flags(..., FGP_LOCK) 拿到的缓存页,addr 来自 kmap(page),offset 是 fuse_parse_cache() 里的当前 page 内偏移,dirent 是 addr + offset 得到的当前 cached directory entry:
1 | /* fs/fuse/readdir.c:431, func fuse_readdir_cached() */ |
P3 输出里的关键字段是:
1 | offset=0 namelen=0 ino=0 off=0 type=0 |
结合 P2 的 cache_off=0 index=0 size=4096,这说明 SystemTap 读到的是 page index 0 起始位置的 struct fuse_dirent。前面的 继续往里看:cached readdir 当时读到哪里 和 看源码:cached readdir 为什么会死循环 已经解释了 namelen=0 为什么会让解析提前停止;这里的重点只是说明这些字段是由 L388 probe 直接读出来的。
A.8 P4:retry 前缓存字节偏移有没有推进
P4 的 probe 是:
1 | kernel.statement("fuse_readdir_cached@fs/fuse/readdir.c:553") |
完整脚本位置见 field-readdir-15s.stp L87-L102。它输出 L553 retry_before_align,核心脚本是:
1 | cache_off = $ff->readdir->cache_off |
L553 在 fuse_readdir_cached() 里:
1 | /* fs/fuse/readdir.c:431, func fuse_readdir_cached() */ |
这个 probe 命中时打印的是赋值前的 ff->readdir.cache_off。P4 输出里的关键字段是:
1 | cache_off=0 index=0 |
所以带入 L553 就是:
1 | ALIGN(0, PAGE_SIZE) = 0 |
这和前面的源码解释对应起来:P3 看到空 dirent,缓存字节偏移没有推进;P4 看到 retry 前它仍然是 0,因此 goto retry 之后仍回到 page index 0。
A.9 残留检查
复现实验结束后,运行 scripts/08-cleanup-check.sh L1-L9,确认没有残留 stap_* 模块或 staprun 进程。
附录 B:overlayfs 如何转到 lower FUSE file
这里说明这条调用链怎么一路走到 lower FUSE file:业务进程读目录,最后会进入 lower FUSE file。overlayfs 在这里负责把 readdir 交给 lower FUSE file,后面对 page index 0 / namelen=0 的分析主要都在 lower FUSE 里。
对象链可以看成这样:
1 | 业务进程的目录遍历线程 |
overlayfs 打开目录时,会再打开真实的 lower 目录文件,然后把它放到 overlay 自己的私有状态里。这里的 file 还是 overlay 的 struct file,file->private_data = od 只是把 lower realfile 挂到 overlay file 上,后续 readdir 才能找回来。源码位置是 fs/overlayfs/readdir.c:896-916。
1 | /* fs/overlayfs/readdir.c:896, func ovl_dir_open() */ |
后续 ovl_iterate() 会从 overlay file 的 private_data 里取出 od。这一步会从 overlay file 换成 od->realfile 继续读,所以后面的 iterate_dir(od->realfile, ctx) 才会进入 lower FUSE 的 readdir。源码位置是 fs/overlayfs/readdir.c:736-764。
1 | /* fs/overlayfs/readdir.c:736, func ovl_iterate() */ |
lower FUSE 目录被打开时,FUSE 会分配 struct fuse_file *ff,然后把它放进 lower FUSE struct file 的 private_data。这样前面的 iterate_dir(od->realfile, ctx) 进入 FUSE 后,fuse_readdir() 才能用 file->private_data 取回同一个 ff。源码位置是 fs/fuse/file.c:139-170。
1 | /* fs/fuse/file.c:133, func fuse_do_open() */ |
附录 C:cache_off 为 0 为什么会表现成 __lock_page 热点
这里补充最开始看到的 __lock_page 热点怎么接到正文里的 cached readdir 路径。这个附录只解释 page lock 热点来源;正文里的死循环主线,还是看 namelen=0 为什么让 cache_off 不推进。
FUSE directory cache 字节流保存在 file->f_mapping 对应的目录 inode page cache 里。struct address_space 可以先理解成“某个文件或目录对应的 page cache 索引表”。内核用 mapping + page index 找到一个 struct page。
FUSE cached readdir 会先用缓存字节偏移算要读哪一页:
1 | page index = ff->readdir.cache_off >> PAGE_SHIFT |
字段级 SystemTap 读到的 ff->readdir.cache_off = 0,所以算出来就是 page index 0。源码位置是 fs/fuse/readdir.c:500-502。
把字段值带进来,这条映射就是:
1 | ff->readdir.cache_off = 0 |
对应到源码,个人书签在公共抄本 byte 0,因此源码会去拿保存 byte 0 的 page index 0。
接着 FUSE 用 find_get_page_flags(..., FGP_LOCK) 拿这个缓存页。FGP_LOCK 的意思是:找到 page 后,还要拿 page lock,保证当前线程解析这页内容时不会和其他修改/解析冲突。源码位置是 fs/fuse/readdir.c:514-515。
1 | /* fs/fuse/readdir.c:431, func fuse_readdir_cached() */ |
这里要多跨一层:find_get_page_flags() 本身是 include/linux/pagemap.h 里的 inline wrapper,它会把 FGP_LOCK 这个 flag 原样传给 pagecache_get_page()。所以从上一段代码继续往下看,下一站就是 pagecache_get_page() 检查 fgp_flags & FGP_LOCK,再调用 lock_page();如果 trylock_page() 拿不到锁,才会落到 __lock_page()。源码位置是 include/linux/pagemap.h:263、mm/filemap.c:1641、include/linux/pagemap.h:476。
1 | /* mm/filemap.c:1629, func pagecache_get_page() */ |
调用方向可以概括成:
1 | 业务进程的目录遍历线程 |
-
2026-04-23
太长不看版
这个问题发生在
nydusd重新挂载镜像的时候:第一次挂载后路径能访问;卸载后再挂回同一条路径,直接访问有时会提示“不存在”。但如果先对它的父目录执行一次ls -l,再访问原来的路径,它又能恢复。ls -l这里不是普通的“看一眼目录”。它会在读父目录名字的同时,继续读取子项属性;这会让内核重新拿到当前的“这个名字现在对应哪个对象”。这部分机制正文会先用一个最小 FUSE demo 展开。根因是卸载时,
nydusd需要告诉内核:“这个父目录下面的某个名字已经变了。”原来的代码通知发错了位置,内核还记着旧的名字关系,所以直接按路径访问时会走到旧状态。修复就是把通知发到正确位置:不要把整条挂载路径当成一个名字,而是用“直接父目录 + 最后一级名字”去通知内核。正文的走法是:先看现象和一个最小 FUSE demo,再回到
nydusd的真实路径结构和源码修复。 -
2015-05-06
nginx的文件缓存分为两种,内存缓存和硬盘缓存。
内存缓存指文件句柄等信息进行缓存,减少使用open等的系统调用。
硬盘缓存指文件被缓存到硬盘上,一般是因为当作反向代理用才会有这种需求。
内存缓存是全模块都可以调用的,因为封装在core/ngx_open_file_cache.c中。
而硬盘缓存只有http模块可以调用,因为封装在http/ngx_http_file_cache.c中,最常见的就是upstream模块的cache了。
-
2022-09-29
之前写过一篇c++ 分析 gperftools 总结
对普通的性能优化来说,gperftools已经足够了
但是如果要深入优化,还是需要借助linux内置的perf工具
这个工具的功能包括但不限于:
- 协助优化代码中的cpu热点
- gpertools最多只能精确到某一行热点,perf是汇编级别的,因此可以协助优化生成的汇编代码
- 协助优化代码的分支预测命中率
- 协助优化代码的cpu高速缓存命中率
- 协助优化代码中对内核部分的使用
- 协助理解代码运行时在linux的调度情况
- 协助优化代码中的cpu热点
-
2024-12-07
续接十年前总结的前文linux的内存管理介绍
最近遇到了Serverless平台的管理节点上报内存不准确的问题,导致调度误判从而大量oom,所以不同于前文对meminfo一笔带过,需要对meminfo做一个系统的深入了解,并对其内容做一个分类,搞清楚存在的相互联系
首先贴一个
/proc/meminfo的数据,可以看到,有49项,非常复杂1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49MemTotal: 1950928 kB
MemFree: 110512 kB
MemAvailable: 300776 kB
Buffers: 0 kB
Cached: 295128 kB
SwapCached: 0 kB
Active: 1431548 kB
Inactive: 159160 kB
Active(anon): 1293796 kB
Inactive(anon): 1916 kB
Active(file): 137752 kB
Inactive(file): 157244 kB
Unevictable: 0 kB
Mlocked: 0 kB
SwapTotal: 0 kB
SwapFree: 0 kB
Dirty: 2108 kB
Writeback: 0 kB
AnonPages: 1295424 kB
Mapped: 85936 kB
Shmem: 2188 kB
KReclaimable: 53172 kB
Slab: 132728 kB
SReclaimable: 53172 kB
SUnreclaim: 79556 kB
KernelStack: 9360 kB
PageTables: 14280 kB
NFS_Unstable: 0 kB
Bounce: 0 kB
WritebackTmp: 0 kB
CommitLimit: 975464 kB
Committed_AS: 3710176 kB
VmallocTotal: 34359738367 kB
VmallocUsed: 0 kB
VmallocChunk: 0 kB
Percpu: 1136 kB
HardwareCorrupted: 0 kB
AnonHugePages: 397312 kB
ShmemHugePages: 0 kB
ShmemPmdMapped: 0 kB
HugePages_Total: 0
HugePages_Free: 0
HugePages_Rsvd: 0
HugePages_Surp: 0
Hugepagesize: 2048 kB
Hugetlb: 0 kB
DirectMap4k: 430776 kB
DirectMap2M: 1583104 kB
DirectMap1G: 0 kBMemTotal,MemFree,MemAvailable