避免死锁¶
如果任何目录都不是它自己的祖先,则上述方案是无死锁的。
证明
锁上存在一个排名,使得所有原语都按非递减的排名顺序获取它们。即,
按 inode 指针顺序,在给定文件系统上的非目录的 ->i_rwsem 排名。
将文件系统上所有目录的 ->i_rwsem 置于相同排名,低于同一文件系统上任何非目录的 ->i_rwsem。
将 ->s_vfs_rename_mutex 置于低于同一文件系统上任何 ->i_rwsem 的排名。
在不同文件系统上的锁之间,使用这些文件系统的相对排名。
例如,如果我们有一个在本地文件系统上缓存的 NFS 文件系统,我们有
NFS 文件系统的 ->s_vfs_rename_mutex
该 NFS 文件系统上目录的 ->i_rwsem,所有目录的排名相同
该文件系统上非目录的 ->i_rwsem,按 inode 地址递增的顺序
本地文件系统的 ->s_vfs_rename_mutex
本地文件系统上目录的 ->i_rwsem,所有目录的排名相同
本地文件系统上非目录的 ->i_rwsem,按 inode 地址递增的顺序。
很容易验证操作永远不会获取排名低于已持有的锁的锁。
假设可能发生死锁。考虑最小的死锁线程集。它是一个由多个线程组成的循环,每个线程都阻塞在循环中下一个线程所持有的锁上。
由于锁定顺序与排名一致,最小死锁中所有竞争的锁都将具有相同的排名,即它们都将是同一文件系统上目录的 ->i_rwsem。此外,不失一般性地,我们可以假设所有操作都直接在该文件系统上完成,并且它们都没有真正达到方法调用。
换句话说,我们有一个由线程 T1, ..., Tn 组成的循环,以及相同数量的目录 (D1, ..., Dn),使得
T1 被 D1 阻塞,D1 由 T2 持有
T2 被 D2 阻塞,D2 由 T3 持有
...
Tn 被 Dn 阻塞,Dn 由 T1 持有。
最小循环中的每个操作都必须至少锁定了一个目录,并在尝试锁定另一个目录时被阻塞。这只剩下 3 种可能的操作:目录删除(先锁定父目录,再锁定子目录)、杀死子目录的同目录重命名(同上)以及某种跨目录重命名。
集合中必须有一个跨目录重命名;事实上,如果所有操作都是“先锁定父目录,再锁定子目录”类型,那么我们将得到 Dn 是 D1 的父目录,D1 是 D2 的父目录,D2 是 D3 的父目录,……,Dn 是 Dn 的父目录。关系不可能在目录锁获取后发生改变,因此它们将在死锁时同时成立,并且我们将得到一个循环。
由于所有操作都在同一文件系统上,因此其中不会有多个跨目录重命名。不失一般性,我们可以假设 T1 是执行跨目录重命名的线程,而所有其他操作都是“先锁定父目录,再锁定子目录”类型。
换句话说,我们有一个跨目录重命名操作,它锁定了 Dn 并在尝试锁定 D1 时被阻塞,其中 D1 是 D2 的父目录,D2 是 D3 的父目录,……,D 是 Dn 的父目录。D1, ..., Dn 之间的关系在死锁时同时成立。此外,跨目录重命名在获取文件系统锁并验证所涉及目录具有共同祖先之前不会锁定任何目录,这保证了它们之间祖先关系是稳定的。
考虑跨目录重命名锁定目录的顺序;先锁定父目录,然后可能锁定其子目录。Dn 和 D1 必须在其中,且 Dn 在 D1 之前被锁定。会是哪一对呢?
它不可能是父目录——事实上,由于 D1 是 Dn 的祖先,它将是第一个被锁定的父目录。因此,至少有一个子目录必须涉及,因此它们都不会是另一个的子孙——否则操作将不会在锁定父目录之后进行。
它不可能是父目录和它的子目录;否则我们会有一个循环,因为父目录在子目录之前被锁定,所以父目录必须是其子目录的后代。
它也不能是父目录和另一个父目录的子目录。否则,相关父目录的子目录将是另一个子目录的后代。
这只剩下一种可能性——即,Dn 和 D1 都属于子目录,以某种顺序。但这也是不可能的,因为两个子目录都不是另一个的后代。
至此证明完毕,因为具有最小死锁所需属性的操作集无法存在。
请注意,跨目录重命名中检查是否存在共同祖先至关重要——没有它就可能发生死锁。事实上,假设父目录最初位于不同的树中;我们将锁定源的父目录,然后尝试锁定目标的父目录,结果却有一个不相关的查找将源的远祖先与目标父目录的某个远子孙拼接起来。此时,跨目录重命名持有源父目录的锁,并试图锁定其远祖先。再加上对中间所有目录的一堆 rmdir() 尝试(如果它们曾经获得过锁,所有这些都将失败并返回 -ENOTEMPTY),瞧——我们就死锁了。