递归锁的缺点
October 18, 2021
本文译自 Stephen Cleary 的博客 Recursive (Re-entrant) Locks。建议读者结合原文阅读。
是时候讨论另一个可能有争议的话题了:递归锁,亦被称为可重入锁或者递归互斥锁。
递归锁的定义
递归锁是一种在上锁时会先检查当前锁是否已经被持有,如果已经被持有则允许代码递归地获取它的锁。通常来说递归锁的实现基于引用计数,这样它们就能被多次获取并被多次释放,但锁被真正释放的时机是当释放的次数恰好等于获取的次数时。
根据传统,递归锁是微软平台上的默认实现。不论是 C# 的 lock
语句,还是 Monitor
、Mutex
和 ReaderWriterLock
都是递归的。但是一些新型的锁正在改变这一切,例如 SpinLock
不是递归的,ReaderWriterLockSlim
默认也不是递归的(它将递归作为配置项)。
不过如果你搜索一下关于递归锁的观点,你就会发现一部分程序员坚决地拥护递归锁,而另一部分则坚决地反对它。尽管有些难以向「外人」解释,但是关于这个话题的讨论的激烈程度确实挺有趣的;即便是从事非技术工作的伴侣也很难理解我们的感受。
加入讨论的行列
递归锁很糟糕。
是的,我的确这么认为。我将会在下文中罗列出我的论点。但是在我开始之前,我需要声明我是一名怎样的选手。许多支持递归锁的人们声称反对递归锁的人们都是「理论学家」,并且递归锁使得编程更加简单了。我就是一个反对递归锁的人,但我绝不是「理论学家」,我已经在相当多的现实世界的多线程代码中使用过递归锁,并且对它们的缺点深有体会。所以我的反递归立场直接来自于实战,而不是象牙塔。
我认为大部分情况下使用递归锁是一种很糟糕的选择,不过我也认为递归锁在某种场景下可能有用(不过我从来没有见过这种场景,只是理论上有用)。
不一致的不变量
排名第一的反对递归锁的原因便是不一致性。我们使用锁的目的是确保共享的可变状态的读写原子化;但是当锁被一段代码持有时,这些状态可能并没有保持一致。换句话说,尽管我们持有锁,但我们并没有持有不变量(invariants);在获取锁到释放锁的过程中,只是按照契约暂时挂起了所有不变量。
public void A() {
lock (_mutex) {
// body method...
}
}
public void B() {
lock (_mutex) {
// body method...
A();
// body method...
}
}
考虑这样一个关于递归锁的最常见的争论的场景:你已经有一个方法 A 需要获取锁并且执行一些操作,然后再释放这个锁。现在你需要新写一个方法 B 获取锁,执行一些操作(包括 A 所需要执行的操作),然后再释放这个锁。出于代码复用的角度,我们很自然地会在 B 中调用 A。递归锁当然允许这种代码复用。
但这是一个错误。
当你在阅读代码时,获取锁和释放锁是语义上的屏障。我们可以很自然地假设当锁被释放时不变量仍然保持不变,但在递归锁场景下这种假设并不成立。当 B 调用 A 时,A 并不能确定获取锁时不变量保持不变,也不能确定释放锁时不变量保持不变。
非递归的解决方案是先将 A 重构为一个新的且具备清晰文档的私有方法 C(遵循命名约定),并且假设当 C 被调用时需要已经持有锁。这样 A 和 B 都是在持有锁的情况下调用 C 了。
public void A() {
lock (_mutex) {
C_UnderLock();
}
}
public void B() {
lock (_mutex) {
// body method...
C_UnderLock();
// body method...
}
}
private void C_UnderLock() {
// body method...
}
依赖升级
对于简单的例子而言,这个论点可能很蠢,但是当场景变得复杂起来,这个论点就显得重要了。这是因为你不再能孤立地去完全理解一个方法;你需要同时考虑所有其他使用相同锁的方法,以及调用它们或者被它们调用的方法(通常是同一个类中的所有其他方法)。为了确保语义的正确,你需要理解整个类而不是单个方法。
最终的结果便是「依赖升级」,每个方法都依赖于其他方法的内部实现。每当新增一个方法时,都需要考虑是否会影响到所有已经存在的方法。整体的复杂度又再次上升了。
这一次让我们站在维护者的角度上思考上述 A 和 B 的场景。如果某些人正在修改 A,那么他们在持有锁时必须非常小心,以确保没有违反不变量的现象出现,因为 B 也依赖于这其中的部分不变量。而如果某些人正在修改 B,那么他们也必须非常清楚都使用到了哪些不变量,因为任意数量的方法在调用 B 时都可能只持有这部分不变量的子集。
我们最终会面对这样一个问题:每个方法都不再对自己的正确性负责。使用递归锁的方法都依赖于其他方法的内部实现。如果你使用通过将其重构为 C 的非递归的锁实现,就能大量减少不同方法之间的依赖。任何调用 C 的方法当然都依赖于 C 的内部实现,但也仅此而已了(所以 C 需要具备清晰的文档);当你使用递归锁时,这一切都太自由了:每个方法都依赖于其他可以访问锁的方法的内部实现。当需要修改 C 的实现时,我们可以很轻易地验证调用 C 的方法都仍然是正确的;但对于递归锁而言,不论哪个方法有改变,你都需要校验任何它调用的方法(以及这些方法调用的方法)和调用它的方法(以及调用这些方法的方法)。
使用递归锁可能可以让开发者少写一个方法和 7 行代码,但后果便是增加了他维护代码的复杂度。所以这完全不是一个好的选择。
精神分裂的代码
让我们尝试把使用递归锁的方法们变成良好的公民,就是说,不论它们有没有持有锁都可以被调用。于是我们很快就能写出精神分裂的代码。
「精神分裂的代码」是指,在运行时之前我们都不知道将如何被执行的代码。它们在运行时会根据是否已经被同步(synchronized)调整自己的行为。问题在于,我们很难去验证这两种行为是否都是正确的。这些代码将是引导你走向精神崩溃的最快捷径。
在我多年的多线程调试生涯中学到的一课就是:你必须在编译时就搞清楚每一行代码都是被如何执行的。这就像是以前的 ISynchronizeInvoke.InvokeRequired
/ Dispatcher.CheckAccess
/ CoreDispatcher.HasThreadAccess
特性一样:它们就不应该被使用!你的代码应当被理解是如何执行的。这样维护和调试起来就简单得多。
锁状态的不确定性
递归锁的另一个问题是,一个方法永远不能确定它的锁是否被解锁了。这个锁可能在这个方法获取它之前就被锁上了,也可能在这个方法释放它之后仍然被锁住。
一个支持递归锁的例子便是同步的集合类,其中 AddRange
方法可以直接调用 Add
方法。为了理解这个问题的不确定性,请读者站在维护者的角度思考;假设我们现在要添加一个 INotifyCollectionChanged
回调。
public void Add(T item) {
lock (_mutex) {
// add the item...
}
}
public void AddRange(IEnumerable<T> items) {
lock (_mutex) {
foreach (var item in items)
Add(item);
}
}
这看起来似乎非常简单:AddRange
将获取锁并且通过调用 Add
添加东西;而 Add
也会获取锁并且添加东西,然后释放这个锁,最后再调用 CollectionChanged
。但是这便产生了一个之前不存在的死锁问题,这是一种连单元测试(或者其他绝大部分测试)也发现不了的死锁。你可以看得出来吗?
public void Add(T item) {
lock (_mutex) {
// add the item...
}
RaiseNotifyCollectionChanged(item);}
public void AddRange(IEnumerable<T> items) {
lock (_mutex) {
foreach (var item in items)
Add(item);
}
}
发现问题了吗?CollectionChanged
可以被用于回调最终的用户代码,并且这个事件应该在不持有锁的情况下被触发。在这个例子中,Add
在抛出事件之前就释放了锁,但是它并不能确定这个锁真的被释放了。这个锁可能被释放了,也可能没有(精神分裂)。其实 AddRange
也有这个问题,它也不知道自己是否会被另一段已经持有锁的代码调用(依赖升级)。
所以这里就有一个问题:代码的维护者会注意到这些问题吗?
当然修复起来也很简单:将其重构为非递归锁的形式,但这也只是在你一开始就注意到这个问题时的一种选择。更大的问题是,是否有人会注意到这个改动可能会引入死锁问题。随着时间的推移和人员的变更,需要维护的代码通常会变得与原始代码非常不一致,于是你将不得不面对依赖升级的问题;这些问题都使得递归锁代码很难在相当长的时间内都保持正确。
递归其他东西
另一个问题是,递归锁并不能被很好地转换为其他概念上相关的协调原语(Coordination Primitives)。以信号量为例,信号量有很多种使用方式,但在这里我们只是将其当作可以获取指定次数而不是只能一次的锁。
但使用信号量实现的「多重锁」本身并不天然支持递归。如果一个需要获取信号量的方法调用了另一个也需要获取这个信号量的方法,那就是有两把锁而不是一把锁被持有了。这段代码可能会工作一小会儿(当获取信号量的调用栈非常浅时),但很快就会出现死锁问题。那么「多重锁」需要支持递归吗?
那如果是递归读写锁呢?如果同时运行的读锁数量有限制,那么递归读锁是应该按照多个读锁那样计数,还是使用单个读锁的引用计数呢?如果在已经获取了一个读锁时再去获取写锁,我们是应该允许这种场景存在还是抛出异常呢?
一旦你开始审视这些其它类型的协调原语,「递归」的语义就变得不那么清晰了。
一个条件变量的有趣例子
许多本篇博客的读者并不清楚条件变量这个概念。本质上,条件变量是一种允许一个方法先获取锁并且执行一些操作,然后等待某个条件发生(在等待过程中先释放锁,在等待结束时重新获取锁),再执行更多的操作,最后再释放锁的协调原语。
.NET 中的 Monitor 类本质上是一个单一条件变量的锁实现,并且维基百科对此有很好的描述。条件变量非常有用,一个经典的例子便是用于实现 有界的生产者和消费者队列。
Monitor 是一个递归锁实现,所以让我们继续考虑 B 调用 A 的场景。这次 A 打算等待一个条件发生(比如使用 Monitor.Wait
进行等待)。
public void A() {
lock (_mutex) {
// body method...
Monitor.Wait(_mutex);
// body method...
}
}
public void B() {
lock (_mutex) {
// body method...
A();
// body method...
}
}
此时等待过程中将会发生什么?Monitor(条件变量)在等待过程中解锁了。它并不只是释放锁,因为这是个递归锁,所以无论如何它都会解锁。因此在等待结束后,它会多次重新获取锁。这是官方文档中记录的行为,也是唯一一个递归锁与条件变量配合使用的合理场景。
但是对于 B 而言会发生什么呢?B 只知道自己申请锁之后调用了 A,然后再释放锁。不知道当它调用 A 时,它的锁会被暂时性地释放然后再被重新获取。所以在此期间任何代码都可能被执行。所以 B 需要在调用 A 前确保所有相关不变量都做好了锁被释放的准备,而当 A 返回时,B 还需要重新检查所有可能发生改变的状态(不仅仅是 A 会影响到的状态)。更糟糕的是,B 不得不了解 A 的内部实现是怎样的,因为它必须这么做。
真是令人作呕。
是的,这几乎总结了我对递归锁的看法:令人作呕。
一种有用的场景
我不得不承认递归锁的名声差是因为人们在不应该使用它们的地方使用了它们。上述所有例子都指出虽然使用递归锁写代码更容易,但是代码的可维护性将会让你不知所措。但是递归锁仍然存在一种合理的使用场景。事实上,这也是递归锁一开始被发明出来的原因。
递归锁在递归算法中非常有用。
请允许我重新解释一下:递归锁在具有并行特征的递归算法中非常有用,在这种情况下,由于性能原因需要对共享数据结构进行细粒度的锁定。
换句话说:我们几乎很难遇到这样的场景。
以我的经验来看,我绝对不需要它们。
但是递归锁符合直觉!
让我们重新思考一下递归锁的定义。在文章一开始我就写到:
递归锁是一种在上锁时会先检查当前锁是否已经被持有
是否已经被持有 …… 被什么持有?
我故意省略了一部分定义,因为我想留到这里再讨论。
一种观点是锁被线程持有。开发者可以使用锁来排除其他线程的影响。一个线程通过持有锁来排除其他线程对共享状态的干扰。共享状态的改变对其他线程来说是原子操作。
对于这种观点来说,使用递归锁是相当符合直觉的;如果一个线程已经持有锁,那它当然可以重新申请同一把锁!
之所以会有这种观点,是因为许多关于锁的定义是站在操作系统的角度定义的。对于操作系统来说,这也确实是锁的行为:它阻塞了其他线程!
但是一个有多线程开发经验的程序员不应该有这种观点。我们应该拥抱另一种思维方式:一把锁可以被一段代码持有(在给定的抽象级别)。开发者可以使用锁来排除其他代码块的影响。一段代码通过持有锁来排除其他代码块对共享状态的干扰。
对于这种观点来说,递归锁就没什么意义了。实际上考虑递归锁就意味着你在两种抽象维度上使用了同一把锁(或者应该说是两种不同的抽象级别)。
就像许多开发者一样,我在学校的时候被传授了经典的(线程)定义,并且我的第一个多线程程序使用了粗力度的锁来实现线程合作。写了几年的精简锁和限制锁的可见性的代码之后,我开始被另一种(代码块)定义吸引。
实际上你的确应该拥抱另一种思维方式,因为一些新型的锁比如 异步锁 并不能绑定到线程。
结论
好吧,其实我一开始是想把这些作为异步递归锁的引子,但我实在是太啰嗦了。我会另外再找时间聊聊异步递归锁的。