业务进程退出卡住:一次 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
2
3
4
5
6
7
8
__lock_page
pagecache_get_page
fuse_readdir
iterate_dir
ovl_iterate
iterate_dir
ksys_getdents64
__x64_sys_getdents64

这条栈给了下一步方向:目录遍历线程已经卡在 overlay/FUSE 读目录这条内核调用链里。接下来先补一点读目录的背景知识,再回到 SystemTap 输出看它到底像哪类问题。

背景知识:读 overlay/FUSE 目录会经过哪些层

下面只补这次排障需要的三块背景:应用和 VFS 怎么读目录,FUSE directory cache 怎么写入和重放,以及这条 overlay 调用链怎么落到 lower FUSE。

目录读取的三层视角

同一次目录读取,在不同位置看到的入口和术语不一样。如果把这些层次混在一起看,就很容易误以为 readdir()getdents64()、FUSE READDIR 是一一对应的。分成三层看会更清楚。

第一层是应用/C 库视角。业务进程或它的运行时库,通常只是像下面这样读一个目录流:

1
2
3
4
DIR *dir = opendir(path);
while ((de = readdir(dir)) != NULL) {
/* 使用 de->d_name */
}

这里的 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 filestruct file 可以先理解成“内核里的这次打开目录实例”。它里面的 file->f_pos 记录这个目录流在内核看来读到哪里了;VFS 调文件系统 readdir 回调前,会把它放进 ctx->pos,回调结束后再写回去。源码位置是 fs/readdir.c:63-68getdents64 入口在 fs/readdir.c:353-371

1
2
3
4
5
6
7
8
9
10
11
12
13
应用/C 库:
readdir(dir)
-> 如果 C 库目录缓冲区还有内容,直接返回下一条
-> 如果缓冲区不够,进入 Linux 内核补一批目录项

Linux/VFS:
getdents64(fd, buf, count)
-> fdget_pos(fd)
-> 找到这个 fd 对应的 struct file
-> iterate_dir(file, ctx)
-> ctx->pos = file->f_pos
-> file->f_op->iterate_shared(file, ctx)
-> file->f_pos = ctx->pos

下面先把后面会用到的 fdstruct filefffi 的关系讲清楚。

fd、struct file、ff 和 fi 的关系

前面说 getdents64(fd, ...) 会找到这个 fd 对应的 struct file。这里要多分清一个点:struct file 表示一次打开目录实例,不等于目录 inode 本身。

先看同一个 fd。VFS 通过 fd 找到这次打开目录对应的 struct file 后,后面几个 FUSE 变量都是顺着这个 file 取出来的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
getdents64(fd, ...)
-> fdget_pos(fd)
-> struct file *file

file
-> file->private_data
-> struct fuse_file *ff
-> ff->open_flags & FOPEN_CACHE_DIR
这个 open 允许优先尝试 cached readdir
-> ff->readdir.pos / ff->readdir.cache_off
当前 reader 的目录逻辑位置和缓存字节偏移

-> file->f_inode
-> struct fuse_inode *fi
-> fi->rdc
-> rdc.cached / rdc.size / rdc.pos
这个目录 inode 上的 readdir cache 元数据

-> file->f_mapping
-> 这个目录 inode 的 page cache
+ ff->readdir.cache_off
-> page index / page 内 offset
-> 当前 cached struct fuse_dirent

再看同一个目录被打开多次。这里保留路径名是为了强调:不同 fd 来自同一个目录,所以它们可以共享 inode 级状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
opendir("/mnt/x")
-> fd 10
-> struct file A
-> fileA->private_data = ff_A
-> fileA->f_inode = inode_X
-> fileA->f_mapping = inode_X->i_mapping

opendir("/mnt/x")
-> fd 11
-> struct file B
-> fileB->private_data = ff_B
-> fileB->f_inode = inode_X
-> fileB->f_mapping = inode_X->i_mapping

不同:
fd 10 / fd 11
struct file A / struct file B
ff_A / ff_B

共享:
inode_X
struct fuse_inode *fi
fi->rdc
inode_X->i_mapping,也就是这个目录 inode 的 page cache

所以同一个目录的多次打开会共享 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
file->f_op->iterate_shared(file, ctx)
-> fuse_readdir(file, ctx)
-> ff = file->private_data
-> fi = file->f_inode 对应的 FUSE inode

+-> cached 路径:
| fuse_readdir_cached(file, ctx)
| -> 看 fi->rdc 判断目录 cache 是否可用、已有多大
| -> 用 ff->readdir.cache_off 在 file->f_mapping 对应的目录 inode page cache 里找位置
| -> 从这个位置读取 cached struct fuse_dirent
| -> 读到有效目录项后推进 ctx->pos / ff->readdir.pos / ff->readdir.cache_off
|
+-> uncached 路径:
fuse_readdir_uncached(file, ctx)
-> 向 nydusd 发 FUSE_READDIR / FUSE_READDIRPLUS
-> 把返回的目录项交给 VFS/getdents
-> 启用 FOPEN_CACHE_DIR 时,把同一批有效目录项写入 file->f_mapping
-> 更新 fi->rdc,读到末尾后标记 cache 完整

这张图里的几个对象,可以用图书馆类比来记:

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
应用/C 库:
readdir(dir)
-> 要下一条目录项

C 库 / VFS:
getdents64(fd, ...)
-> 找到 struct file
-> 维护目录逻辑位置:
file->f_pos / ctx->pos

FUSE uncached readdir:
-> 向 nydusd 请求目录项
-> 像抄写员,把有效目录项写进公共抄本:
file->f_mapping 对应的目录 inode page cache
-> 更新公共抄本状态:
fi->rdc.cached / fi->rdc.size / fi->rdc.pos

FUSE cached readdir:
-> 不再问 nydusd
-> 直接读公共抄本
-> 用 cached readdir 的个人书签定位:
ff->readdir.cache_off
-> 读到有效目录项后推进个人书签

有了这张全链路图,下面再单独拆开“公共抄本”:FUSE directory cache 到底存在哪里、按什么偏移写入和重放。

FUSE readdir 的目录缓存模型

一个贯穿例子:第一次读写入 cache,后续读重放 cache

接下来把上面图里的“公共抄本”和“个人书签”用一个小目录具体展开。假设目录里有两个文件:

1
2
3
目录:
"a.py"
"long_name_b.py"

用户代码连续调用 readdir(dir) 时,看到的是一条条目录项:

1
2
3
4
DIR *dir = opendir(path)

第一次 readdir(dir) -> "a.py"
第二次 readdir(dir) -> "long_name_b.py"

这两次 readdir(dir) 不一定对应两次内核读取。更常见的情况是:第一次 readdir(dir) 发现 C 库目录缓冲区不够,于是通过 getdents64(fd, ...) 向内核补一批目录项;第二次 readdir(dir) 只是从 C 库自己的缓冲区里拿下一条。

1
2
3
4
5
6
7
8
9
应用/C 库:
readdir(dir) 想返回下一条
-> C 库目录缓冲区为空
-> getdents64(fd, ...) 向内核补一批

Linux/VFS:
fd -> struct file -> file->f_pos
iterate_dir(file, ctx)
-> ctx->pos = file->f_pos

第一次读这个目录时,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
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
第一次读,目录缓存还没准备好:

fuse_readdir(file, ctx)
-> fuse_readdir_uncached(file, ctx)
-> ctx->pos 表示这次请求从哪个目录逻辑位置开始
-> 向 nydusd 发 FUSE_READDIR / FUSE_READDIRPLUS
-> nydusd 返回一批 struct fuse_dirent:
"a.py"
"long_name_b.py"
-> parse_dirfile()
-> fuse_emit("a.py")
-> dir_emit("a.py")
-> 交给 VFS/getdents,后面进入 C 库目录缓冲区
-> fuse_add_dirent_to_cache("a.py")
-> 追加到 file->f_mapping 对应的目录 inode page cache
-> 更新这个目录 inode 上的 fi->rdc.size / fi->rdc.pos

-> fuse_emit("long_name_b.py")
-> dir_emit("long_name_b.py")
-> 交给 VFS/getdents,后面进入 C 库目录缓冲区
-> fuse_add_dirent_to_cache("long_name_b.py")
-> 追加到 file->f_mapping 对应的目录 inode page cache
-> 更新这个目录 inode 上的 fi->rdc.size / fi->rdc.pos

-> 读到目录末尾:
-> fuse_readdir_cache_end()
-> 把这个目录 inode 上的 fi->rdc.cached 标记为 true

这一步同时形成三类状态:应用拿到一批目录项;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
2
3
4
5
6
7
8
9
后续读,目录缓存已经有效:

fuse_readdir(file, ctx)
-> ff = file->private_data
-> fuse_readdir_cached(file, ctx)
-> 检查 ctx->pos 和 ff->readdir.pos 是否匹配
-> 用 ff->readdir.cache_off 去 file->f_mapping 对应的目录 inode page cache 里找位置
-> 从 FUSE directory cache 字节流读取 cached struct fuse_dirent
-> 读到有效目录项后交给 VFS/getdents

对应到类比中,第一次读是在形成公共抄本,后续 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
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
这个目录 inode 的 page cache:
file->f_mapping
-> page index 0
保存 FUSE directory cache 字节流的 byte [0, 4096)
-> page index 1
保存 FUSE directory cache 字节流的 byte [4096, 8192)
-> ...

FUSE directory cache 字节流(逻辑上连续):

byte 0 byte 32 byte 96
| | |
v v v
+-------------------+-------------------------------+-------------------+
| fuse_dirent a.py | fuse_dirent long_name_b.py | fuse_dirent ... |
| reclen = 32 | reclen = 64 | |
+-------------------+-------------------------------+-------------------+

ff->readdir.cache_off = 0
-> 下一次从 byte 0 开始解析

ff->readdir.cache_off = 32
-> 下一次从 byte 32 开始解析

ff->readdir.cache_off = 64
-> 已经在第二条 cached dirent 中间,这不是合法的下一条记录开头

ff->readdir.cache_off = 96
-> 前两条 cached dirent 已经解析完

一次正常的 cached 重放里,两个坐标会一起往前走:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
VFS 进入文件系统前:
file->f_pos = 0
ctx->pos = file->f_pos = 0

FUSE cached readdir 初始状态:
ff->readdir.pos = 0
ff->readdir.cache_off = 0

读取缓存字节偏移 0:
cached dirent = "a.py", dirent->off = 10, reclen = 32
dir_emit("a.py")
ctx->pos = 10
ff->readdir.pos = 10
ff->readdir.cache_off = 0 + 32 = 32

继续读取缓存字节偏移 32:
cached dirent = "long_name_b.py", dirent->off = 20, reclen = 64
dir_emit("long_name_b.py")
ctx->pos = 20
ff->readdir.pos = 20
ff->readdir.cache_off = 32 + 64 = 96

VFS 返回前:
file->f_pos = ctx->pos = 20

看到这里就能回答一个小问题: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
2
ff->readdir.pos = 0
ff->readdir.cache_off = 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
2
当时读到哪个缓存位置?
那个位置上的 dirent 是什么?

字段级 SystemTap 的代表性输出如下。这里的 commpid 已脱敏,readdir_worker 代表目标业务进程里的目录遍历线程。L502L539L388L553 是脚本自己打的标签,意思是“在源码对应行附近读到的字段”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
L539 before_parse comm=readdir_worker pid=646710 cache_off=0 index=0 size=4096
L388 dirent comm=readdir_worker pid=646710 offset=0 namelen=0 ino=0 off=0 type=0
L553 retry_before_align comm=readdir_worker pid=646710 cache_off=0 index=0

-- summary --
__lock_page by comm:
readdir_worker 1083276
cached L502 by comm:
readdir_worker 1801839
parse L388 by comm:
readdir_worker 1801840
retry L553 by comm:
readdir_worker 1801839

先把这段输出里的字段逐个翻译一下:

字段 说明
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_direntnamelen=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
2
3
4
5
6
7
struct fuse_dirent {
ino;
off;
namelen;
type;
name[];
}

把这些字段和前面的存放模型对应起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
FUSE readdir cache 状态
fi->rdc.cached = 1
fi->rdc.size = 5264 > PAGE_SIZE

当前 reader 的缓存位置
ff->readdir.cache_off = 0

FUSE directory cache 存放位置
file->f_mapping
-> 这个目录 inode 的 page cache
-> page index 0
-> offset 0

当前位置读到的 cached dirent
offset = 0
struct fuse_dirent { ino=0, off=0, namelen=0, type=0 }

到这里,我们已经知道它读到了什么:cache_off=0index=0,page index 0 offset 0 的 struct fuse_direntnamelen=0index 来自 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()。这里的 UNCACHEDreaddir.c 内部的 sentinel,表示 cached 路径放弃、让外层改走 uncached 路径,不是用户态看到的错误码。源码位置是 fs/fuse/readdir.c:429fs/fuse/readdir.c:576-580

1
2
3
4
5
6
7
8
9
10
11
12
13
/* fs/fuse/readdir.c:565, func fuse_readdir() */
int fuse_readdir(struct file *file, struct dir_context *ctx)
{
...

err = UNCACHED; /* L576 */
if (ff->open_flags & FOPEN_CACHE_DIR)
err = fuse_readdir_cached(file, ctx); /* L578 */
if (err == UNCACHED)
err = fuse_readdir_uncached(file, ctx); /* L580 */

...
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* fs/fuse/readdir.c:431, func fuse_readdir_cached() */
static int fuse_readdir_cached(struct file *file, struct dir_context *ctx)
{
...

page = find_get_page_flags(file->f_mapping, index, /* L514 */
FGP_ACCESSED | FGP_LOCK); /* L515 */
...
addr = kmap(page); /* L539 */
res = fuse_parse_cache(ff, addr, size, ctx); /* L540 */
kunmap(page); /* L541 */
unlock_page(page); /* L542 */
put_page(page); /* L543 */

...
}

先看 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() 处理的是上一段代码里 addrsize 描述的那一页缓存字节。offset 是当前 reader 在这页里的缓存字节偏移,dirent = addr + offset 没有新建目录项,只是把这段缓存字节按 struct fuse_dirent 来解释。后面的 dirent->namelen 就是从这段缓存字节里读出的字段。源码位置是 fs/fuse/readdir.c:381-416

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
/* fs/fuse/readdir.c:377, func fuse_parse_cache() */
static enum fuse_parse_result fuse_parse_cache(struct fuse_file *ff,
void *addr, unsigned int size,
struct dir_context *ctx)
{
unsigned int offset = ff->readdir.cache_off & ~PAGE_MASK; /* L381 */
enum fuse_parse_result res = FOUND_NONE; /* L382, 初始结果 */

for (;;) { /* L386, 解析当前 page */
struct fuse_dirent *dirent = addr + offset; /* L387 */
unsigned int nbytes = size - offset; /* L388 */
size_t reclen; /* L389 */

/* L391, namelen=0 会在 reclen 计算前退出循环 */
if (nbytes < FUSE_NAME_OFFSET || !dirent->namelen) /* L391 */
break;

reclen = FUSE_DIRENT_SIZE(dirent); /* L394 */

if (WARN_ON(dirent->namelen > FUSE_NAME_MAX)) /* L396 */
return FOUND_ERR; /* L397 */
if (WARN_ON(reclen > nbytes)) /* L398 */
return FOUND_ERR; /* L399 */
if (WARN_ON(memchr(dirent->name, '/', dirent->namelen) != NULL))
return FOUND_ERR; /* L400-L401 */

if (ff->readdir.pos == ctx->pos) { /* L403 */
res = FOUND_SOME; /* L404 */
if (!dir_emit(...))
return FOUND_ALL; /* L407 */
ctx->pos = dirent->off; /* L408 */
}
ff->readdir.pos = dirent->off; /* L410 */

/* L411, 只有有效 dirent 才会推进缓存字节偏移 */
ff->readdir.cache_off += reclen; /* L411 */

offset += reclen; /* L413 */
}

/* L416, namelen=0 直接 break 时,res 仍是初始的 FOUND_NONE */
return res; /* L416 */
}

这个案例刚好命中这个分支:

1
2
3
4
fi->rdc.cached = 1
fi->rdc.size = 5264 > PAGE_SIZE
ff->readdir.cache_off = 0
page index 0 dirent = namelen=0, ino=0, off=0, type=0

接着看 fuse_readdir_cached() 的 retry 路径。源码位置是 fs/fuse/readdir.c:460-562

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
/* fs/fuse/readdir.c:431, func fuse_readdir_cached() */
static int fuse_readdir_cached(struct file *file, struct dir_context *ctx)
{
...

retry: /* L460 */
...
index = ff->readdir.cache_off >> PAGE_SHIFT; /* L502 */

if (index == (fi->rdc.size >> PAGE_SHIFT)) /* L504 */
size = fi->rdc.size & ~PAGE_MASK; /* L505 */
else
size = PAGE_SIZE; /* L507 */

...
page = find_get_page_flags(file->f_mapping, index, /* L514 */
FGP_ACCESSED | FGP_LOCK); /* L515 */
...
addr = kmap(page); /* L539 */
res = fuse_parse_cache(ff, addr, size, ctx); /* L540 */
...

if (res == FOUND_ERR)
return -EIO; /* L545-L546 */
if (res == FOUND_ALL)
return 0; /* L548-L549 */

/* L551-L554, 解析结果不是 ERR/ALL,且当前页是完整 page,就继续 retry */
if (size == PAGE_SIZE) { /* L551 */
ff->readdir.cache_off =
ALIGN(ff->readdir.cache_off, PAGE_SIZE); /* L553 */
goto retry; /* L554 */
}

return res == FOUND_SOME ? 0 : UNCACHED; /* L562 */
}

把字段值放回前面的 cache 布局里看:

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
这个目录 inode 的 page cache:
file->f_mapping
-> page index 0
保存 FUSE directory cache 字节流的 byte [0, 4096)
-> page index 1
保存 FUSE directory cache 字节流的 byte [4096, 5264)
这是最后一页,因为 fi->rdc.size = 5264

FUSE directory cache 字节流(现场读到的内容):

byte 0 byte 4096 byte 5264
| | |
v v v
+---------------------------------------------------------+----------------+
| page index 0 | page index 1 |
| struct fuse_dirent { ino=0, off=0, namelen=0, type=0 } | tail bytes |
| namelen = 0 | |
+---------------------------------------------------------+----------------+
^
|
第 N 次解析:
ff->readdir.cache_off = 0
page index = 0
page 内 offset = 0
size = PAGE_SIZE = 4096
namelen = 0
-> fuse_parse_cache() 在 for 循环里 break
-> 返回 FOUND_NONE
-> ff->readdir.cache_off 仍然是 0

retry:
ALIGN(0, PAGE_SIZE) = 0
goto retry
|
v

第 N+1 次解析:
ff->readdir.cache_off = 0
page index = 0
page 内 offset = 0
-> 仍然解析 page index 0 的 byte 0
-> page index 1 没有被读到

对应到类比中,个人书签没有被推进,按页对齐后仍然留在抄本开头,所以 retry 又读回同一页。

把这个过程放回目录读取的三层视角,循环发生在第三层的 cached readdir 内部:

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
49
50
51
52
应用/C 库:
readdir(dir)
-> 如果 C 库目录缓冲区还有内容,直接返回下一条
-> 如果缓冲区不够,进入 Linux 内核补一批目录项

Linux/VFS:
getdents64(fd, buf, count)
-> fdget_pos(fd)
-> 找到这个 fd 对应的 struct file
-> iterate_dir(file, ctx)
-> ctx->pos = file->f_pos
-> file->f_op->iterate_shared(file, ctx)
-> file->f_pos = ctx->pos

FUSE:
file->f_op->iterate_shared(file, ctx)
-> fuse_readdir(file, ctx)
-> ff = file->private_data
-> fi = file->f_inode 对应的 FUSE inode

+-> uncached 路径:
| fuse_readdir_uncached(file, ctx)
| -> 向 nydusd 发 FUSE_READDIR / FUSE_READDIRPLUS
| -> nydusd 返回 struct fuse_dirent 字节
| -> dir_emit(...) 把目录项交给 VFS/getdents
| -> 如果启用了 FOPEN_CACHE_DIR,把有效目录项写入 file->f_mapping 对应的目录 inode page cache
| -> 同时更新 fi->rdc.size / fi->rdc.pos
| -> 读到目录末尾后标记 fi->rdc.cached
|
+-> cached 路径:
fuse_readdir_cached(file, ctx)
-> fi->rdc.cached=1,fi->rdc.size 覆盖 page index 0
-> if ff->readdir.pos != ctx->pos:
ff->readdir.pos = 0
ff->readdir.cache_off = 0
-> 用 ff->readdir.cache_off 定位 FUSE directory cache 字节流
-> 这份字节流保存在 file->f_mapping 对应的目录 inode page cache
-> cache_off=0,所以 page index=0、page 内 offset=0
-> 锁 page index 0
-> 从 page index 0 offset 0 解析当前 cached struct fuse_dirent
+-> 有效目录项:
| dir_emit(...)
| ctx->pos / ff->readdir.pos 按 dirent->off 推进
| ff->readdir.cache_off 按这条目录项占用的字节数推进
|
+-> 这次读到的字段:
page index 0 offset 0
namelen=0
解析停止,返回 FOUND_NONE
ff->readdir.cache_off 不推进
ALIGN(0, PAGE_SIZE) 仍是 0
goto retry 后仍然回到 page index 0

这解释了为什么 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_direntff->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 race5.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
2
3
4
5
6
7
8
9
10
11
上游 bug 形态:
readdir cache 竞争
-> 可能留下未初始化或未完整填充的 page
-> cached readdir 后续读取这个 page

本文字段级输出:
fi->rdc.cached = 1
fi->rdc.size = 5264
ff->readdir.cache_off = 0
page index 0 offset 0
-> struct fuse_dirent { ino=0, off=0, namelen=0, type=0 }

本地 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 和 dbgsym vmlinux 必须匹配。
  • 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-pagelock-wakereaddir-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.stpfunction-lock-wake.stpfunction-readdir-entry.stp。字段级完整脚本见 stap/field-readdir-15s.stp L1-L125

A.2 函数级 SystemTap:先看方向的输出

__lock_page 热点集中在目录遍历线程。下面输出里的 readdir_worker 是脱敏后的线程名:

1
2
__lock_page by comm:
readdir_worker 169065

同一组输出里,unlock_page()wake_up_page_bit() 也很热:

1
2
3
4
5
6
7
8
9
10
__lock_page by comm:
readdir_worker 125074
unlock_page by comm:
readdir_worker 199241
bash 1855
cat 973
sleep 955
loggerThread 492
wake_up_page_bit by comm:
readdir_worker 199240

函数级阶段最有用的对比是 __lock_page 和 FUSE readdir 入口:

1
2
3
4
__lock_page by comm:
readdir_worker 159375
fuse_readdir entry by comm:
fuse_readdir_uncached entry by comm:

A.3 字段级 SystemTap:直接看到的关键字段

字段级 SystemTap 直接看到的字段如下。它依赖 Ubuntu linux-image-unsigned-5.4.0-48-generic-dbgsym 解出的 DWARF vmlinux;输出里的 commpid 已脱敏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
L539 before_parse comm=readdir_worker pid=646710 cache_off=0 index=0 size=4096
L388 dirent comm=readdir_worker pid=646710 offset=0 namelen=0 ino=0 off=0 type=0
L553 retry_before_align comm=readdir_worker pid=646710 cache_off=0 index=0

-- summary --
__lock_page by comm:
readdir_worker 1083276
cached L502 by comm:
readdir_worker 1801839
parse L388 by comm:
readdir_worker 1801840
retry L553 by comm:
readdir_worker 1801839

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
cache_off =
@cast($file->private_data, "struct fuse_file", "kernel")
->readdir->cache_off
ff_pos =
@cast($file->private_data, "struct fuse_file", "kernel")
->readdir->pos
ff_ver =
@cast($file->private_data, "struct fuse_file", "kernel")
->readdir->version

fi_cached =
@cast($file->f_inode, "struct fuse_inode", "kernel")
->rdc->cached
fi_size =
@cast($file->f_inode, "struct fuse_inode", "kernel")
->rdc->size
fi_ver =
@cast($file->f_inode, "struct fuse_inode", "kernel")
->rdc->version

printf("L502 cached_state ctx_pos=%d ff_pos=%d cache_off=%d index=%d fi_cached=%d fi_size=%d ff_ver=%d fi_ver=%d\n",
$ctx->pos, ff_pos, cache_off, cache_off >> 12,
fi_cached, fi_size, ff_ver, fi_ver)

这里有两条取字段路径:

脚本表达式 读出的对象 用途
$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 这条线:$filestruct file *$file->f_inodestruct inode *。FUSE 在内存里实际分配的是一个更大的 struct fuse_inode,并把 VFS 通用的 struct inode inode 放在第一个字段:

1
2
3
struct fuse_inode object
+ offset 0: struct inode inode <-- $file->f_inode 指向这里
+ 后续字段: rdc.cached / rdc.size / rdc.version

换成地址看就是:

1
2
3
struct fuse_inode object 地址: 0xffff888012340000
+0x0000 struct inode inode <-- file->f_inode = 0xffff888012340000
+后面 FUSE 私有字段,比如 rdc.cached / rdc.size / rdc.version

因为 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
2
3
4
cache_off = $ff->readdir->cache_off

printf("L539 before_parse cache_off=%d index=%d size=%d\n",
cache_off, cache_off >> 12, $size)

这里 $fffuse_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
2
3
printf("L388 dirent offset=%d namelen=%d ino=%d off=%d type=%d\n",
$offset, $dirent->namelen,
$dirent->ino, $dirent->off, $dirent->type)

在源码里,pagefuse_readdir_cached() 通过 find_get_page_flags(..., FGP_LOCK) 拿到的缓存页,addr 来自 kmap(page)offsetfuse_parse_cache() 里的当前 page 内偏移,direntaddr + offset 得到的当前 cached directory entry:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* fs/fuse/readdir.c:431, func fuse_readdir_cached() */
static int fuse_readdir_cached(struct file *file, struct dir_context *ctx)
{
...

page = find_get_page_flags(file->f_mapping, index, /* L514 */
FGP_ACCESSED | FGP_LOCK); /* L515 */
...
addr = kmap(page); /* L539 */
res = fuse_parse_cache(ff, addr, size, ctx); /* L540 */

...
}

/* fs/fuse/readdir.c:377, func fuse_parse_cache() */
static enum fuse_parse_result fuse_parse_cache(struct fuse_file *ff,
void *addr, unsigned int size,
struct dir_context *ctx)
{
unsigned int offset = ff->readdir.cache_off & ~PAGE_MASK; /* L381 */
...
struct fuse_dirent *dirent = addr + offset; /* L387 */
...
}

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
2
3
4
cache_off = $ff->readdir->cache_off

printf("L553 retry_before_align cache_off=%d index=%d\n",
cache_off, cache_off >> 12)

L553 在 fuse_readdir_cached() 里:

1
2
3
4
5
6
7
8
9
10
/* fs/fuse/readdir.c:431, func fuse_readdir_cached() */
static int fuse_readdir_cached(struct file *file, struct dir_context *ctx)
{
...

ff->readdir.cache_off =
ALIGN(ff->readdir.cache_off, PAGE_SIZE); /* L553 */

...
}

这个 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
2
3
4
5
6
7
业务进程的目录遍历线程
-> 运行时/库函数读取目录流
-> 需要补充目录项时进入 getdents64(fd, ...)
-> fd 找到 overlay struct file
-> overlay_file->private_data = od
-> od->realfile = lower FUSE struct file
-> lower_fuse_file->private_data = ff

overlayfs 打开目录时,会再打开真实的 lower 目录文件,然后把它放到 overlay 自己的私有状态里。这里的 file 还是 overlay 的 struct filefile->private_data = od 只是把 lower realfile 挂到 overlay file 上,后续 readdir 才能找回来。源码位置是 fs/overlayfs/readdir.c:896-916

1
2
3
4
5
6
7
8
9
10
11
12
/* fs/overlayfs/readdir.c:896, func ovl_dir_open() */
static int ovl_dir_open(struct inode *inode, struct file *file)
{
...

realfile = ovl_path_open(&realpath, file->f_flags); /* L908 */
...
od->realfile = realfile; /* L913 */
file->private_data = od; /* L916 */

return 0;
}

后续 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
2
3
4
5
6
7
8
/* fs/overlayfs/readdir.c:736, func ovl_iterate() */
static int ovl_iterate(struct file *file, struct dir_context *ctx)
{
struct ovl_dir_file *od = file->private_data; /* L738 */
...

return iterate_dir(od->realfile, ctx); /* L764 */
}

lower FUSE 目录被打开时,FUSE 会分配 struct fuse_file *ff,然后把它放进 lower FUSE struct fileprivate_data。这样前面的 iterate_dir(od->realfile, ctx) 进入 FUSE 后,fuse_readdir() 才能用 file->private_data 取回同一个 ff。源码位置是 fs/fuse/file.c:139-170

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* fs/fuse/file.c:133, func fuse_do_open() */
int fuse_do_open(struct fuse_conn *fc, u64 nodeid, struct file *file,
bool isdir)
{
...

ff = fuse_file_alloc(fc); /* L139 */
...
ff->open_flags = outarg.open_flags; /* L153 */
...
file->private_data = ff; /* L170 */

return 0;
}

附录 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
2
3
4
5
ff->readdir.cache_off = 0
-> page index = 0 >> PAGE_SHIFT = 0
-> file->f_mapping
-> 这个目录 inode 的 page cache
-> FUSE directory cache 的 page index 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
2
3
4
5
6
7
8
9
10
/* fs/fuse/readdir.c:431, func fuse_readdir_cached() */
static int fuse_readdir_cached(struct file *file, struct dir_context *ctx)
{
...

page = find_get_page_flags(file->f_mapping, index, /* L514 */
FGP_ACCESSED | FGP_LOCK); /* L515 */

...
}

这里要多跨一层: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:263mm/filemap.c:1641include/linux/pagemap.h:476

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* mm/filemap.c:1629, func pagecache_get_page() */
struct page *pagecache_get_page(struct address_space *mapping, pgoff_t offset,
int fgp_flags, gfp_t gfp_mask)
{
...

if (fgp_flags & FGP_LOCK) { /* L1641 */
...
lock_page(page); /* L1648 */
}

...
}

/* include/linux/pagemap.h:476, func lock_page() */
static inline void lock_page(struct page *page)
{
...

if (!trylock_page(page)) /* L479 */
__lock_page(page); /* L480 */
}

调用方向可以概括成:

1
2
3
4
5
6
7
8
9
10
11
业务进程的目录遍历线程
-> 运行时/库函数读取目录流
-> 需要补充目录项时进入 getdents64(fd, ...)
-> 中间多一层 overlayfs 转发
-> lower FUSE: fuse_readdir()
-> fuse_readdir_cached()
-> index = ff->readdir.cache_off >> PAGE_SHIFT
-> find_get_page_flags(file->f_mapping, index, FGP_LOCK)
-> pagecache_get_page()
-> lock_page()
-> __lock_page()