路径名查找¶
本文基于 lwn.net 上发表的三篇文章:
<https://lwn.net/Articles/649115/> Linux 中的路径名查找
<https://lwn.net/Articles/649729/> RCU-walk:Linux 中更快的路径名查找
<https://lwn.net/Articles/650786/> 符号链接之旅
由 Neil Brown 撰写,Al Viro 和 Jon Corbet 协助。此后已更新,以反映内核中的更改,包括
每目录并行名称查找。
openat2()
解析限制标志。
路径名查找简介¶
路径名查找最明显的方面,几乎无需深入探索即可发现,就是其复杂性。它包含许多规则、特殊情况和实现替代方案,这些都可能让粗心的读者感到困惑。计算机科学早就熟悉这种复杂性,并拥有帮助管理它的工具。我们将广泛使用的一种工具是“分而治之”。在分析的早期部分,我们将把符号链接(symlinks)分开处理——把它们留到最后一部分。在处理符号链接之前,我们还有另一个重要的划分,它是基于 VFS 的锁定方法,这将使我们能够单独回顾“REF-walk”和“RCU-walk”。但我们有些超前了。首先我们需要澄清一些重要的低级区别。
有两种...¶
路径名(有时也称为“文件名”)用于标识文件系统中的对象,大多数读者对此都很熟悉。它们包含两种元素:“斜杠”,即一个或多个“/
”字符的序列;以及“组件”,即一个或多个非“/
”字符的序列。这些形成两种路径。以斜杠开头的路径是“绝对路径”,从文件系统根目录开始。其他路径是“相对路径”,从当前目录开始,或者从通过文件描述符指定给“*at()
”系统调用(例如 openat())的某个其他位置开始。
描述第二种路径以组件开头很吸引人,但这并不总是准确的:路径名可以既没有斜杠也没有组件,换句话说,它可以是空的。这在 POSIX 中通常是禁止的,但 Linux 中的一些“*at()
”系统调用在给定 AT_EMPTY_PATH
标志时允许这样做。例如,如果你有一个指向可执行文件的打开文件描述符,你可以通过调用 execveat() 并传递该文件描述符、一个空路径和 AT_EMPTY_PATH
标志来执行它。
这些路径可以分为两部分:最终组件和其余部分。“其余部分”是简单的部分。在所有情况下,它必须标识一个已存在的目录,否则将报告 ENOENT
或 ENOTDIR
等错误。
最终组件则不那么简单。不仅不同的系统调用对其解释大相径庭(例如,有些创建它,有些不创建),而且它甚至可能不存在:空路径名和仅由斜杠组成的路径名都没有最终组件。如果它确实存在,它可能是“.
”或“..
”,这些与其它组件的处理方式截然不同。
如果路径名以斜杠结尾,例如“/tmp/foo/
”,可能很想认为它有一个空的最终组件。在许多方面,这会产生正确的结果,但并非总是如此。特别是,mkdir()
和 rmdir()
都创建或删除由最终组件命名的目录,并且它们被要求能够处理以“/
”结尾的路径名。根据 POSIX:
包含至少一个非斜杠字符并以一个或多个尾随斜杠字符结尾的路径名,除非尾随斜杠字符之前的最后一个路径名组件命名一个现有目录,或者是在路径名解析后立即为一个目录创建的目录项,否则将无法成功解析。
Linux 路径遍历代码(主要在 fs/namei.c
中)处理所有这些问题:将路径分解为组件,将“其余部分”与最终组件完全分开处理,并检查尾随斜杠是否未在不允许的地方使用。它还解决了并发访问的重要问题。
当一个进程正在查找路径名时,另一个进程可能正在进行会影响该查找的更改。一个相当极端的例子是,如果“a/b”被重命名为“a/c/b”,而另一个进程正在查找“a/b/..”,那么该进程可能会成功解析到“a/c”。大多数竞态条件要微妙得多,路径名查找的一大部分任务就是防止它们产生破坏性影响。许多可能的竞态条件在“dcache”的上下文中表现得最清楚,理解它对于理解路径名查找至关重要。
不仅仅是缓存¶
“dcache”缓存每个文件系统中关于名称的信息,以便快速查找。每个条目(称为“dentry”)包含三个重要字段:组件名称、指向父 dentry 的指针以及指向“inode”的指针,inode 包含该父目录中具有给定名称的对象的更多信息。inode 指针可以为 NULL
,表示该名称在父目录中不存在。虽然目录的 dentry 中可能存在指向子目录 dentry 的链接,但该链接不用于路径名查找,因此在此不予考虑。
dcache 除了加速查找之外还有许多用途。其中一个特别相关的是它与挂载表紧密集成,挂载表记录了哪个文件系统挂载在哪里。挂载表实际存储的是哪个 dentry 挂载在哪个其他 dentry 之上。
考虑 dcache 时,我们又遇到了“两种类型”的区别:文件系统有两种类型。
一些文件系统确保 dcache 中的信息始终完全准确(尽管不一定完整)。这使得 VFS 可以在不检查文件系统的情况下确定特定文件是否存在,并意味着 VFS 可以保护文件系统免受某些竞态条件和其他问题的影响。这些通常是“本地”文件系统,例如 ext3、XFS 和 Btrfs。
其他文件系统不提供该保证,因为它们不能。这些通常是跨网络共享的文件系统,无论是像 NFS 和 9P 这样的远程文件系统,还是像 ocfs2 或 cephfs 这样的集群文件系统。这些文件系统允许 VFS 重新验证缓存的信息,并且必须提供自己的保护以防止棘手的竞态条件。VFS 可以通过 dentry 中设置的 DCACHE_OP_REVALIDATE
标志来检测这些文件系统。
REF-walk:使用引用计数和自旋锁的简单并发管理¶
在对所有这些划分进行了仔细分类之后,我们现在可以开始研究路径遍历的实际过程。特别是,我们将从路径名中“其余部分”的处理开始,并重点关注“REF-walk”并发管理方法。如果你忽略所有仅在设置了“LOOKUP_RCU
”(表示使用 RCU-walk)时运行的地方,此代码可以在 link_path_walk()
函数中找到。
REF-walk 在锁和引用计数方面相当“强硬”。虽然不像过去“大内核锁”时代那样强硬,但肯定不害怕在需要时获取锁。它使用了各种不同的并发控制机制。这里假设读者对各种原语有背景知识,或者可以从其他地方(例如 Meet the Lockers)了解它们。
REF-walk 使用的锁定机制包括:
dentry->d_lockref¶
这使用 lockref 原语来提供自旋锁和引用计数。这个原语的特别之处在于,概念上的“锁定;增加引用计数;解锁”序列通常可以通过单个原子内存操作完成。
持有 dentry 的引用可确保 dentry 不会突然被释放并用于其他目的,从而使各种字段中的值按预期运行。它还在一定程度上保护了对 inode 的 ->d_inode
引用。
dentry 及其 inode 之间的关联是相当永久的。例如,当文件重命名时,dentry 和 inode 会一起移动到新位置。当文件创建时,dentry 最初将是负的(即 d_inode
为 NULL
),并将在创建过程中分配给新的 inode。
当文件被删除时,这可以在缓存中通过两种方式反映:一是将 d_inode
设置为 NULL
,二是从用于在父目录中查找名称的哈希表(稍后描述)中移除它。如果 dentry 仍在被使用,则使用第二种选项,因为在文件被删除后继续使用打开的文件是完全合法的,并且保留 dentry 有助于此。如果 dentry 没有被其他方式使用(即 d_lockref
中的引用计数为 1),则只有此时才会将 d_inode
设置为 NULL
。这样做对于非常常见的情况效率更高。
因此,只要持有对 dentry 的计数引用,非 NULL
的 ->d_inode
值就不会被改变。
dentry->d_lock¶
d_lock
是上述 d_lockref
中自旋锁的同义词。就我们的目的而言,持有此锁可防止 dentry 被重命名或解除链接。特别是,它的父项(d_parent
)和它的名称(d_name
)不能被改变,并且它不能从 dentry 哈希表中移除。
在目录中查找名称时,REF-walk 会对它在哈希表中找到的每个候选 dentry 获取 d_lock
,然后检查父项和名称是否正确。因此,它在缓存中搜索时不会锁定父项;它只锁定子项。
当查找给定名称的父项(以处理“..
”)时,REF-walk 可以获取 d_lock
以获取对 d_parent
的稳定引用,但它首先尝试一种更轻量级的方法。正如在 dget_parent()
中所见,如果可以获取对父项的引用,并且随后发现 d_parent
没有改变,那么实际上没有必要对子项获取锁。
rename_lock¶
在给定目录中查找给定名称涉及从两个值(名称和目录的 dentry)计算哈希值,访问哈希表中的相应槽位,并搜索在那里找到的链表。
当 dentry 被重命名时,名称和父 dentry 都可以更改,因此哈希值也几乎肯定会更改。这将把 dentry 移动到哈希表中的不同链。如果文件名搜索碰巧正在查找以这种方式移动的 dentry,它最终可能会沿着错误的链继续搜索,从而错过正确链的一部分。
名称查找过程(d_lookup()
)*不*尝试阻止这种情况发生,而只是在发生时检测到它。rename_lock
是一个 seqlock,每当有 dentry 被重命名时,它都会更新。如果 d_lookup
发现在它扫描哈希表中的一个链失败时发生了重命名,它会简单地重试。
rename_lock
也用于检测和防御在解析“..”时(父目录被移到根目录之外,绕过 path_equal()
检查)针对 LOOKUP_BENEATH
和 LOOKUP_IN_ROOT
的潜在攻击。如果在查找过程中 rename_lock
被更新,并且路径遇到“..”,则表示发生了潜在攻击,handle_dots()
将以 -EAGAIN
退出。
inode->i_rwsem¶
i_rwsem
是一个读/写信号量,它将对特定目录的所有更改序列化。这确保了例如 unlink()
和 rename()
不能同时发生。它还在文件系统被要求查找当前不在 dcache 中的名称时,或者(可选地)在使用 readdir()
检索目录中的条目列表时,保持目录的稳定。
这与 d_lock
具有互补作用:目录上的 i_rwsem
保护该目录中的所有名称,而名称上的 d_lock
仅保护目录中的一个名称。对 dcache 的大多数更改都会在相关目录 inode 上持有 i_rwsem
,并在更改发生时短暂地对一个或多个 dentry 获取 d_lock
。一个例外是当空闲 dentry 因内存压力而从 dcache 中移除时。这使用 d_lock
,但 i_rwsem
不起作用。
该信号量以两种不同的方式影响路径名查找。首先,它防止在目录中查找名称时发生更改。walk_component()
首先使用 lookup_fast()
,后者反过来检查名称是否在缓存中,仅使用 d_lock
锁定。如果未找到名称,则 walk_component()
会回退到 lookup_slow()
,后者在 i_rwsem
上获取共享锁,再次检查名称是否不在缓存中,然后调用文件系统以获取确定性答案。无论结果如何,新的 dentry 都将被添加到缓存中。
其次,当路径名查找到达最终组件时,有时需要在执行最后一次查找之前对 i_rwsem
获取排他锁,以便实现所需的排他性。路径查找如何选择获取或不获取 i_rwsem
是后续章节中讨论的问题之一。
如果两个线程同时尝试查找同一个名称(一个尚未在 dcache 中的名称),i_rwsem
上的共享锁将无法阻止它们同时添加具有相同名称的新 dentry。由于这会导致混淆,因此采用了额外一层的互锁,基于一个辅助哈希表(in_lookup_hashtable
)和一个每 dentry 标志位(DCACHE_PAR_LOOKUP
)。
要在仅持有 i_rwsem
共享锁的情况下向缓存添加新的 dentry,线程必须调用 d_alloc_parallel()
。这会分配一个 dentry,在其中存储所需的名称和父项,检查主哈希表或辅助哈希表中是否已存在匹配的 dentry,如果不存在,则将新分配的 dentry 存储在辅助哈希表中,并设置 DCACHE_PAR_LOOKUP
。
如果在主哈希表中找到匹配的 dentry,则返回该 dentry,调用者便可知它在与另一个线程添加条目的竞争中失败了。如果在任何缓存中都没有找到匹配的 dentry,则返回新分配的 dentry,调用者可以通过 DCACHE_PAR_LOOKUP
的存在来检测这一点。在这种情况下,它知道它赢得了任何竞争,现在负责要求文件系统执行查找并找到匹配的 inode。当查找完成时,它必须调用 d_lookup_done()
,该函数会清除标志并执行其他一些内部管理工作,包括从辅助哈希表中移除 dentry——它通常已经添加到主哈希表中了。请注意,一个 struct waitqueue_head
会传递给 d_alloc_parallel()
,并且必须在 waitqueue_head
仍在作用域内时调用 d_lookup_done()
。
如果在辅助哈希表中找到匹配的 dentry,d_alloc_parallel()
还有更多工作要做。它首先等待 DCACHE_PAR_LOOKUP
被清除,使用传递给赢得竞态的 d_alloc_parallel()
实例的 wait_queue,该 wait_queue 将由对 d_lookup_done()
的调用唤醒。然后它检查 dentry 是否已添加到主哈希表。如果已添加,则返回该 dentry,调用者只是看到它在任何竞争中失败了。如果它尚未添加到主哈希表,最可能的解释是使用 d_splice_alias()
添加了其他 dentry。无论如何,d_alloc_parallel()
会从头开始重复所有查找,并且通常会从主哈希表中返回一些东西。
mnt->mnt_count¶
mnt_count
是“mount
”结构上的每 CPU 引用计数器。这里的“每 CPU”意味着增加计数成本很低,因为它只使用 CPU 本地内存,但检查计数是否为零成本很高,因为它需要检查每个 CPU。获取 mnt_count
引用可防止挂载结构因常规卸载操作而消失,但不会阻止“惰性”卸载。因此,持有 mnt_count
并不能确保挂载保留在命名空间中,特别是不能稳定到挂载点 dentry 的链接。然而,它确实确保了 mount
数据结构保持一致,并且它提供了对挂载文件系统根 dentry 的引用。因此,通过 ->mnt_count
的引用提供了对已挂载 dentry 的稳定引用,但不是对挂载点 dentry 的稳定引用。
mount_lock¶
mount_lock
是一个全局的 seqlock,有点像 rename_lock
。它可用于检查是否对任何挂载点进行了任何更改。
在向下遍历树(远离根)时,此锁用于在跨越挂载点时检查跨越是否安全。也就是说,读取 seqlock 中的值,然后代码查找当前目录上挂载的挂载点(如果有),并增加 mnt_count
。最后,将 mount_lock
中的值与旧值进行比较。如果没有更改,则跨越是安全的。如果发生了更改,则 mnt_count
递减,整个过程重新尝试。
当通过遵循“..”链接向上遍历树(朝向根)时,需要更加小心。在这种情况下,seqlock(包含计数器和自旋锁)被完全锁定,以防止在向上移动时对任何挂载点进行任何更改。此锁定是稳定到挂载点 dentry 的链接所必需的,而挂载本身的引用计数无法保证这一点。
mount_lock
也用于检测和防御在解析“..”时(父目录被移到根目录之外,绕过 path_equal()
检查)针对 LOOKUP_BENEATH
和 LOOKUP_IN_ROOT
的潜在攻击。如果在查找过程中 mount_lock
被更新,并且路径遇到“..”,则表示发生了潜在攻击,handle_dots()
将以 -EAGAIN
退出。
RCU¶
最后,全局(但极其轻量级的)RCU 读锁会不时地持有,以确保某些数据结构不会意外地被释放。
特别是在扫描 dcache 哈希表和挂载点哈希表中的链时持有。
结合 struct nameidata
¶
在路径遍历的整个过程中,当前状态存储在 struct nameidata
中,“namei”是传统名称——追溯到 Unix 第一版——用于将“名称”转换为“inode”的函数。struct nameidata
包含(除其他字段外):
struct path path
¶
一个 path
包含一个 struct vfsmount
(嵌入在 struct mount
中)和一个 struct dentry
。它们共同记录遍历的当前状态。它们最初指向起点(当前工作目录、根目录或由文件描述符标识的某个其他目录),并在每一步中更新。通过 d_lockref
和 mnt_count
的引用始终被持有。
struct qstr last
¶
这是一个字符串及其长度(即 *不* 以 nul
终止),它是路径名中的“下一个”组件。
int last_type
¶
这是一个 LAST_NORM
、LAST_ROOT
、LAST_DOT
或 LAST_DOTDOT
之一。last
字段仅在类型为 LAST_NORM
时有效。
struct path root
¶
这用于保存对文件系统有效根的引用。通常不需要该引用,因此此字段仅在首次使用时或请求非标准根时才分配。在 nameidata
中保留引用可确保在整个路径遍历过程中只有一个根生效,即使它与 chroot()
系统调用发生竞态。
应该注意的是,在 LOOKUP_IN_ROOT
或 LOOKUP_BENEATH
的情况下,有效根成为传递给 openat2()
(暴露这些 LOOKUP_
标志的函数)的目录文件描述符。
当满足以下两个条件之一时,需要根目录:(1)路径名或符号链接以“/
”开头;或(2)正在处理“..
”组件,因为从根目录的“..
”必须始终停留在根目录。使用的值通常是调用进程的当前根目录。可以提供替代根,例如当 sysctl()
调用 file_open_root()
时,以及当 NFSv4 或 Btrfs 调用 mount_subtree()
时。在每种情况下,路径名都在文件系统的特定部分中查找,并且不允许查找逃离该子树。它的工作方式有点像局部 chroot()
。
忽略符号链接的处理,我们现在可以描述“link_path_walk()
”函数,它处理除最终组件之外的所有内容的查找,如下所示:
给定一个路径 (
name
) 和一个 nameidata 结构 (nd
),检查当前目录是否具有执行权限,然后将name
推进一个组件,同时更新last_type
和last
。如果这是最终组件,则返回,否则调用walk_component()
并从头开始重复。
walk_component()
甚至更简单。如果组件是 LAST_DOTS
,它会调用 handle_dots()
,后者执行已描述的必要锁定。如果它发现一个 LAST_NORM
组件,它首先调用“lookup_fast()
”,该函数只在 dcache 中查找,但如果文件系统是那种类型的文件系统,它会要求文件系统重新验证结果。如果这没有得到好的结果,它会调用“lookup_slow()
”,该函数获取 i_rwsem
的共享锁,重新检查缓存,然后要求文件系统找到最终答案。
作为 walk_component()
的最后一步,step_into()
将直接从 walk_component()
或 handle_dots()
中调用。它调用 handle_mounts()
来检查和处理挂载点,其中会创建一个新的 struct path
,包含对新 dentry 的计数引用以及对新 vfsmount
的引用(仅当其与之前的 vfsmount
不同时才计数)。然后,如果存在符号链接,step_into()
调用 pick_link()
来处理它,否则它会在 struct nameidata
中安装新的 struct path
,并丢弃不需要的引用。
这种“手把手”的顺序——在释放对前一个 dentry 的引用之前获取对新 dentry 的引用——可能看起来很明显,但值得指出,以便我们能在“RCU-walk”版本中识别出它的类似物。
处理最终组件¶
link_path_walk()
只遍历到设置 nd->last
和 nd->last_type
以引用路径的最终组件。它不会在最后一次调用 walk_component()
。处理该最终组件的任务留给调用者解决。这些调用者是 path_lookupat()、path_parentat() 和 path_openat(),它们各自处理不同系统调用的不同要求。
path_parentat()
显然是最简单的——它只是在 link_path_walk()
周围包装了一些内务处理,并向调用者返回父目录和最终组件。调用者要么旨在创建名称(通过 filename_create()
),要么删除或重命名名称(在这种情况下使用 user_path_parent()
)。他们将使用 i_rwsem
来排除其他更改,同时他们验证并执行其操作。
path_lookupat()
也差不多简单——它用于需要现有对象的情况,例如通过 stat()
或 chmod()
。它本质上只是通过调用 lookup_last()
来对最终组件调用 walk_component()
。path_lookupat()
只返回最终的 dentry。值得注意的是,当设置了标志 LOOKUP_MOUNTPOINT
时,path_lookupat() 将在 nameidata 中取消设置 LOOKUP_JUMPED,以便在随后的路径遍历中不会调用 d_weak_revalidate()。这在卸载不可访问的文件系统(例如由宕机的 NFS 服务器提供的文件系统)时很重要。
最后,path_openat()
用于 open()
系统调用;它在以“open_last_lookups()”开头的辅助函数中包含了处理 O_CREAT(带或不带 O_EXCL)、最终“/
”字符和尾随符号链接等不同微妙之处所需的所有复杂性。我们将在本系列的最后一部分重新探讨这一点,该部分重点关注这些符号链接。“open_last_lookups()”有时会(但并非总是)获取 i_rwsem
,具体取决于它发现的内容。
这些函数,或调用它们的函数,都需要警惕最终组件不是 LAST_NORM
的可能性。如果查找的目标是创建某个东西,那么 last_type
的任何值(除了 LAST_NORM
)都将导致错误。例如,如果 path_parentat()
报告 LAST_DOTDOT
,那么调用者就不会尝试创建该名称。它们还通过测试 last.name[last.len]
来检查尾随斜杠。如果最终组件之外有任何字符,那它必须是一个尾随斜杠。
重新验证和自动挂载¶
除了符号链接之外,“REF-walk”过程还有两个尚未涵盖的部分。一个是处理陈旧的缓存条目,另一个是自动挂载。
在需要的文件系统上,查找例程将调用 ->d_revalidate()
dentry 方法以确保缓存信息是当前的。这通常会确认有效性或从服务器更新一些详细信息。在某些情况下,它可能会发现路径上游发生了变化,并且以前认为有效的东西实际上并非如此。发生这种情况时,整个路径的查找会被中止,并设置“LOOKUP_REVAL
”标志进行重试。这会强制重新验证更彻底。我们将在下一篇文章中看到有关此重试过程的更多详细信息。
自动挂载点是文件系统中的位置,在这些位置上尝试查找名称可能会触发该查找的处理方式发生变化,特别是通过在该处挂载文件系统。这些内容在 Linux 文档树中的 autofs - 工作原理 中有更详细的介绍,但此处需要特别指出一些与路径查找相关的注意事项。
Linux VFS 有一个“托管”dentry 的概念。关于这些 dentry,有三点可能有趣的地方,它们对应于 dentry->d_flags
中可能设置的三个不同标志:
DCACHE_MANAGE_TRANSIT
¶
如果设置了此标志,则文件系统已请求在处理任何可能的挂载点之前调用 d_manage()
dentry 操作。这可以提供两种特定服务:
它可以阻塞以避免竞态条件。如果正在卸载一个自动挂载点,d_manage()
函数通常会等待该过程完成,然后才允许新的查找继续并可能触发新的自动挂载。
它可以选择性地只允许某些进程通过挂载点。当服务器进程管理自动挂载时,它可能需要访问目录而不触发正常的自动挂载处理。该服务器进程可以向 autofs
文件系统表明身份,然后 autofs
将通过返回 -EISDIR
来给予其通过 d_manage()
的特殊权限。
DCACHE_MOUNTED
¶
此标志设置在每个被挂载的 dentry 上。由于 Linux 支持多个文件系统命名空间,因此 dentry 可能并未在此命名空间中挂载,而是在其他命名空间中挂载。因此,此标志被视为一个提示,而非承诺。
如果此标志已设置,并且 d_manage()
未返回 -EISDIR
,则调用 lookup_mnt()
来检查挂载哈希表(尊重前面描述的 mount_lock
),并可能返回新的 vfsmount
和新的 dentry
(两者均带有计数引用)。
DCACHE_NEED_AUTOMOUNT
¶
如果 d_manage()
允许我们走到这一步,并且 lookup_mnt()
没有找到挂载点,那么这个标志会触发调用 d_automount()
dentry 操作。
鉴于 d_automount()
操作可以任意复杂,并且可能与服务器进程等进行通信,它最终应该要么报告发生错误,要么报告没有要挂载的内容,或者提供一个包含新的 dentry
和 vfsmount
的更新的 struct path
。
在后一种情况下,将调用 finish_automount()
以安全地将新挂载点安装到挂载表中。
这里没有新的重要锁定,并且重要的是,在此处理过程中不持有任何锁(仅持有计数引用),因为这极有可能导致长时间延迟。这在下次我们研究对延迟特别敏感的 RCU-walk 时将变得更加重要。
RCU-walk - Linux 中更快的路径名查找¶
RCU-walk 是 Linux 中执行路径名查找的另一种算法。它在许多方面与 REF-walk 相似,并且两者共享相当多的代码。RCU-walk 的显著区别在于它如何允许并发访问的可能性。
我们注意到 REF-walk 很复杂,因为它有许多细节和特殊情况。RCU-walk 通过简单地拒绝处理某些情况来降低这种复杂性——它反而回退到 REF-walk。RCU-walk 的困难来自另一个方向:不熟悉。依赖 RCU 时的锁定规则与传统锁定截然不同,因此当我们遇到这些规则时,我们将花费更多时间。
明确的角色划分¶
管理并发最简单的方法是强制阻止任何其他线程更改给定线程正在查看的数据结构。在没有其他线程会想到更改数据并且许多不同线程希望同时读取的情况下,这可能会非常昂贵。即使使用允许多个并发读取器的锁,简单地更新当前读取器数量的计数也可能带来不必要的开销。因此,当读取其他进程未更改的共享数据结构时,目标是完全避免向内存写入任何内容。不获取锁,不增加计数,不留下任何痕迹。
已经描述的 REF-walk 机制显然不遵循这一原则,但它确实设计用于可能存在其他线程修改数据的情况。相比之下,RCU-walk 旨在处理常见情况:存在大量频繁读取器而只有偶尔写入器。这在文件系统树的所有部分可能不常见,但在许多部分会是这样。对于其他部分,重要的是 RCU-walk 可以快速回退到使用 REF-walk。
路径名查找总是以 RCU-walk 模式开始,但只有当它查找的内容在缓存中且稳定时才保持该模式。它轻快地沿着缓存的文件系统映像向下“舞蹈”,不留下任何痕迹,并仔细观察其位置,以确保它不会出错。如果它发现某些东西已更改或正在更改,或者某些东西不在缓存中,那么它会尝试优雅地停止并切换到 REF-walk。
这种停止需要获取对当前 vfsmount
和 dentry
的计数引用,并确保这些仍然有效——即使用 REF-walk 遍历路径也会找到相同的条目。这是 RCU-walk 必须保证的一个不变式。它只能做出 REF-walk 在同时遍历树时也能做出的决策,例如选择下一步。如果优雅停止成功,路径的其余部分将由可靠但略显迟缓的 REF-walk 处理。如果 RCU-walk 发现它无法优雅停止,它会简单地放弃并使用 REF-walk 从头开始重新启动。
这种“尝试 RCU-walk,如果失败则尝试 REF-walk”的模式可以清楚地在 filename_lookup()、filename_parentat()、do_filp_open() 和 do_file_open_root() 等函数中看到。这四个函数大致对应于我们前面遇到的三个 path_*()
函数,每个函数都调用 link_path_walk()
。path_*()
函数会使用不同的模式标志进行调用,直到找到一个有效模式。它们首先在设置了 LOOKUP_RCU
的情况下调用,以请求“RCU-walk”。如果失败并返回错误 ECHILD
,它们会再次在没有特殊标志的情况下调用,以请求“REF-walk”。如果其中任何一个报告错误 ESTALE
,则会进行最后一次尝试,设置 LOOKUP_REVAL
(且不设置 LOOKUP_RCU
),以确保缓存中找到的条目被强制重新验证——通常只有当文件系统确定它们太旧而不可信时才重新验证条目。
LOOKUP_RCU
尝试可能会在内部放弃该标志并切换到 REF-walk,但此后绝不会尝试切换回 RCU-walk。阻碍 RCU-walk 的地方更有可能靠近叶子,因此切换回来几乎不会有任何好处。
RCU 和 Seqlocks:快速轻量¶
RCU 对 RCU-walk 模式至关重要,这并不奇怪。rcu_read_lock()
在 RCU-walk 遍历路径的整个过程中都被持有。它提供的具体保证是,在持有锁期间,关键数据结构——dentries、inodes、super_blocks 和 mounts——不会被释放。它们可能以某种方式被解除链接或失效,但内存不会被重新利用,因此各个字段中的值仍然有意义。这是 RCU 提供的唯一保证;其他一切都使用 seqlock 完成。
正如我们上面看到的,REF-walk 持有对当前 dentry 和当前 vfsmount 的计数引用,并且在获取对“下一个”dentry 或 vfsmount 的引用之前不会释放这些引用。它有时也会获取 d_lock
自旋锁。获取这些引用和锁是为了防止某些更改发生。RCU-walk 不得获取这些引用或锁,因此无法阻止此类更改。相反,它会检查是否发生了更改,如果发生了更改,则中止或重试。
为了保持上述不变式(即 RCU-walk 只能做出 REF-walk 也能做出的决策),它必须在 REF-walk 持有引用的相同或附近位置进行检查。因此,当 REF-walk 增加引用计数或获取自旋锁时,RCU-walk 会使用 read_seqcount_begin()
或类似函数采样 seqlock 的状态。当 REF-walk 减少计数或释放锁时,RCU-walk 会使用 read_seqcount_retry()
或类似函数检查采样的状态是否仍然有效。
然而,seqlock 还有更多内容。如果 RCU-walk 访问受 seqlock 保护的结构中两个不同的字段,或者两次访问同一个字段,则无法先验保证这些访问之间存在任何一致性。当需要一致性时(通常是这样),RCU-walk 必须复制一份,然后使用 read_seqcount_retry()
来验证该副本。
read_seqcount_retry()
不仅检查序列号,还强制设置内存屏障,以确保 CPU 或编译器不会将 *调用之前* 的任何内存读取指令延迟到 *调用之后*。一个简单的例子可以在 slow_dentry_cmp()
中看到,对于不使用简单字节级名称相等性的文件系统,该函数会调用文件系统来将名称与 dentry 进行比较。长度和名称指针被复制到局部变量中,然后调用 read_seqcount_retry()
以确认两者一致,然后才调用 ->d_compare()
。当使用标准文件名比较时,则调用 dentry_cmp()
。值得注意的是,它 *不* 使用 read_seqcount_retry()
,而是有一个详细的注释解释为什么不需要一致性保证。随后的 read_seqcount_retry()
将足以捕获此时可能发生的任何问题。
在简单回顾了 seqlock 之后,我们可以看看 RCU-walk 如何使用 seqlock 的更大图景。
mount_lock
和 nd->m_seq
¶
我们在 REF-walk 中已经遇到过 mount_lock
seqlock,它用于确保安全地跨越挂载点。RCU-walk 也为此目的使用它,但用途远不止于此。
RCU-walk 在向下遍历树时,不获取每个 vfsmount
的计数引用,而是在遍历开始时采样 mount_lock
的状态,并将此初始序列号存储在 struct nameidata
的 m_seq
字段中。这一个锁和一个序列号用于验证对所有 vfsmounts
的所有访问以及所有挂载点交叉。由于挂载表的更改相对较少,因此在发生任何“挂载”或“卸载”时,回退到 REF-walk 是合理的。
在 RCU-walk 序列结束时,m_seq
会被检查(使用 read_seqretry()
),无论是在路径的其余部分切换到 REF-walk 还是到达路径末尾。在向下遍历挂载点(在 __follow_mount_rcu()
中)或向上遍历(在 follow_dotdot_rcu()
中)时也会检查它。如果发现它已更改,则整个 RCU-walk 序列将被中止,并由 REF-walk 再次处理路径。
如果 RCU-walk 发现 mount_lock
没有改变,那么它可以确定,如果 REF-walk 对每个 vfsmount 都取得了计数引用,结果也会相同。这确保了不变式成立,至少对于 vfsmount 结构而言。
dentry->d_seq
和 nd->seq
¶
RCU-walk 不在 d_reflock
上获取计数或锁,而是采样每个 dentry 的 d_seq
seqlock,并将序列号存储在 nameidata 结构的 seq
字段中,因此 nd->seq
应该始终是 nd->dentry
的当前序列号。在复制之后、使用 dentry 的名称、父级或 inode 之前,需要重新验证此数字。
名称的处理我们已经看过,而父项只在 follow_dotdot_rcu()
中访问,该函数相当简单地遵循所需的模式,尽管它处理了三种不同的情况。
当不在挂载点时,会遵循 d_parent
并收集其 d_seq
。当我们在挂载点时,我们会改为遵循 mnt->mnt_mountpoint
链接以获取新的 dentry 并收集其 d_seq
。然后,在最终找到要遵循的 d_parent
后,我们必须检查是否已到达挂载点,如果到达,则必须找到该挂载点并遵循 mnt->mnt_root
链接。这可能意味着一种有些不寻常但肯定可能发生的情况,即路径查找的起点位于文件系统的某个已挂载部分,因此从根目录不可见。
存储在 ->d_inode
中的 inode 指针更有趣一些。inode 总是需要至少访问两次,一次是确定它是否为 NULL,另一次是验证访问权限。符号链接处理也需要一个经过验证的 inode 指针。与其在每次访问时都重新验证,不如在第一次访问时复制一份,并将其存储在 nameidata
的 inode
字段中,从那里可以安全访问而无需进一步验证。
lookup_fast()
是 RCU 模式下唯一使用的查找例程,因为 lookup_slow()
太慢且需要锁。正是在 lookup_fast()
中,我们发现了对当前 dentry 重要的“手递手”跟踪。
当前的 dentry
和当前的 seq
数字被传递给 __d_lookup_rcu()
,该函数在成功时返回一个新的 dentry
和一个新的 seq
数字。lookup_fast()
随后复制 inode 指针并重新验证新的 seq
数字。然后它最后一次使用旧的 seq
数字验证旧的 dentry
,然后才继续。获取新 dentry 的 seq
数字然后检查旧 dentry 的 seq
数字的这个过程,与我们在 REF-walk 中看到的在释放旧 dentry 的计数引用之前获取新 dentry 的计数引用这一过程完全相同。
无 inode->i_rwsem
甚至无 rename_lock
¶
信号量是一种相当重量级的锁,只有在允许休眠时才能获取。由于 rcu_read_lock()
禁止休眠,因此 inode->i_rwsem
在 RCU-walk 中不起作用。如果其他线程获取 i_rwsem
并以 RCU-walk 需要注意的方式修改目录,结果将是 RCU-walk 未能找到它正在寻找的 dentry,或者它会找到一个 read_seqretry()
无法验证的 dentry。在这两种情况下,它都会降级到 REF-walk 模式,该模式可以获取所需的任何锁。
尽管 rename_lock
可以被 RCU-walk 使用,因为它不需要任何休眠,但 RCU-walk 不会费心使用它。REF-walk 使用 rename_lock
来防止在搜索 dcache 中的哈希链时发生变化。这可能导致未能找到实际存在的内容。当 RCU-walk 未能在 dentry 缓存中找到某些内容时,无论它是否真的存在,它都会降级到 REF-walk 模式,并使用适当的锁重新尝试。这巧妙地处理了所有情况,因此添加额外的 rename_lock
检查不会带来显著价值。
unlazy walk()
和 complete_walk()
¶
“降级到 REF-walk”通常涉及调用 unlazy_walk()
,之所以这样命名是因为“RCU-walk”有时也被称为“lazy walk”。当沿着路径到达当前的 vfsmount/dentry 对似乎已成功进行,但下一步出现问题时,会调用 unlazy_walk()
。这可能发生在以下情况:dcache 中找不到下一个名称;在持有 rcu_read_lock()
(禁止休眠)时无法完成权限检查或名称重新验证;发现自动挂载点;或者涉及符号链接的几种情况。它也会在查找到达最终组件或路径的末尾时(取决于使用的特定查找方式)从 complete_walk()
调用。
不触发调用 unlazy_walk()
而退出 RCU-walk 的其他原因包括发现无法立即处理的不一致性,例如 mount_lock
或其中一个 d_seq
序列锁报告更改。在这些情况下,相关函数将返回 -ECHILD
,这将向上渗透直到触发从顶部使用 REF-walk 的新尝试。
对于那些 unlazy_walk()
是一个选项的情况,它本质上是对其持有的每个指针(vfsmount、dentry 以及可能的某些符号链接)进行引用,然后验证相关序列锁是否未被更改。如果发生了更改,它也会以 -ECHILD
中止,否则到 REF-walk 的转换成功,查找过程继续。
对这些指针进行引用并不像简单地递增计数器那么简单。如果你已经有了一个引用(通常通过另一个对象间接获得),它可以用作第二个引用,但如果你根本没有计数引用,它就不够了。对于 dentry->d_lockref
,除非它被明确标记为“dead”(这涉及到将计数器设置为 -128
),否则递增引用计数器以获取引用是安全的。lockref_get_not_dead()
实现了这一点。
对于 mnt->mnt_count
,只要随后使用 mount_lock
来验证引用,获取引用就是安全的。如果该验证失败,则可能不安全以调用 mnt_put()
的标准方式直接删除该引用——卸载可能已经进展得太远。因此,legitimize_mnt()
中的代码,当它发现所获得的引用可能不安全时,会检查 MNT_SYNC_UMOUNT
标志以确定简单的 mnt_put()
是否正确,或者它是否应该只是递减计数并假装这一切从未发生过。
文件系统中的注意事项¶
RCU-walk 几乎完全依赖于缓存信息,并且通常根本不会调用文件系统。然而,除了前面提到的组件名称比较之外,文件系统可能被包含在 RCU-walk 中的两个地方,并且它必须知道要小心。
如果文件系统具有非标准的权限检查要求——例如可能需要与服务器检查的网络文件系统——则 i_op->permission
接口可能会在 RCU-walk 期间被调用。在这种情况下,会传递一个额外的“MAY_NOT_BLOCK
”标志,以便它知道不能休眠,而是如果不能及时完成则返回 -ECHILD
。i_op->permission
获得了 inode 指针,而不是 dentry,因此它不需要担心进一步的一致性检查。但是,如果它访问任何其他文件系统数据结构,它必须确保它们在仅持有 rcu_read_lock()
的情况下可以安全访问。这通常意味着它们必须使用 kfree_rcu()
或类似方式释放。
如果文件系统可能需要重新验证 dcache 条目,那么 d_op->d_revalidate
也可能在 RCU-walk 中被调用。这个接口确实传递了 dentry,但无法访问 inode
或来自 nameidata
的 seq
号,因此在访问 dentry 中的字段时需要格外小心。这种“格外小心”通常涉及使用 READ_ONCE() 来访问字段,并在使用之前验证结果是否非 NULL。这种模式可以在 nfs_lookup_revalidate()
中看到。
一对模式¶
在 REF-walk 和 RCU-walk 的细节中,以及在整体上,有几个相关的模式值得注意。
第一个是“快速尝试并检查,如果失败则慢速尝试”。我们可以在高层方法中看到这一点,即首先尝试 RCU-walk,然后尝试 REF-walk,以及在 unlazy_walk()
用于将剩余路径切换到 REF-walk 的地方。我们之前在 dget_parent()
遵循“..
”链接时也看到了这一点。它尝试一种快速方式获取引用,然后在需要时回退到获取锁。
第二种模式是“快速尝试并检查,如果失败则重复尝试”。这在 REF-walk 中使用 rename_lock
和 mount_lock
时可见。RCU-walk 不使用这种模式——如果出现任何问题,中止并尝试更稳健的方法要安全得多。
这里的重点是“快速尝试并检查”。它可能应该是“快速且仔细地尝试,然后检查”。需要检查这一事实提醒我们系统是动态的,只有有限数量的东西是完全安全的。整个过程中最常见的错误原因是假设某物是安全的,而实际上它并非如此。有时有必要仔细考虑究竟是什么保证了每次访问的安全性。
符号链接之旅¶
我们将考察几个基本问题以理解符号链接的处理:符号链接栈,以及缓存生命周期,这将帮助我们理解符号链接的整体递归处理,并引出最终组件所需的特殊处理。然后,考察访问时间更新和查找控制的各种标志的总结将结束本篇故事。
符号链接栈¶
在最终组件之前,路径中只有两种文件系统对象可以有效出现:目录和符号链接。处理目录相当简单:新目录简单地成为解释路径中下一个组件的起点。处理符号链接需要更多的工作。
从概念上讲,可以通过编辑路径来处理符号链接。如果一个组件名称引用了一个符号链接,那么该组件将被链接的主体替换,如果该主体以“/”开头,则路径中所有前面的部分都将被丢弃。这是“readlink -f
”命令所做的,尽管它也编辑掉了“.
”和“..
”组件。
在查找路径时,直接编辑路径字符串并非必要,丢弃早期组件也毫无意义,因为它们无论如何都不会被查看。跟踪所有剩余组件很重要,但它们当然可以分开保存;无需将它们连接起来。由于一个符号链接很容易引用另一个符号链接,而另一个符号链接又可以引用第三个,我们可能需要保留多个路径的剩余组件,每个路径在前面的路径完成后进行处理。这些路径残余保留在一个有限大小的栈上。
对单个路径查找中可以出现的符号链接数量施加限制有两个原因。最明显的是避免循环。如果一个符号链接直接或通过中间实体引用自身,那么遵循该符号链接将永远无法成功完成——必须返回错误 ELOOP
。无需施加限制也可以检测循环,但限制是最简单的解决方案,并且考虑到限制的第二个原因,这已经足够了。
第二个原因最近由 Linus 阐述:Linus 最近概述了
因为它也关乎延迟和拒绝服务问题。我们需要很好地应对真正的循环,也要应对“非常深”的非循环。这与内存使用无关,而是用户触发不合理的 CPU 资源。
Linux 对任何路径名长度施加了限制:PATH_MAX
,即 4096。这个限制有几个原因;不让内核在一个路径上花费太多时间是其中之一。使用符号链接可以有效地生成更长的路径,因此出于同样的原因需要某种限制。Linux 对任何路径查找中的符号链接数量最多限制为 40 (MAXSYMLINKS)。它之前还对最大递归深度施加了八个的限制,但在实现单独的栈时,该限制提高到 40,因此现在只有一个限制。
我们在前一篇文章中遇到的 nameidata
结构包含一个小型栈,可用于存储最多两个符号链接的剩余部分。在许多情况下,这将足够。如果不够,则会分配一个单独的栈,其中有足够的空间容纳 40 个符号链接。路径名查找永远不会超过该栈,因为一旦检测到第 40 个符号链接,就会返回错误。
看起来好像这个栈只需要存储名称残余,但我们需要更多。要理解这一点,我们需要继续讨论缓存生命周期。
缓存符号链接的存储和生命周期¶
与其他文件系统资源(如 inode 和目录条目)一样,Linux 会缓存符号链接,以避免重复昂贵的外部存储访问。对于 RCU-walk 来说,能够找到并临时持有这些缓存条目尤为重要,这样它就不需要降级到 REF-walk。
虽然每个文件系统都可以自由选择,但符号链接通常存储在两个位置之一。短符号链接通常直接存储在 inode 中。当文件系统分配 struct inode
时,它通常会分配额外的空间来存储私有数据(内核中常见的面向对象设计模式)。这有时会包括符号链接的空间。另一个常见位置是页缓存,它通常存储文件内容。符号链接中的路径名可以被视为该符号链接的内容,并且可以像文件内容一样轻松地存储在页缓存中。
当以上两种方式都不适用时,最可能的情况是文件系统会分配一些临时内存,并在需要时将符号链接内容复制或构建到该内存中。
当符号链接存储在 inode 中时,它与 inode 具有相同的生命周期,而 inode 本身受到 RCU 或 dentry 上的计数引用保护。这意味着路径名查找用于安全访问 dcache 和 icache(inode 缓存)的机制足以安全访问某些缓存的符号链接。在这些情况下,inode 中的 i_link
指针被设置为指向符号链接的存储位置,并且可以在需要时直接访问。
当符号链接存储在页缓存或其他地方时,情况就不那么简单了。对 dentry 甚至 inode 的引用不意味着对该 inode 的缓存页有任何引用,即使 rcu_read_lock()
也不足以确保页不会消失。因此,对于这些符号链接,路径名查找代码需要请求文件系统提供一个稳定的引用,并且重要的是,在完成后需要释放该引用。
即使在 RCU-walk 模式下,通常也可以对缓存页进行引用。它确实需要对内存进行更改,这最好避免,但这不一定是很大的成本,而且比完全退出 RCU-walk 模式要好。即使是那些分配空间来复制符号链接的文件系统,也可以使用 GFP_ATOMIC
来成功分配内存,而无需退出 RCU-walk。如果文件系统无法在 RCU-walk 模式下成功获取引用,它必须返回 -ECHILD
,然后调用 unlazy_walk()
返回到 REF-walk 模式,在该模式下文件系统允许休眠。
所有这些发生的地方是 i_op->get_link()
inode 方法。这在 RCU-walk 和 REF-walk 中都会被调用。在 RCU-walk 中,dentry*
参数为 NULL,->get_link()
可以返回 -ECHILD 以退出 RCU-walk。就像我们之前看过的 i_op->permission()
方法一样,->get_link()
需要小心,确保它引用的所有数据结构在不持有计数引用而只持有 RCU 锁的情况下可以安全访问。一个回调 struct delayed_called
将被传递给 ->get_link()
:文件系统可以通过 set_delayed_call() 设置自己的 put_link 函数和参数。稍后,当 VFS 想要放置链接时,它将调用 do_delayed_call() 以调用带有参数的回调函数。
为了在 walk 完成时(无论是在 RCU-walk 还是 REF-walk 中)释放对每个符号链接的引用,符号链接栈需要包含,除了路径残余之外:
struct path
提供对先前路径的引用const char *
提供对先前名称的引用seq
允许路径安全地从 RCU-walk 切换到 REF-walkstruct delayed_call
用于稍后调用。
这意味着符号链接栈中的每个条目需要保存五个指针和一个整数,而不是仅仅一个指针(路径残余)。在 64 位系统上,每个条目大约 40 字节;40 个条目加起来总共 1600 字节,不到半页。所以看起来很多,但绝非过度。
请注意,在给定的栈帧中,路径残余(name
)不是其他字段所引用的符号链接的一部分。它是该符号链接完全解析后要遵循的残余。
跟随符号链接¶
link_path_walk()
中的主循环无缝地遍历路径中的所有组件以及所有非最终符号链接。随着符号链接的处理,name
指针被调整为指向新的符号链接,或者从栈中恢复,因此大部分循环不需要注意。将这个 name
变量进出栈非常简单;压入和弹出引用则稍微复杂一些。
当找到符号链接时,walk_component()
通过 step_into()
调用 pick_link()
,后者从文件系统返回链接。如果操作成功,旧路径 name
被放置到栈上,新值在一段时间内用作 name
。当找到路径的末尾(即 *name
为 '\0'
)时,旧的 name
从栈中恢复,路径遍历继续。
压入和弹出引用指针(inode、cookie 等)更加复杂,部分原因是希望处理尾递归。当符号链接的最后一个组件本身指向一个符号链接时,我们希望在压入刚找到的符号链接之前,从栈中弹出刚完成的符号链接,以避免留下空的路径残余,这只会妨碍处理。
最方便的做法是在 walk_component()
中,当符号链接被发现时立即将新的符号链接引用压入栈中;walk_component()
也是在遍历最后一个组件时需要查看旧符号链接的最后一段代码。因此,walk_component()
释放旧符号链接并在压入新符号链接的引用信息之前弹出引用,这非常方便。它受三个标志的引导:WALK_NOFOLLOW
禁止它在找到符号链接时跟随;WALK_MORE
表示现在释放当前符号链接还为时过早;以及 WALK_TRAILING
表示它处于查找的最终组件,因此我们将检查用户空间标志 LOOKUP_FOLLOW
以决定当它是符号链接时是否跟随它,并调用 may_follow_link()
来检查我们是否有权限跟随它。
没有最终组件的符号链接¶
有几对特殊情况的符号链接值得进一步解释。它们都导致在 nameidata
中设置一个新的 struct path
(带有 mount 和 dentry),并导致 pick_link()
返回 NULL
。
更明显的情况是到“/
”的符号链接。所有以“/
”开头的符号链接都在 pick_link()
中被检测到,它会重置 nameidata
以指向有效的根文件系统。如果符号链接只包含“/
”,那么就没有更多可做的了,根本没有组件,因此返回 NULL
以指示可以释放符号链接并丢弃栈帧。
另一种情况涉及 /proc
中看起来像符号链接但实际上不是的东西(因此通常被称为“魔术链接”)。
$ ls -l /proc/self/fd/1
lrwx------ 1 neilb neilb 64 Jun 13 10:19 /proc/self/fd/1 -> /dev/pts/4
任何进程中的每个打开文件描述符在 /proc
中都表示为一个看起来像符号链接的对象。它实际上是对目标文件的引用,而不仅仅是它的名称。当你 readlink
这些对象时,你会得到一个可能指向同一文件的名称——除非它已被取消链接或被挂载覆盖。walk_component()
跟踪其中一个时,“procfs”中的 ->get_link()
方法不返回字符串名称,而是调用 nd_jump_link()
,它就地更新 nameidata
以指向该目标。->get_link()
然后返回 NULL
。同样,没有最终组件,pick_link()
返回 NULL
。
在最终组件中跟随符号链接¶
所有这些都导致 link_path_walk()
遍历每个组件,并跟随它找到的所有符号链接,直到到达最终组件。这只是在 nameidata
的 last
字段中返回。对于某些调用者来说,这正是他们所需要的;他们希望如果 last
名称不存在则创建它,如果存在则报错。其他调用者希望如果找到符号链接则跟随它,并可能对该符号链接的最后一个组件进行特殊处理,而不仅仅是原始文件名的最后一个组件。这些调用者可能需要一遍又一遍地在连续的符号链接上调用 link_path_walk()
,直到找到一个不指向另一个符号链接的符号链接。
这种情况由 link_path_walk()
的相关调用者(例如 path_lookupat()
、path_openat()
)通过一个循环来处理,该循环调用 link_path_walk()
,然后通过调用 open_last_lookups()
或 lookup_last()
来处理最终组件。如果需要跟随一个符号链接,open_last_lookups()
或 lookup_last()
会正确设置,并返回路径,以便循环重复,再次调用 link_path_walk()
。如果每个符号链接的最后一个组件都是另一个符号链接,这可能会循环多达 40 次。
在检查最终组件的各种函数中,open_last_lookups()
是最有趣的,因为它与 do_open()
协同工作以打开文件。open_last_lookups()
的一部分在持有 i_rwsem
的情况下运行,这部分在一个单独的函数 lookup_open()
中。
完全解释 open_last_lookups()
和 do_open()
超出了本文的范围,但一些要点应该能帮助那些有兴趣探索代码的人。
do_open() 不仅仅是查找目标文件,它在 open_last_lookup() 之后用于打开该文件。如果文件在 dcache 中找到,则使用
vfs_open()
进行此操作。如果未找到,则lookup_open()
将调用atomic_open()
(如果文件系统提供)以将最终查找与打开操作结合起来,或者直接执行单独的i_op->lookup()
和i_op->create()
步骤。在后一种情况下,这个新找到或创建的文件的实际“打开”将由vfs_open()
执行,就像在 dcache 中找到名称一样。如果缓存信息不够最新,
vfs_open()
可能会失败并返回-EOPENSTALE
。如果它在 RCU-walk 中,将返回-ECHILD
,否则返回-ESTALE
。当返回-ESTALE
时,调用者可以设置LOOKUP_REVAL
标志并重试。与其它创建系统调用(如
mkdir
)不同,使用 O_CREAT 标志打开文件会跟随最终组件中的符号链接。因此,以下序列ln -s bar /tmp/foo echo hello > /tmp/foo
将创建一个名为
/tmp/bar
的文件。如果设置了O_EXCL
,则不允许这样做,但对于 O_CREAT 打开,其处理方式与非创建打开非常相似:lookup_last()
或open_last_lookup()
返回非NULL
值,然后调用link_path_walk()
,打开过程继续在找到的符号链接上进行。
更新访问时间¶
我们之前谈到 RCU-walk 时说它“不加锁,不增计数,不留痕迹”。我们后来看到,在处理符号链接时,有时需要一些“痕迹”,因为可能需要计数引用(甚至内存分配)。但这些痕迹最好保持在最小。
另一个在遍历符号链接时可能留下痕迹,而这种方式不影响目录的地方是更新访问时间。在 Unix(和 Linux)中,每个文件系统对象都有一个“上次访问时间”,即“atime
”。为了 atime
的目的,通过目录访问其中的文件不被视为一次访问;只有列出目录内容才能更新其 atime
。符号链接似乎有所不同。读取符号链接(使用 readlink()
)和在前往其他目标的过程中查找符号链接都可以更新该符号链接的 atime
。
尚不清楚为何会如此;POSIX 对此主题鲜有提及。最明确的声明是,如果某个特定实现更新了 POSIX 未指定位置的时间戳,则必须记录此情况,“除非是路径名解析引起的变化则无需记录”。这似乎意味着 POSIX 并不真正关心路径名查找期间的访问时间更新。
历史考察表明,在 Linux 1.3.87 之前,至少 ext2 文件系统在跟踪链接时不会更新 atime。不幸的是,我们没有关于这种行为为何改变的记录。
无论如何,现在必须更新访问时间,而该操作可能相当复杂。在执行此操作时尝试保持在 RCU-walk 模式下最好避免。幸运的是,通常允许跳过 atime
更新。因为 atime
更新在各个方面都会导致性能问题,Linux 支持 relatime
挂载选项,该选项通常将未更改文件的 atime
更新限制为每天一次(符号链接一旦创建就永远不会更改)。即使没有 relatime
,许多文件系统也以一秒的粒度记录 atime
,因此每秒只需一次更新。
在 RCU-walk 模式下,很容易测试是否需要更新 atime
,如果不需要,则可以跳过更新并继续 RCU-walk 模式。只有当确实需要更新 atime
时,路径遍历才会降级到 REF-walk。所有这些都在 get_link()
函数中处理。
几个标志¶
结束路径遍历之旅的合适方式是列出可以存储在 nameidata
中以指导查找过程的各种标志。其中许多标志仅在考虑最终组件时才有意义,其他标志反映路径查找的当前状态,还有一些标志对路径查找中遇到的所有路径组件施加限制。
然后是 LOOKUP_EMPTY
,它在概念上与其他的格格不入。如果未设置此标志,空路径名会很早地导致错误。如果设置了此标志,空路径名则不被视为错误。
全局状态标志¶
我们已经遇到了两个全局状态标志:LOOKUP_RCU
和 LOOKUP_REVAL
。它们在三种总体查找方法之间进行选择:RCU-walk、REF-walk 和强制重新验证的 REF-walk。
LOOKUP_PARENT
指示尚未到达最终组件。这主要用于告诉审计子系统正在审计的特定访问的完整上下文。
ND_ROOT_PRESET
表示 nameidata
中的 root
字段是由调用者提供的,因此当不再需要时,不应将其释放。
ND_JUMPED
意味着当前 dentry 不是因为它名称正确而被选中,而是由于其他原因。当跟随“..
”、跟随指向 /
的符号链接、跨越挂载点或访问“/proc/$PID/fd/$FD
”符号链接(也称为“魔术链接”)时会发生这种情况。在这种情况下,文件系统未被要求重新验证名称(使用 d_revalidate()
)。在这种情况下,inode 可能仍然需要重新验证,因此如果查找完成时(可能在最终组件,或者在创建、取消链接或重命名时在倒数第二个组件)设置了 ND_JUMPED
,则会调用 d_op->d_weak_revalidate()
。
解析限制标志¶
为了让用户空间保护自己免受某些竞争条件和涉及更改路径组件的攻击场景,提供了一系列标志,这些标志对路径查找过程中遇到的所有路径组件施加限制。这些标志通过 openat2()
的 resolve
字段公开。
LOOKUP_NO_SYMLINKS
阻止所有符号链接遍历(包括魔术链接)。这与 LOOKUP_FOLLOW
明显不同,因为后者仅与限制跟踪符号链接有关。
LOOKUP_NO_MAGICLINKS
阻止所有魔术链接遍历。文件系统必须确保它们从 nd_jump_link()
返回错误,因为这就是 LOOKUP_NO_MAGICLINKS
和其他魔术链接限制的实现方式。
LOOKUP_NO_XDEV
阻止所有 vfsmount
遍历(这包括绑定挂载和普通挂载)。请注意,包含查找的 vfsmount
由路径查找到达的第一个挂载点决定——绝对路径以 /
的 vfsmount
开始,相对路径以 dfd
的 vfsmount
开始。魔术链接只有在路径的 vfsmount
未更改的情况下才被允许。
LOOKUP_BENEATH
阻止任何解析到起始点之外的路径组件。这是通过阻止 nd_jump_root()
以及如果“..”会跳出起始点则阻止它来实现的。rename_lock
和 mount_lock
用于检测针对“..”解析的攻击。魔术链接也被阻止。
LOOKUP_IN_ROOT
将所有路径组件解析为仿佛起始点就是文件系统根目录。nd_jump_root()
将解析带回到起始点,而起始点处的“..”将作为无操作。与 LOOKUP_BENEATH
一样,rename_lock
和 mount_lock
用于检测针对“..”解析的攻击。魔术链接也被阻止。
最终组件标志¶
其中一些标志仅在考虑最终组件时设置。其他标志仅在考虑该最终组件时才进行检查。
LOOKUP_AUTOMOUNT
确保如果最终组件是自动挂载点,则触发挂载。有些操作无论如何都会触发它,但像 stat()
这样的操作则故意不触发。statfs()
需要触发挂载,但其行为与 stat()
非常相似,因此它会设置 LOOKUP_AUTOMOUNT
,就像“quotactl()
”和“mount --bind
”的处理一样。
LOOKUP_FOLLOW
的功能与 LOOKUP_AUTOMOUNT
类似,但针对符号链接。有些系统调用会隐式设置或清除它,而其他系统调用则有 API 标志,例如 AT_SYMLINK_FOLLOW
和 UMOUNT_NOFOLLOW
来控制它。它的效果与我们之前遇到的 WALK_GET
相似,但用法不同。
LOOKUP_DIRECTORY
坚持最终组件必须是目录。各种调用者会设置此标志,当发现最终组件后面带有斜杠时也会设置它。
最后,LOOKUP_OPEN
、LOOKUP_CREATE
、LOOKUP_EXCL
和 LOOKUP_RENAME_TARGET
不由 VFS 直接使用,但可供文件系统,特别是 ->d_revalidate()
方法使用。如果文件系统知道很快就会被要求打开或创建文件,它可以选择不过分地重新验证。这些标志以前对 ->lookup()
也很有用,但随着 ->atomic_open()
的引入,它们在那里的相关性降低了。
路之尽头¶
尽管路径查找代码复杂,但它似乎状况良好——与几个版本前相比,现在理解各个部分确实更容易了。但这并不意味着它“已完成”。如前所述,RCU-walk 目前只跟随存储在 inode 中的符号链接,因此,虽然它处理许多 ext4 符号链接,但它对 NFS、XFS 或 Btrfs 没有帮助。这种支持很可能不会延迟太久。