linux kernel 路径查询
路径名查询说明
路径名查找几乎没有可以取巧的方法,因此反而比较复杂。有许多规则、特殊情况和实现替代方案,所有这些都使读者感到困惑。
然而,计算机科学早就熟悉这种复杂性,这就是我们广泛使用的一种方法——“分而治之”。对于分析的早期部分,我们将划分符号链接 - 将它们留到最后部分。在我们讨论符号链接之前,我们有另一个基于 VFS 锁定方法的主要部分,这将允许我们分别回顾“REF-walk”和“RCU-walk”。但我们正在超越自己。我们需要首先澄清一些重要的低级区别。
用于标识文件系统中的对象的路径名(有时是“文件名”)对大多数读者来说都很熟悉。它们包含两种类型的元素:“斜杠”是一个或多个“/”字符的序列,以及“文件夹/文件元素”是一个或多个非“/”字符的序列。
共有两种路径:那些以斜杠开始的是“绝对的”,并且从文件系统根目录开始;其他是“相对的”,从当前目录开始,或者从*at()
系统调用(如openat())所给出的文件描述符指定的其他位置开始。
将相对路径描述为从当前目录开始是可以的,但这并不总是准确的:路径名可以既没有斜杠又没有文件/文件夹名,换句话说,它可以是空的。这在POSIX中通常是被禁止的,但是Linux中的一些*at()
系统调用在给出AT_EMPTY_PATH
标志时允许这样做。例如,如果在可执行文件上有一个打开的文件描述符,那么可以通过传递文件描述符、空路径和AT_EMPTY_PATH
标志,调用execveat()
来执行它。
这些路径可以分为两个部分:最终元素
和其他内容
。“其他一切”是容易的部分。在所有情况下,它必须识别一个已经存在的目录,否则将报告一个错误,如ENOENT
或ENOTDIR
。
最后一个组成部分就没那么简单了。不仅不同的系统调用对它的解释非常不同(例如,一些创建了它,一些没有),而且它甚至可能不存在:空的路径名或只是斜杠的路径名都没有最终元素。如果它确实存在,它可能是".“或”..",处理方式与其他组件截然不同。
如果一个路径名以斜杠结尾,例如“/tmp/foo/”,它可能会考虑有一个空的最终组件。在很多方面,这会导致正确的结果,但并不总是如此。特别是,mkdir()和rmdir()都会创建或删除一个由最终组件命名的目录,它们需要处理以“/”结尾的路径名。根据POSIX:
- 包含至少一个
非切割
字符的路径(pathname),以一个或多个切割字符结尾,是不合法的,除非最后一个切割符
之前的的元素是一个已存在的文件夹,或者是一个将要创建的文件夹,这样的路径才是合法的。
Linux路径名遍历代码(主要在fs/name.c中)处理所有这些问题:将路径分解为组件,与最终组件完全分开处理“其他所有内容”,并检查在不允许的地方是否使用末尾的斜杠。它还解决了并发访问的重要问题。
当一个进程查找路径名时,另一个进程可能会做出影响该查找的更改。一个相当极端的情况是,如果“a/b”被重命名为“a/c/b”,而另一个进程正在查找“a/b/..”,该过程可能导致查询结果为“a/c”。大多数竞争要微妙得多,路径名查找的主要任务是防止它们产生破坏性影响。许多可能的竞争在“dcache”上下文中表现得最为清晰,理解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:包含refcount和自旋锁的简单并发管理
仔细分类后,我们现在可以开始观察沿着路径行走的实际过程。特别地,我们将从处理路径名的“其他一切”部分开始,并重点关注并发管理的“ref-walk”方法。这段代码可以在link_path_walk()
函数中找到,如果你忽略所有只有在设置了LOOKUP_RCU
(表示使用了RCU-walk
)时才运行的地方。
REF-walk
对锁和引用计数的处理相当繁琐。不像以前的“big kernel lock”时代那样笨拙,但肯定不用担心在需要锁的时候使用锁。它使用各种不同的并发控制。对各种原语的背景理解是假设的,或者可以从其他地方收集,如Meet the locker。
REF-walk
使用的锁定机制包括:
dentry->d_lockref
这使用了lockref
原语来提供自旋锁和引用计数。这个原语的特殊之处在于概念序列的“lock
; inc_ref
; unlock
通常可以通过单个原子内存操作来执行。在dentry
上持有引用可以确保dentry
不会突然被释放并用于其他内容,因此各个字段中的值将按照预期的方式运行。它还在一定程度上保护了对inode
的->d_inode
引用。dentry
与其inode
之间的关联是相当持久的。例如,当重命名一个文件时,dentry
和inode
会一起移动到新位置。当一个文件被创建时,dentry
最初是负的(例如,d_inode
是NULL
),并在创建过程中被赋值给新的inode
。当一个文件被删除时,可以通过将d_inode
设置为NULL
或从用于在父目录中查找名称的哈希表中删除它来反映在缓存中。如果dentry
仍在使用,则使用第二种选择,因为在删除文件后继续使用打开的文件是完全合法的,而且有dentry
在身边会有所帮助。如果dentry
没有被使用(例如,如果d_lockref
中的引用计数是一个),只有这时d_inode
才会被设置为NULL
。对于一个非常常见的情况,这样做更有效。因此,只要统计的引用保存在dentry
中,->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
是一个sequlock
,每当重命名任何dentry
时都会更新它。如果d_lookup
在未成功扫描哈希表中的链时发现发生了重命名,它会简单地再次尝试。当解析“..
”时,rename_lock
还用于检测和防御针对LOOKUP_BENEATH
和LOOKUP_IN_ROOT
的潜在攻击。(父目录被移到根目录之外,绕过了path_equal()
检查)。如果在查找过程中更新了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
。一个例外是由于内存压力而从dcache
中删除空闲的dentry
。这使用了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
)。
要在缓存中添加一个新的dentry
,同时只持有i_rwsem
上的共享锁,线程必须调用d_alloc_parallel()
。它分配一个dentry
,在其中存储所需的名称和父元素,检查主哈希表或辅助哈希表中是否已经存在匹配的dentry
,如果没有,则使用DCACHE_PAR_LOOKUP
集在辅助哈希表中存储新分配的dentry
。
如果在主哈希表中找到匹配的dentry
,则返回该dentry
,调用者可以知道它在与添加条目的其他线程的竞争中失败了。如果在两个缓存中都没有找到匹配的dentry
,则返回新分配的dentry
,调用者可以从DCACHE_PAR_LOOKUP
中检测到这一点。在本例中,它知道它已经赢得了比赛,现在负责请求文件系统执行查找并找到匹配的索引节点。当查找完成时,它必须调用d_lookup_done()
,它会清除标记,并进行一些其他的内部维护,包括从二级哈希表中删除dentry
——它通常已经被添加到主哈希表中。注意,waitqueue_head
结构体被传递给d_alloc_parallel()
,并且当这个waitqueue_head
仍然在作用域中时,必须调用d_lookup_done()
。
如果在二级散列表中找到匹配的dentry
, d_alloc_parallel()
需要做更多的工作。它首先等待DCACHE_PAR_LOOKUP
被清除,使用一个wait_queue
被传递给赢得竞争的d_alloc_parallel()
实例,该实例将通过调用d_lookup_done()
被唤醒。然后检查dentry
现在是否已添加到主散列表中。如果有,则返回dentry
,调用者只会看到它输掉了所有竞赛。如果没有将它添加到主哈希表中,最可能的解释是使用d_splice_alias()
添加了其他dentry
。在任何情况下,d_alloc_parallel()
都会从一开始重复所有的查找,并且通常会从主哈希表返回一些内容。
mnt->mnt_count
mnt_count
是“mount”结构上每个cpu的引用计数器。这里的Per-CPU意味着增加计数很便宜,因为它只使用CPU本地内存,但检查计数是否为零则很昂贵,因为它需要检查每个CPU。接受mnt_count
引用可以防止挂载结构作为常规卸载操作的结果消失,但不能防止“惰性”卸载。因此,持有mnt_count
不能确保挂载保持在名称空间中,特别是不能稳定到挂载的dentry
的链接。但是,它可以确保挂载数据结构保持一致,并且提供了对挂载文件系统根目录的引用。因此,通过->mnt_count
的引用提供了对挂载的dentry
的稳定引用,而不是挂载的dentry
。
mount_lock
mount_lock
是一个全局序列锁,有点像rename_lock
。它可以用来检查是否对任何挂载点进行了更改。
当沿着树向下走(离开根节点)时,在通过挂载点时使用此锁检查挂载点是否安全。也就是说,读取seqlock
中的值,然后代码查找挂载在当前目录上的挂载(如果有挂载的话),并增加mnt_count
。最后,根据旧值检查mount_lock
中的值。如果没有变化,那么穿越是安全的。如果发生了更改,则减少mnt_count
,并重试整个进程。
当向根目录遍历(遍历当前目录..
)链接时,需要多加注意。在这种情况下,seqlock
(包含一个计数器和一个自旋锁)被完全锁定,以防止在升级时对任何挂载点进行任何更改。需要使用此锁定来稳定到挂载dentry
的链接,而挂载上的引用本身不能确保这一点。
mount_lock
还用于在解析..
时检测和防御针对LOOKUP_BENEATH
和LOOKUP_IN_ROOT
的潜在攻击。(父目录被移到根目录之外,绕过了path_equal()
检查)。如果mount_lock
在查找过程中更新,并且路径遇到一个..
,一个潜在的攻击发生了,handle_dots()
将跳出-EAGAIN
。
RCU
最后,全局的(但非常轻量级的)RCU读锁会被时不时地持有,以确保某些数据结构不会意外被释放。
特别是在扫描dcache
散列表和挂载点散列表中的链时使用。
struct nameidata
在整个路径遍历的过程中,当前状态被存储在结构nameidata中,“namei”是将“名称”转换为“inode”的函数的传统名称——可以追溯到第一版Unix。Struct nameidata包含(在其他字段中):
struct path path
路径包含一个结构vfmount
(嵌入在结构挂载中)和一个结构dentry
。它们一起记录遍历的当前状态。它们开始引用起始点(当前工作目录、根目录或由文件描述符标识的其他目录),并在每一步中更新。通过d_lockref
和mnt_count
的引用总是被保存。
struct qstr last
这是一个字符串,其长度(即非null终止)是pathname
中的“next”组件。
int last_type
这是一个LAST_NORM
, LAST_ROOT
, LAST_DOT
或LAST_DOTDOT
。最后一个字段只有当类型是LAST_NORM
时才有效。
struct path root
它用于保存对文件系统的有效根目录的引用。通常不需要这个引用,所以只有在第一次使用它时,或者请求非标准根时,才会分配这个字段。在nameidata
中保留一个引用可以确保在整个路径遍历中只有一个根有效,即使它与chroot()
系统调用竞争。
应该注意的是,对于LOOKUP_IN_ROOT
或LOOKUP_BENEATH
,有效的根成为传递给openat2()
的目录文件描述符(它公开这些LOOKUP_flags
)。
当两个条件中的任何一个都成立时,就需要根节点:(1)路径名或符号链接以/
开头,或(2)一个..
正在被处理,..
必须来自根部的,必须永远停留在根部。”所使用的值通常是调用进程的当前根目录。当sysctl()
调用file_open_root()
,以及NFSv4
或Btrfs
调用mount_subtree()
时,可以提供备用根。在每种情况下,都要在文件系统的特定部分查找路径名,并且不能允许查找转义该子树。它的工作方式有点像本地chroot()
。
忽略符号链接的处理,我们现在可以描述link_path_walk()
函数,它处理除最后一个组件以外的所有内容:
给定一个路径(名称)
和一个nameidata结构(nd)
,检查当前目录是否具有执行权限,然后在更新last_type
和last
的同时在一个组件上推进名称。如果这是最后一个组件,则返回,否则调用walk_component()
并从头开始重复。
walk_component()
更简单。如果组件是LAST_DOTS
,它调用handle_dots()
,它执行前面描述的必要锁定。如果它找到一个LAST_NORM
组件,它首先调用lookup_fast()
,它只在dcache
中查找,但如果是这种类型的文件系统,它会要求文件系统重新验证结果。如果没有得到好的结果,它调用lookup_slow()
,它接受i_rwsem
,重新检查缓存,然后要求文件系统找到确定的答案。
作为walk_component()
的最后一步,将直接从walk_component()
或handle_dots()
调用step_into()
。它调用handle_mounts()
来检查和处理挂载点,在此过程中,将创建一个新的struct path
,其中包含对新dentry
的已统计的引用和对新vfmount
的引用,只有当它与前一个vfmount
不同时才会进行统计。然后,如果有符号链接,step_into()
调用pick_link()
来处理它,否则它将在struct nameidata
中安装新的结构路径,并删除不需要的引用。
在放弃对前一个dentry
的引用之前,这种hand-over-hand
的顺序获取新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_lookup()
开始的支持函数,处理O_CREAT
(带或不带O_EXCL
)、最后的/
字符和末尾的符号链接所需的所有复杂性。我们将在本系列的最后一部分中重新讨论这个问题,重点讨论这些符号链接。open_last_lookup()
有时会(但并非总是)接受i_rwsem
,这取决于它找到了什么。
每一个组件,或者调用它们的函数,都需要警惕最终组件不是LAST_NORM
的可能性。如果查找的目标是创建某个值,那么last_type
的任何值(LAST_NORM
除外)都将导致错误。例如,如果path_parentat()
报告LAST_DOTDOT
,那么调用者不会尝试创建该名称。它们还通过测试last.name[last.len]
来检查末尾的斜杠。如果在最后一个组件之外有任何字符,则必须是末尾的斜杠。
检验与加载
除了符号链接之外,REF-walk
过程中只有两个部分还没有涉及到。一种是对过期缓存条目的处理,另一种是自动加载。
在需要它的文件系统上,查找例程将调用->d_revalidate()
dentry
方法,以确保缓存的信息是当前的。这通常会确认有效性或从服务器更新一些细节。在某些情况下,它可能会发现,在向前遍历的过程中上已经发生了变化,以前被认为是有效的东西实际上是无效的。当发生这种情况时,整个路径的查找将被中止,并设置LOOKUP_REVAL
标志重新尝试。这迫使重新验证更加彻底。我们将在下一篇文章中看到这个重试过程的更多细节。
自动挂载点是文件系统中的一些位置,在这些位置上,如果试图查找一个名称,可能会触发对该查找的处理方式的更改,特别是在那里挂载一个文件系统。autofs
在Linux
文档树中详细介绍了这些内容,但这里有一些与路径查找相关的注意事项。
Linux VFS
有一个托管 dentry
的概念。这些dentry
有三个潜在的有趣的地方,它们对应着在dentry->d_flags
中可能设置的三个不同的标志:
-
DCACHE_MANAGE_TRANSIT
如果设置了这个标志,那么文件系统请求在处理任何可能的挂载点之前调用d_manage()
dentry
操作。它可以执行两种特定的服务: 它可以阻止以避免比赛。如果正在卸载一个自动挂载点,d_manage()
函数通常会等待该进程完成,然后才让新的查找继续进行,并可能触发新的自动挂载。 它可以有选择地只允许一些进程通过一个挂载点。当一个服务器进程正在管理自动挂载时,它可能需要访问一个目录,而不触发正常的自动挂载处理。该服务器进程可以将自己标识给autofs文件系统,然后通过返回-EISDIR给
它一个通过d_manage()
的特殊传递。 -
DCACHE_MOUNTED
这个标志设置在每个挂载的dentry上。由于Linux支持多个文件系统名称空间,因此dentry可能不是挂载在这个名称空间上,而是挂载在其他名称空间上。因此,这面旗帜被视为一种暗示,而不是承诺。
如果设置了这个标志,而d_manage()
没有返回-EISDIR
,那么就会调用lookup_mnt()
来检查挂载散列表(使用前面描述的mount_lock
),并可能返回一个新的vfmount
和一个新的dentry
(都带有计数的引用)。
DCACHE_NEED_AUTOMOUNT
如果d_manage()
允许我们走到这一步,而lookup_mnt()
没有找到挂载点,那么这个标志将导致调用d_automount()
dentry
操作。
d_automount()
操作可以是任意复杂的,可以与服务器进程等通信,但它最终应该报告有一个错误,没有什么可以挂载,或应该提供一个更新的struct path
与新的dentry
和vfmount
。
在后一种情况下,将调用finish_automount()
来安全地将新的挂载点安装到挂载表中。
这里没有导入的新锁,而且在这个处理过程中不要持有锁(只有计算过的引用),这一点很重要,因为很可能会出现扩展延迟。当我们下次研究rcu walk
时,这将变得更加重要,它对延迟特别敏感。
RCU-walk – Linux中更快的路径名查找
RCU-walk
是Linux中执行路径名查找的另一种算法。它在许多方面与REF-walk
相似,并且两者共享相当多的代码。rcu遍历的显著区别在于它允许并发访问的可能性。
我们注意到REF-walk
很复杂,因为有很多细节和特殊情况。RCU-walk
通过简单地拒绝处理一些情况来降低复杂性,它退回到REF-walk。rcu-walk
的困难来自于另一个方向:不熟悉。依赖于RCU的锁规则与传统的锁有很大的不同,所以我们将在这些规则上多花一些时间。
明确的角色划分
管理并发性的最简单方法是强制阻止任何其他线程更改给定线程正在查看的数据结构。如果没有其他线程考虑更改数据,而许多不同的线程想要同时读取数据,那么这将是非常可贵的。即使使用允许多个并发读取器的锁,更新当前读取器数量的简单操作也会带来不必要的成本。因此,当读取一个没有其他进程改变的共享数据结构时,目标是完全避免向内存写入任何内容。不加锁,不加计数,不留下痕迹。
前面描述的REF-walk
机制当然不遵循这一原则,但它实际上是为其他线程修改数据时设计的。相反,RCU-walk
是为有很多频繁的读取和只有偶尔的写数据的常见情况而设计的。在文件系统树的所有部分中,这可能并不常见,但在许多部分中,这将是常见的。对于其他部分来说,重要的是RCU-walk
可以快速回落到使用REF-walk
。
路径名查找总是以RCU-walk
模式开始,但只有当它所查找的内容在缓存中并且是稳定的时候才会继续存在。它轻快地沿着缓存的文件系统映像向下查找,不留下任何足迹,并仔细地观察它在哪里,以确保它不会出错。如果它注意到某些内容已经更改或正在更改,或者某些内容不在缓存中,那么它会尝试优雅地停止,并切换到REF-walk
。
这个停止需要获取当前vfmount
和dentry
上的一个已统计的引用,并确保这些仍然有效—使用REF-walk
的路径遍历将找到相同的条目。这是RCU-walk
必须保证的不变量。它只能做出决定,比如选择下一步,如果同时在树上走,REF-walk
也可以做出这样的决定。如果优雅停止成功,路径的其余部分将以可靠的REF-walk
(如果速度稍慢)处理。如果RCU-walk
发现它不能优雅地停止,它就会放弃,然后用REF-walk
从头开始。
在filename_lookup()
、filename_parentat()
、do_filp_open()
和do_file_open_root()
等函数中可以清楚地看到这种“尝试RCU-walk
,如果失败则尝试REF-walk
”的模式。这四个函数大致对应于我们前面遇到的三个path_*()
函数,每个函数都调用link_path_walk()
。path_*()
函数使用不同的模式标志调用,直到找到一个有效的模式。它们首先在LOOKUP_RCU
设置请求”RCU-walk
“时被调用。如果失败并报错为ECHILD
,它们将被再次调用,没有特殊标志来请求REF-walk
。如果其中任何一个报错,就会使用LOOKUP_REVAL
设置(而不是LOOKUP_RCU
设置)进行最后一次尝试,以确保在缓存中找到的条目被强制重新验证——通常情况下,只有当文件系统认为条目太老而不能信任时,才会重新验证条目。
LOOKUP_RCU
可能会在内部删除这个标志,然后切换到REF-walk
,但是不会尝试切换回RCU-walk
。RCU-walk
无法继续遍历的地方更有可能靠近树叶,所以没必要再切回 RCU-walk
。
RCU和seqlocks: 快速和轻量
毫无疑问,RCU
对于RCU walk
模式是至关重要的。rcu_read_lock()
在RCU-walk
遍历路径的整个过程中保持。它提供的特殊保证是,在锁被持有时,关键数据结构——dentry
、inodes
、super_blocks
和mount
——不会被释放。它们可能以这样或那样的方式被取消链接或失效,但内存不会被重新使用,因此各个字段中的值仍然是有意义的。这是RCU
提供的唯一保证;其他所有事情都是使用seqlock
完成的。
正如我们上面看到的,REF-walk
持有对当前dentry
和当前vfmount
的已统计的引用,并且在引用“next”dentry
或vfmount
之前不会释放这些引用。它有时也使用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
不是在每个vfmount
下行时对它进行计数,而是在遍历开始时对mount_lock
的状态进行采样,并将这个初始序列号存储在m_seq
字段的struct nameidata
中。这一个锁和一个序列号用于验证对所有vfmount
的所有访问,以及所有挂载点交叉。由于对挂载表的更改相对较少,所以在发生任何“挂载”或“卸载”时,采用REF-walk
是合理的。
m_seq
在rcu
遍历序列的末尾被检查(使用read_seqretry()
),无论在路径的其余部分切换到REF-walk
还是到达路径的末尾。它也会在挂载点向下遍历(在__follow_mount_rcu()
中)或向上遍历(在follow_dotdot_rcu()
中)时被检查。如果发现路径发生了变化,整个RCU-walk
序列将被终止,并通过REF-walk
再次处理该路径。
如果RCU-walk
发现mount_lock
没有改变,那么可以确定,如果REF-walk
对每个vfmount
进行引用计数,结果将是相同的。这确保了不变量保持不变,至少对于vfmount
结构是这样。
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
的重要的“hand over hand
”跟踪。
当前dentry
和当前seq
号被传递给__d_lookup_rcu()
,如果成功,返回一个新的dentry
和一个新的seq
号。然后,lookup_fast()
复制inode
指针并重新验证新的seq
号。然后,它最后一次使用旧seq
编号1验证旧dentry
,然后才继续。这个获取新dentry
的seq
号,然后检查旧dentry
的seq
号的过程与我们在REF-walk
中看到的在删除旧dentry
之前获取新dentry
的计数引用的过程完全相同。
No inode->i_rwsem
or even 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()
and complete_walk()
“下拉到REF-walk
”通常涉及到unlazy_walk()
的调用,之所以这样命名是因为“RCU-walk
”有时也被称为“lazy walk
”。当沿着当前vfmount/dentry
对的路径向下走似乎已经成功,但是下一步有问题时,调用unlazy_walk()
。如果在dcache
中找不到下一个名称,如果在rcu_read_lock()
被持有(这禁止sleep)时无法进行权限检查或名称重新验证,如果找到自动挂载点,或者在一些涉及符号链接的情况下,就会发生这种情况。当查找到达最后一个组件或路径的末尾时,也会从complete_walk()
调用它,这取决于使用的是哪种查找风格。
退出RCU-walk
而不触发unlazy_walk()
调用的其他原因是当发现一些不能立即处理的不一致时,例如mount_lock
或报告更改的d_seq
seqlock
之一。在这些情况下,相关函数将返回-ECHILD
,该函数将一直渗透到使用REF-walk
从顶部触发新的尝试为止。
对于unlazy_walk()
是一个选项的情况,它本质上接受它持有的每个指针(vfmount
、dentry
,可能还有一些符号链接)的引用,然后验证相关的seqlock
是否没有被更改。如果发生了更改,它也会使用-ECHILD
终止,否则转换为REF-walk
是成功的,查找过程会继续。
对这些指针进行引用并不像递增计数器那么简单。如果您已经有了一个引用(通常是通过另一个对象间接地),那么这样做可以获取第二个引用,但如果您根本没有经过计数的引用,那么这样做是不够的。对于dentry->d_lockref
,增加引用计数器以获得引用是安全的,除非它被显式地标记为“死亡”,包括将计数器设置为-128
。lockref_get_not_dead()
对这些指针进行引用并不像递增计数器那么简单。如果您已经有了一个引用(通常是通过另一个对象间接地),那么这样做可以获取第二个引用,但如果您根本没有经过计数的引用,那么这样做是不够的。对于dentry->d_lockref
,增加引用计数器以获得引用是安全的,除非它被显式地标记为“死亡”,包括将计数器设置为-128
。lockref_get_not_dead()
实现这些。
对于mnt->mnt_count
,只要使用mount_lock
来验证引用,那么接受引用是安全的。如果验证失败,那么以调用mnt_put()
的标准方式删除该引用可能不安全——卸载可能进行得太过了。因此,当它发现它得到的引用可能不安全时,会检查MNT_SYNC_UMOUNT
标志,以确定简单的mnt_put()
是否正确,或者它是否应该减少计数并假装这些都没有发生。
注意文件系统
RCU-walk
几乎完全依赖于缓存的信息,通常根本不会调用文件系统。但是,除了前面提到的组件名称比较之外,还有两个地方可能包含文件系统,RCU-walk
必须小心。
如果文件系统有非标准的权限检查要求—例如网络文件系统可能需要向服务器进行检查—在rcu walk
期间可能会调用i_op->permission
(权限接口)。在这种情况下,一个额外的”MAY_NOT_BLOCK
“标志被传递,以便它知道不sleep,但如果它不能及时完成返回-ECHILD
。I_op ->permission
被赋予了inode
指针,而不是dentry
,因此它不需要担心进一步的一致性检查。但是,如果它访问任何其他文件系统数据结构,必须确保它们在只持有rcu_read_lock()
的情况下是安全的。这通常意味着它们必须使用kfree_rcu()
或类似的方法来释放。
如果文件系统可能需要重新验证dcache
条目,那么在RCU-walk
中也可以调用d_op->d_revalidate
。该接口被传递dentry
,但不能访问inode
或nameidata
中的seq
号,因此在访问dentry
中的字段时需要格外小心。这种“额外注意”通常包括使用READ_ONCE()
访问字段,并在使用它之前验证结果是否为NULL
。这个模式可以在nfs_lookup_revalidate()
中看到。
A pair of patterns
在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最近概括的: 因为这也是一个延迟和DoS问题。我们不仅需要对真正的循环做出反应,也需要对“非常深入”的非循环做出反应。这不是关于内存使用,而是关于用户触发不合理的CPU资源。
Linux对任何路径名的长度都有限制:PATH_MAX
,即4096
。造成这种限制的原因有很多;其中之一就是不让内核在一条路径上花费太多时间。使用符号链接,您可以有效地生成更长的路径,因此出于同样的原因,需要某种限制。Linux在任何一个路径查找中限制最多40
个(MAXSYMLINKS
)符号链接。以前,它对递归的最大深度施加了8
的限制,但是当实现单独的堆栈时,这个限制提高到了40
,所以现在只有一个限制。
我们在前一篇文章中遇到的nameidata结构包含一个小堆栈,可用于存储最多两个符号链接的剩余部分。在许多情况下,这就足够了。如果不是,则分配一个单独的堆栈,并为40个符号链接分配空间。路径名查找永远不会超过这个堆栈,因为一旦检测到第40个符号链接,就会返回一个错误。
看起来这个堆栈中只需要存储名字的残余部分,但是我们还需要更多。为了了解这一点,我们需要继续研究缓存生存期。
缓存符号链接的存储和生存期
与其他文件系统资源(如inode
和目录条目)一样,符号链接由Linux
缓存,以避免对外部存储的重复昂贵访问。对于RCU-walk
来说,能够找到并临时保存这些缓存条目是特别重要的,这样它就不需要下拉到REF-walk
中。
虽然每个文件系统都可以自由地做出自己的选择,但符号链接通常存储在两个地方之一。短符号链接通常直接存储在inode
中。当一个文件系统分配一个结构节点时,它通常会分配额外的空间来存储私有数据(这是内核中常见的面向对象设计模式)。这有时会包括符号链接的空间。另一个常见的位置是在页面缓存中,它通常存储文件的内容。符号链接中的路径名可以被视为该符号链接的内容,并且可以像文件内容一样容易地存储在页面缓存中。
当这两种方法都不合适时,下一个最有可能的场景是文件系统将分配一些临时内存,并在需要时将符号链接内容复制或构造到该内存中。
当符号链接存储在inode
中时,它的生命周期与被RCU
保护的inode
相同,或者被dentry
上的计数引用所保护。这意味着,路径名查找用于安全访问dcache
和icache
(inode缓存)的机制对于安全访问一些缓存的符号链接已经足够了。在这些情况下,inode
中的i_link
指针被设置为指向存储符号链接的任何地方,并且在需要时可以直接访问符号链接。
当符号链接存储在页面缓存或其他地方时,情况就不那么简单了。对dentry
甚至是inode
的引用并不意味着对该inode
的缓存页面的引用,即使使用rcu_read_lock()
也不足以确保页面不会消失。因此,对于这些符号链接,路径名查找代码需要要求文件系统提供一个稳定的引用,而且,重要的是,需要在使用完该引用后释放该引用。
即使在rcu
遍历模式下,也经常可以获取对缓存页的引用。它确实需要改变内存,这是最好避免的,但这并不一定是一个大的成本,它比完全放弃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锁)的情况下都是安全的。一个回调结构体delayed_called
将被传递给->get_link()
:文件系统可以通过set_delayed_call()
设置自己的put_link
函数和参数。稍后,当VFS
想要放置link
时,它将调用do_delayed_call()
来调用带有参数的回调函数。
为了在遍历完成时删除对每个符号链接的引用,无论是在RCU-walk
还是REF-walk
中,符号链接堆栈需要包含路径剩余部分:
- 提供对前一个路径的引用的结构路径
const char*
用于提供对前一个名称的引用- 使路径从
RCU-walk
安全切换到REF-walk
- 用于以后调用的结构体
delayed_call
。
这意味着符号链接堆栈中的每个条目需要保存五个指针和一个整数,而不是只有一个指针(路径剩余部分)。在64位系统上,每个条目大约是40字节;40个条目加起来总共是1600字节,这还不到半页。所以这看起来很多,但绝不是过度。
注意,在给定的堆栈帧中,路径剩余(name)不是其他字段引用的符号链接的一部分。它是符号链接完全解析后要遵循的剩余部分。
符号链接
link_path_walk()
中的主循环无缝地遍历路径中的所有组件和所有非结尾符号链接。在处理符号链接时,名称指针被调整为指向一个新的符号链接,或者从堆栈中恢复,这样大部分循环就不需要注意了。在堆栈上和下获取这个name变量非常简单;推入和取出引用稍微复杂一些。
当找到符号链接时,walk_component()
通过从文件系统返回链接的step_into()
调用pick_link()
。如果操作成功,旧的路径名称将被放置在堆栈上,而新值将暂时用作名称。当找到路径的结束(即*name
为’\0’),旧的名称将从堆栈中恢复,并继续遍历路径。
推入和弹出引用指针(inode
、cookie
等)更加复杂,部分原因是需要处理尾部递归。当符号链接的最后一个组件本身指向一个符号链接时,我们希望在推入刚刚发现的符号链接之前将刚刚完成的符号链接从堆栈中取出,以避免留下空的路径残余物,否则会挡住去路。
当找到符号链接时,最方便的做法是立即将新的符号链接推入walk_component()
中的堆栈;walk_component()
也是在遍历最后一个组件时需要查看旧符号链接的最后一段代码。因此,walk_component()
可以很方便地释放旧的符号链接,并在为新符号链接推送引用信息之前弹出引用。它由个 flag
引导:WALK_NOFOLLOW
,它禁止它在发现符号链接时跟随,WALK_MORE
表示现在释放当前符号链接还为时过早,WALK_TRAILING
表示它在查找的最后一个组件上,因此,我们将检查用户空间标志LOOKUP_FOLLOW
,以确定当它是符号链接时是否跟随它,并调用may_follow_link()
来检查我们是否有权限跟随它。
没有最终组件的符号链接
一对特殊情况的符号链接值得进一步解释。这两种方法都会在nameidata
中设置一个新的结构路径(包含mount
和dentry
),并导致pick_link()
返回NULL
。
更明显的情况是指向/
的符号链接。所有以/
开头的符号链接都会在pick_link()
中检测到,它会将nameidata
重置为指向有效的文件系统根。如果符号链接只包含/
,那么就没有更多的事情要做,根本没有组件,所以返回NULL
表示可以释放符号链接,并丢弃堆栈帧。
另一种情况是/proc
中看起来像符号链接但实际上不是(因此通常被称为“magic-link”):
|
|
在/proc
中,任何进程中打开的文件描述符都用类似符号链接的东西表示。它实际上是对目标文件的引用,而不仅仅是它的名称。当您读取链接这些对象时,您得到的名称可能指向同一个文件——除非它已被解除链接或挂载。当walk_component()
遵循其中之一时,“procfs
”中的->get_link()
方法不返回字符串名称,而是调用nd_jump_link()
,该方法会及时更新nameidata
以指向该目标。->get_link()
返回NULL
。同样没有final组件
,并且pick_link()
返回NULL
。
在最后一个组件的符号链接之后
所有这些将导致link_path_walk()
遍历每个组件,并跟踪它找到的所有符号链接,直到到达最后一个组件。这只是在nameidata
的最后一个字段中返回。对于一些调用者来说,这就是他们所需要的一切;如果这个last name
不存在,则创建它,如果存在则给出一个错误。如果找到了符号链接,其他调用者将希望跟随在符号链接之后,并可能对该符号链接的最后一个组件应用特殊处理,而不仅仅是原始文件名的最后一个组件。这些调用者可能需要对连续的符号链接一次又一次地调用link_path_walk()
,直到找到不指向其他符号链接的符号链接。
这种情况由link_path_walk()
的相关调用者处理,例如path_lookupat()
, path_openat()
使用循环调用link_path_walk()
,然后通过调用open_last_lookup()
或lookup_last()
处理最终组件。如果它是需要跟随的符号链接,open_last_lookup()
或lookup_last()
将正确设置并返回路径,以便循环重复,再次调用link_path_walk()
。如果每个符号链接的最后一个组件是另一个符号链接,则可能会循环多达40次。
在检查最终组件的各种函数中,open_last_lookup()
是最有趣的,因为它与do_open()
一起工作以打开文件。open_last_lookup()
的一部分运行时保持i_rwsem
,这一部分在一个单独的函数中:lookup_open()
。
完全解释open_last_lookup()
和do_open()
超出了本文的范围,但是一些要点应该可以帮助那些对探索代码感兴趣的人。
- 在
open_last_lookup()
之后使用do_open()
来打开目标文件,而不仅仅是查找目标文件。如果该文件是在dcache
中找到的,则使用vfs_open()
。如果不是,那么lookup_open()
将调用atomic_open()
(如果文件系统提供了它)来将最终查找与打开相结合,或者直接执行单独的i_op->lookup()
和i_op->create()
步骤。在后一种情况下,vfs_open()
将实际“open”这个新发现或创建的文件,就像在dcache
中找到文件名一样。 - 如果缓存的信息不够最新,使用
-EOPENSTALE
函数vfs_open()
可能会失败。如果它在RCU-walk
中,则返回-ECHILD
,否则返回-ESTALE
。当-ESTALE
返回时,调用者可以设置LOOKUP_REVAL
标志重试。 - 与其他创建系统调用(如
mkdir
)不同,带有O_CREAT
的open
在最后一个组件中跟随符号链接。所以序列:
|
|
将创建一个名为/tmp/bar
的文件。如果设置了O_EXCL
,则不允许这样做,但对于O_CREAT
打开,则会像处理非创建打开一样处理:lookup_last()
或open_last_lookup()
返回一个非NULL
值,调用link_path_walk()
,打开进程会在找到的符号链接上继续。
更新访问时间
我们之前说过RCU-walk
将“不加锁,不增加计数,不留下足迹”。我们已经看到,当需要将符号链接作为计数引用(甚至是内存分配)处理时,可能需要一些“占用空间”。但这些足迹最好保持在最低限度。
遍历符号链接可能涉及以不影响目录的方式留下足迹的另一个地方是更新访问时间。在Unix(和Linux)中,每个文件系统对象都有一个“最后访问时间”或“atime”。通过一个目录来访问一个文件在时间的目的不被认为是一个访问;只有列出目录的内容才能更新目录的时间。符号链接似乎是不同的。无论是读取符号链接(使用readlink())还是在前往其他目的地的途中查找符号链接,都可以更新该符号链接上的时间。
目前还不清楚为什么会这样;POSIX对此几乎没有任何评论。最明确的说法是,如果一个特定的实现在POSIX没有指定的地方更新了时间戳,这必须被记录下来,“除非任何由路径名解析引起的更改不需要被记录下来”。这似乎意味着POSIX实际上并不关心路径名查找期间的访问时更新。
对历史的研究表明,在Linux 1.3.87之前,至少ext2文件系统在遵循链接时不会更新时间。不幸的是,我们没有记录说明为什么这种行为会改变。
在任何情况下,现在都必须更新访问时间,而且该操作可能相当复杂。最好避免在RCU-walk
过程中停留。幸运的是,通常允许跳过时间更新。因为atime
更新会在各个领域引起性能问题,所以Linux支持相对挂载选项,该选项通常将atime更新限制为每天一次,对那些没有被更改的文件进行更新(而且符号链接一旦创建就不会更改)。即使没有相关性,许多文件系统也以一秒的粒度记录时间,因此每秒只需要一次更新。
在RCU-walk
模式下,很容易测试是否需要更新time
,如果不需要,则跳过更新,继续RCU-walk
模式。只有在实际需要atime
更新时,路径遍历才会下拉到ref
遍历。所有这些都在get_link()
函数中处理。
一些flags
结束路径名遍历的一种合适方法是列出可以存储在nameidata
中的各种标志,以指导查找过程。其中许多限制只对最终组件有意义,其他限制反映路径名查找的当前状态,还有一些限制应用于路径查找中遇到的所有路径组件。
然后是LOOKUP_EMPTY
,它在概念上与其他选项不匹配。如果没有设置此选项,空的路径名会在很早就导致错误。如果设置了该参数,则空路径名不会被认为是错误。
全局状态标记
我们已经遇到了两个全局状态标志:LOOKUP_RCU
和LOOKUP_REVAL
。它们在三种总体查找方法中进行选择:RCU-walk
、REF-walk
和强制重新验证的REF-walk
。
LOOKUP_PARENT
表示还没有到达最后一个组件。这主要用于告诉审计子系统正在审计的特定访问的完整上下文。
ND_ROOT_PRESET
表示nameidata
中的根字段是由调用者提供的,因此当它不再需要时不应被释放。
ND_JUMPED
意味着选择当前dentry
不是因为它有正确的名称,而是出于其他一些原因。这发生在以下..
,跟随到/
的符号链接,穿过挂载点或访问“/proc/$PID/fd/$fd
”符号链接(也称为“魔术链接”)。在这种情况下,文件系统没有被要求重新验证名称(使用d_revalidate()
)。在这种情况下,inode
可能仍然需要重新验证,因此,如果在查找完成时设置了nd_jump
,则调用d_op->d_weak_revalidate()
——这可能在最后一个组件,或者在创建、取消链接或重命名倒数第二个组件时。
Resolution-restriction 标记
为了让用户空间保护自己免受某些竞争条件和攻击场景的影响,包括改变路径组件,有一系列标志可用,它们将限制应用于路径查找期间遇到的所有路径组件。这些标志通过openat2()
的resolve
字段公开。
LOOKUP_NO_SYMLINKS
阻塞所有符号链接遍历(包括魔术链接)。这与LOOKUP_FOLLOW
明显不同,因为后者只与限制尾随符号链接的跟随有关。
LOOKUP_NO_MAGICLINKS
阻塞所有的魔法链接遍历。文件系统必须确保它们从nd_jump_link()
返回错误,因为这是LOOKUP_NO_MAGICLINKS
和其他魔术链接限制的实现方式。
LOOKUP_NO_XDEV
阻塞所有vfmount
遍历(包括绑定挂载和普通挂载)。注意,包含查找的vfmount
是由路径查找到达的第一个挂载点决定的—绝对路径以/
的vfmount
开始,相对路径以dfd
的vfmount
开始。只有在路径的vfmount
没有改变的情况下,才允许使用魔术链接。
LOOKUP_BENEATH
将阻塞解析在起始点以外的任何路径组件。这是通过阻塞nd_jump_root()
和阻塞..
如果它会跳出起点。rename_lock
和mount_lock
用于检测针对..
解析的攻击。魔法链接也被屏蔽了。
LOOKUP_IN_ROOT
解析所有路径组件,就像起点是文件系统根一样。nd_jump_root()
将分辨率带回起始点,并且“..
”,将作为一个无操作。与LOOKUP_BENEATH
一样,rename_lock
和mount_lock
用于检测针对“..
”的攻击。”决议。魔法链接也被屏蔽了。
最后组件 flags
其中一些标志只在考虑最后一个组件时设置。其他的只在考虑最终组件时进行检查。
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目前只跟随存储在索引节点中的符号链接,因此,虽然它处理许多ext4符号链接,但它对NFS、XFS或Btrfs没有帮助。这种支持不太可能拖延太久。