在文件系统中,元数据的加锁机制是保证元数据事务操作正确进行的重要机制。目前的文献很少系统地讲述这方面的内容。本文从 Linux 内核源代码中总结出元数据加锁的规则与机制,展示其在设计过程中的思路及所遇到的问题,并揭示出元数据加锁机制与元数据组织方式之间的关联性。从中可以看到元数据的组织方式直接决定了元数据的加锁机制的制定,而元数据的加锁机制则是认识和理解元数据组织方式的一个绝佳角度。了解这部分知识,可以扩展我们对文件系统元数据组织方式的设计思路。
1. 概述
元数据是一个文件系统的重要组成部分,元数据操作也在文件系统中起着非常关键的作用。据统计,涉及元数据的操作约占文件系统所有操作的83%以上,因此元数据操作直接关系到文件系统的性能与表现。对于元数据操作,很多书籍和文章都介绍过其概念和功能,但却很少有文献涉及到这些元数据操作之间的互斥及加锁机制的问题。一些涉及到锁机制的文章也大都是讲解 Linux 的文件锁,而元数据的加锁机制因为涉及到元数据组织方式对其的影响,要更复杂也更不容易理解。同时,元数据的加锁及死锁避免的机制还会直接影响到元数据操作的并发度与性能。对这部分内容进行仔细分析,我们会发现元数据操作的加锁机制是很有系统性的,并且它同元数据的组织设计有着密切的联系,而所有这些都会直接影响到整个文件系统的性能与带宽。了解这部分内容,是一个很有意思的过程,可以深入认识 Linux 文件系统中元数据操作的进行,同时这也是构建文件系统所必须的知识。
![]() ![]() |
2. 为什么要对元数据进行加锁
文件系统为什么要对元数据进行加锁呢?对于这个问题,我们先来了解一下元数据操作的特性。元数据操作是一种事务操作。所谓事务操作,就是要保证事务内的多个子操作要么全部完成,要么都没有完成。文件系统的一个元数据操作通常是由一系列更基本的操作(basic operations)构成,每个基本操作只修改一个元数据。这些基本操作必须按指定的顺序完成。构成一个元数据操作的基本操作序列是一个不可分割的整体。作为一种事务操作,ACID 特性是事务(transaction)的根本特征。元数据操作也具有 ACID 四个特性:
- 原子性(Atomicity):一个元数据操作的结果要么是所有基本操作都成功时的结果,要么是任何一个基本操作都没有做过的结果。通常,与某个事务关联的基本操作具有共同的目标,并且是相互依赖的。如果系统只执行这些基本操作的一个子集,则可能会破坏事务的总体目标。
- 一致性(Consistency):每个元数据操作的完成都必须保证文件系统的所有元数据结构都是一致的。
- 独立性(Isolation):由并发事务所作的修改必须与任何其它并发事务所作的修改隔离。事务查看元数据时,元数据所处的状态,要么是另一并发事务修改它之前的状态,要么是另一事务修改它之后的状态,事务不会查看中间状态的数据。
- 持久性(Durability):一旦操作完成,其对于系统的影响是永久性的。
正因为元数据操作的这种特性,它会产生两个问题:一个是元数据的一致性问题,一个是元数据操作的干扰问题。第一个问题往往是由于突发性的服务器崩溃所带来的,比如发生了断电等情况。此时,如果一个元数据操作没有完全完成,导致该操作中所涉及的元数据修改一部分写回了磁盘,一部分没有写回而丢失了,那么当服务器重启恢复后,就会导致元数据之间的不一致。比如父目录中记录了新创建的对象,而新创建对象的元数据却丢失了。这种不一致的元数据状态会使服务器无法正常提供元数据服务。元数据的一致性问题可以通过对磁盘进行全面的检查修复,或者采用日志技术来避免,这部分内容不在本文的讨论范围内。第二个问题是元数据操作的干扰问题,这就需要通过对元数据进行加锁来避免。前面介绍过,一个元数据操作包含了多个子操作,当多个元数据操作并发进行时,如果没有对相应的元数据进行加锁,就可能导致多个元数据操作的子操作之间互相交叠,从而使元数据操作产生错误。举一个简单的例子来说明:
图 1. 无加锁机制时,两个元数据操作的交叠干扰

如上图所示,此时系统中有两个元数据操作并发进行。操作①要在目录 a 下创建一个对象 b;操作②要删除目录 a。在有加锁的情况下,操作①会先对目录 a 进行加锁,然后创建对象 b,最后递增目录 a 的 nlink 值,再对 a 解锁,这一连串操作会连续进行,中间不会有其他操作的干扰。如果此时系统中没有相应的加锁机制对元数据操作进行互斥,那么当操作①创建了对象 b 以后,接下来操作②有可能就将目录 a 删除了,当操作①要递增目录 a 的 nlink 值时,就会发现没有可操作的对象了,于是操作出错。
从上面的例子可以看出,通过对要操作的元数据进行加锁就能够很好地保证元数据操作的事务特性。然而同样从上面的例子,我们发现,一个元数据操作可能要涉及到对多个元数据进行加锁,因此元数据操作的加锁机制需要考虑的一个很重要的问题就是死锁避免的问题。所谓死锁,就是两个或两个以上的元数据操作在执行过程中,因争夺加锁的元数据而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。实际上,这里我们要探讨的加锁机制主要就是对死锁避免的考虑,对于死锁的问题,Linux 是通过制定加锁规则来处理的。
![]() ![]() |
3. 元数据操作的加锁规则
在 Linux 文件系统中,元数据的加锁操作基本上都是由其虚拟文件系统(VFS)来规定的。这样做的好处是可以统一管理所有元数据操作的加锁机制,底层的具体文件系统可以不理会这些问题,只需要按照 VFS 的调用来执行对元数据的操作。
在 VFS 中,对于大多数的元数据操作,可以通过制定统一的加锁顺序来避免死锁的发生。这个顺序是:先对父目录加锁,再对要操作的对象(目录或文件)加锁。以创建操作为例,当我们要在一个目录下创建新的对象时,必须先对这个目录进行加锁,然后才能放心地进行创建对象的步骤,由此也不用担心该目录会被中途删除,或者其他操作对该创建操作所造成的干扰。按照元数据名字空间的树状结构来看,我们可以认为,“先对父目录加锁,再对要操作的对象(目录或文件)加锁”是一种从上到下的加锁顺序。那么,只要所有的元数据操作都遵循这个规则,就不会出现相反的加锁顺序(即从下到上的顺序),那么也就不会出现两个操作因为互相等待对方的锁而产生死锁的情况。
这个普遍的加锁规则对于绝大多数元数据操作都是适用的。比如创建、删除等操作,它们的加锁目标都是要操作的对象及其父目录。但有两个元数据操作例外:rename 和 link,因为它们的操作对象不止两个,而这些对象可能位于名字空间树状结构的任意几个位置,导致加锁的路径有可能不在名字空间树状结构“从上到下”的范畴内。对于这两个元数据操作,我们需要对其进行特殊的考虑和处理。
![]() ![]() |
4. rename 操作的加锁方式
我们先看 rename 操作。Rename 操作的目的是将一个对象进行重命名,因此它涉及到两个操作对象:源(source)与目的(target)。rename操作的复杂性在于它需要先分别对 source 的父目录以及 target 的父目录进行加锁,那么多个 rename 操作之间很容易就会出现死锁的状况。举一个简单的例子来说明:
图 2. 多个 rename 操作的死锁隐患

比如,对于同一个名字空间树状结构,如果有两个 rename 操作同时进行,操作①要将 c1 重命名为 c2,操作②要将 c2 重命名为 c1。假设 rename 操作每次都先对 source 的父目录加锁,再对 target 的父目录加锁。那么,操作①会先对目录 b1 加锁,再对目录 b2 加锁;而操作②会先对目录 b2 加锁,再对目录 b1 加锁。这就产生了一个很典型的死锁场景。
产生死锁隐患的原因是由于 source 与 target 各自的父目录可以在名字空间树状结构的任意位置。那么,要加锁的两个对象就完全可能不在父子关系的范畴内,因此无法以Linux文件系统的统一加锁规则来处理。而如果从 rename 操作本身的视角出发,以“源”或“目的”的关系来规定加锁顺序,也无法避免死锁隐患,就如上图的例子所示。这样看来,我们似乎无法对 rename 操作制定统一的加锁顺序。那么,Linux 是如何处理这个问题的呢?
Linux 也没有办法解决多个 rename 操作所造成的死锁隐患,所以它规定每个文件系统内每次只能进行一个 rename 操作。从源代码中可以看到,VFS 在 superblock 结构中定义了一个互斥的 mutex :s_vfs_rename_mutex(在较早的内核版本中是 s_vfs_rename_sem)。在 Linux 中,每次进行 rename 操作都需要先获得 s_vfs_rename_mutex 这把锁,由此保证每次只会有一个 rename 操作在进行。
在 rename 操作中,对 source 与 target 各自的父目录进行加锁的函数是 lock_rename()。它同样也须在必要的时候保持“从上到下”的加锁顺序,以确保 rename 不会同其他元数据操作发生死锁。因此,如果这两个父目录(这里设为 A 和 B)是不同的两个目录,必须先判断 A 和 B 是否有亲戚关系,即 A 的父目录的父目录……的父目录是 B,或者相反。如果存在这种关系,必须先对祖先目录进行加锁。如果不存在这种关系,则不需要有明确的先后加锁顺序,可以先对 A 加锁,也可以先对 B 加锁。
到此为止,我们看到 rename 操作不会同另一个 rename 操作产生死锁(因为一次只会有一个 rename 操作进行)。也不会同其他元数据操作(这里指除了 rename 与 link 操作之外的操作)产生死锁,因为 rename 操作在必要的时候,与其他操作同样遵循“从上到下”的加锁顺序。还需要考虑的问题就是 rename 操作会不会同 link 操作产生死锁,接下来我们就介绍 link 操作的加锁方式。
![]() ![]() |
5. link 操作的加锁方式
前面介绍的 rename 操作已经比较麻烦了,再加上 link 操作,情况似乎变得更加复杂。虽然 link 操作也涉及到两个操作对象,但有一个规定使得这种复杂性得到了很大的简化。这个规定就是:link 的对象不能是目录。基于这个规定,我们来看 link 操作的加锁方式及其与其他元数据操作的关系。