递归锁(互斥锁)与非递归锁(互斥锁)
Posted
技术标签:
【中文标题】递归锁(互斥锁)与非递归锁(互斥锁)【英文标题】:Recursive Lock (Mutex) vs Non-Recursive Lock (Mutex) 【发布时间】:2010-09-16 07:26:29 【问题描述】:POSIX 允许互斥体是递归的。这意味着同一个线程可以两次锁定同一个互斥体并且不会死锁。当然也需要解锁两次,否则其他线程无法获得互斥量。并非所有支持 pthread 的系统也支持递归互斥锁,但如果它们想成为 POSIX conform, they have to。
其他 API(更高级的 API)通常也提供互斥锁,通常称为锁。一些系统/语言(例如 Cocoa Objective-C)同时提供递归和非递归互斥锁。有些语言也只提供一种或另一种。例如。在 Java 中,互斥锁总是递归的(同一个线程可能在同一个对象上“同步”两次)。根据它们提供的其他线程功能,没有递归互斥锁可能没有问题,因为它们可以很容易地自己编写(我已经在更简单的互斥锁/条件操作的基础上自己实现了递归互斥锁)。
我不太明白:非递归互斥锁有什么用?如果它两次锁定同一个互斥锁,为什么我会想要一个线程死锁?即使是可以避免这种情况的高级语言(例如,测试这是否会死锁并抛出异常)通常也不会这样做。他们会让线程死锁。
这仅适用于我不小心将其锁定两次并且仅解锁一次的情况,并且在递归互斥锁的情况下,将更难找到问题,因此我立即将其死锁以查看错误锁定的位置出现?但是我不能在解锁时返回一个锁计数器做同样的事情吗?在我确定我释放了最后一个锁并且计数器不为零的情况下,我可以抛出异常或记录问题吗?还是我没有看到其他更有用的非递归互斥锁用例?或者它可能只是性能,因为非递归互斥体可能比递归互斥体稍微快一点?不过这个我测试了一下,差别真的不大。
【问题讨论】:
【参考方案1】:恕我直言,大多数反对递归锁的论点(在 20 年的并发编程中,我 99.9% 的时间都使用递归锁)将它们的好坏与其他完全无关的软件设计问题混为一谈。举个例子,“回调”问题,它被详尽地阐述并且没有任何多线程相关的观点,例如在书Component software - beyond Object oriented programming中。
一旦你有一些控制反转(例如事件被触发),你就会面临重新进入的问题。与是否涉及互斥锁和线程无关。
class EvilFoo
std::vector<std::string> data;
std::vector<std::function<void(EvilFoo&)> > changedEventHandlers;
public:
size_t registerChangedHandler( std::function<void(EvilFoo&)> handler) // ...
void unregisterChangedHandler(size_t handlerId) // ...
void fireChangedEvent()
// bad bad, even evil idea!
for( auto& handler : changedEventHandlers )
handler(*this);
void AddItem(const std::string& item)
data.push_back(item);
fireChangedEvent();
;
现在,使用上面这样的代码,您会得到所有错误情况,这些情况通常会在递归锁的上下文中命名 - 只是没有任何错误情况。事件处理程序一旦被调用就可以取消注册,这将导致在幼稚编写的fireChangedEvent()
中出现错误。或者它可以调用EvilFoo
的其他成员函数,这会导致各种问题。根本原因是重入。
最糟糕的是,这甚至可能不是很明显,因为它可能会在整个事件链中触发事件,最终我们会回到我们的 EvilFoo(非本地)。
所以,重入是根本问题,而不是递归锁。 现在,如果您觉得使用非递归锁更安全,那么这样的错误将如何表现出来?每当发生意外的重新进入时,就会陷入僵局。 并使用递归锁?同样,它会在没有任何锁的代码中体现出来。
所以EvilFoo
的邪恶部分是事件及其实现方式,而不是递归锁。 fireChangedEvent()
需要首先创建changedEventHandlers
的副本,然后将其用于迭代,对于初学者。
另一个经常进入讨论的方面是首先定义锁应该做什么:
防止一段代码再次进入 保护资源不被同时使用(被多个线程)。我进行并发编程的方式是,我对后者有一个心智模型(保护资源)。这是我擅长递归锁的主要原因。如果某些(成员)函数需要锁定资源,它会锁定。如果它在执行它的操作时调用另一个(成员)函数并且该函数也需要锁定 - 它会锁定。而且我不需要“替代方法”,因为递归锁的引用计数与每个函数都写如下内容完全相同:
void EvilFoo::bar()
auto_lock lock(this); // this->lock_holder = this->lock_if_not_already_locked_by_same_thread())
// do what we gotta do
// ~auto_lock() if (lock_holder) unlock()
一旦事件或类似构造(访问者?!)开始发挥作用,我不希望通过一些非递归锁来解决所有随之而来的设计问题。
【讨论】:
【参考方案2】:非递归互斥锁有什么用处?
当您在做某事之前必须确保互斥体已解锁时,它们绝对是不错的选择。这是因为pthread_mutex_unlock
可以保证互斥体只有在非递归的情况下才会解锁。
pthread_mutex_t g_mutex;
void foo()
pthread_mutex_lock(&g_mutex);
// Do something.
pthread_mutex_unlock(&g_mutex);
bar();
如果g_mutex
是非递归的,则上面的代码保证调用bar()
并使用互斥锁解锁。
因此消除了死锁的可能性,以防bar()
恰好是一个未知的外部函数,它很可能会导致另一个线程尝试获取相同的互斥锁。这种情况在基于线程池的应用程序和分布式应用程序中并不少见,在分布式应用程序中,进程间调用可能会产生一个新线程,而客户端程序员甚至没有意识到这一点。在所有这种情况下,最好在锁被释放后才调用上述外部函数。
如果g_mutex
是递归的,那么没有办法在拨打电话之前确保它已解锁。
【讨论】:
这不是一个真正健康的方法。示例:class foo ensureContains(item); hasItem(item); addItem();
如果ensureContains()
使用hasItem()
和addItem()
,则在调用其他人之前解锁可能会阻止自动死锁,但也会阻止它在存在多个线程时正确。就好像你根本没有锁一样。
@BitTickler,当然!毫无疑问,在某些情况下,互斥锁在调用其他方法时必须保持锁定状态,您的示例就是其中之一。但是,如果出于某种原因,互斥锁必须在调用之前解锁,那么非递归互斥锁是唯一可行的方法。事实上,这就是这个答案的主要思想。【参考方案3】:
递归和非递归互斥体之间的区别与所有权有关。在递归互斥锁的情况下,内核必须跟踪第一次实际获得互斥锁的线程,以便它可以检测递归与应该阻塞的不同线程之间的差异。正如另一个答案所指出的那样,在存储此上下文的内存方面以及维护它所需的周期方面,都存在额外开销的问题。
然而,这里还有其他考虑因素。
因为递归互斥体有归属感,所以抓取互斥体的线程必须是释放互斥体的同一个线程。在非递归互斥锁的情况下,没有所有权感,任何线程通常都可以释放互斥锁,无论哪个线程最初获取互斥锁。在许多情况下,这种类型的“互斥锁”实际上更像是一种信号量操作,您不必将互斥锁用作排除设备,而是将其用作两个或多个线程之间的同步或信号设备。
互斥锁中另一个具有所有权感的属性是支持优先级继承的能力。因为内核可以跟踪拥有互斥锁的线程以及所有阻塞器的身份,所以在优先级线程系统中,可以将当前拥有互斥锁的线程的优先级提升到最高优先级线程的优先级当前在互斥锁上阻塞。这种继承防止了在这种情况下可能发生的优先级反转问题。 (请注意,并非所有系统都支持此类互斥体的优先级继承,但这是通过所有权概念成为可能的另一个功能。
如果您参考经典的 VxWorks RTOS 内核,它们定义了三种机制:
mutex - 支持递归和可选的优先级继承。这种机制通常用于以一致的方式保护数据的关键部分。 二进制信号量 - 没有递归,没有继承,简单的排除,接受者和给予者不必是同一个线程,广播发布可用。这种机制可用于保护关键部分,但对于线程之间的连贯信号或同步也特别有用。 计数信号量 - 无递归或继承,作为任何所需初始计数的一致资源计数器,线程仅在资源净计数为零时阻塞。同样,这因平台而有所不同——尤其是他们所说的这些东西,但这应该代表了正在发挥作用的概念和各种机制。
【讨论】:
您对非递归互斥锁的解释听起来更像是一个信号量。互斥体(无论是递归的还是非递归的)都有所有权的概念。 @JayD 当人们争论这样的事情时,这是非常令人困惑的......那么定义这些事情的实体是谁? @Pacerier 相关标准。这个答案是例如posix (pthreads) 是错误的,其中在除锁定它的线程之外的线程中解锁普通互斥锁是未定义的行为,而对错误检查或递归互斥锁执行相同操作会导致可预测的错误代码。其他系统和标准可能表现得非常不同。 也许这很幼稚,但我的印象是互斥锁的中心思想是锁定线程解锁互斥锁,然后其他线程可能会这样做。来自computing.llnl.gov/tutorials/pthreads: @curiousguy - 广播释放释放信号量上阻塞的任何和所有线程而没有显式给出它(保持为空),而正常的二进制给出只会释放等待队列头部的线程(假设有一个被阻止)。【参考方案4】:递归互斥锁的唯一好用例是当一个对象包含多个方法时。当任何方法修改了对象的内容,因此必须在状态再次一致之前锁定对象。
如果方法使用其他方法(即:addNewArray() 调用 addNewPoint(),并使用 recheckBounds() 结束),但其中任何一个函数本身都需要锁定互斥锁,那么递归互斥锁是双赢的。
对于任何其他情况(解决糟糕的编码,甚至在不同的对象中使用它)显然是错误的!
【讨论】:
我完全同意。这里只有不好的选择: 1. 不要在成员函数中使用任何锁 - 而是在调用任何函数之前使用调用代码锁(“不是我的问题”方法)。 2.为每个需要锁定的类发明一些“相同线程已锁定”的程序逻辑。更多的代码,很难做到正确(比赛),维护人员仍然必须知道如何做到这一点。 3. 设计不变性(修改后的 10000000 个元素列表返回一个新列表)(出于效率原因,不能使用开箱即用的类型)。 4. 客户讨厌你经常死锁的应用程序。 是的,这就是发明递归互斥体的原因。【参考方案5】:递归互斥锁有用的一个主要原因是在同一线程多次访问方法的情况下。例如,如果互斥锁正在保护银行账户提款,那么如果提款也需要支付费用,那么就必须使用相同的互斥锁。
【讨论】:
【参考方案6】:答案是不是效率。不可重入的互斥锁可以带来更好的代码。
示例:A::foo() 获取锁。然后它调用 B::bar()。当你写它时,它工作得很好。但是后来有人更改 B::bar() 以调用 A::baz(),它也获得了锁。
好吧,如果您没有递归互斥锁,就会出现死锁。如果你确实有它们,它会运行,但它可能会中断。 A::foo() 可能在调用 bar() 之前使对象处于不一致的状态,假设 baz() 无法运行,因为它也获取了互斥锁。但它可能不应该运行!写 A::foo() 的人假设没有人可以同时调用 A::baz() - 这就是这两个方法都获得锁的全部原因。
使用互斥体的正确思维模型:互斥体保护不变量。当互斥锁被持有时,不变量可能会改变,但在释放互斥锁之前,不变量会重新建立。可重入锁很危险,因为第二次获取锁时,您无法再确定不变量是否为真。
如果您对可重入锁感到满意,那只是因为您以前没有调试过这样的问题。顺便说一句,Java 现在在 java.util.concurrent.locks 中有不可重入锁。
【讨论】:
我花了一段时间才明白你所说的当你第二次抓住锁时不变量无效。好点子!如果它是一个读写锁(如 Java 的 ReadWriteLock)并且您获得了读锁,然后在同一个线程中第二次重新获得了读锁怎么办。获得读锁后,您不会使不变量无效吗?所以当你获得第二个读锁时,不变量仍然为真。 @Jonathan Java 这些天在 java.util.concurrent.locks 中是否有不可重入锁?? +1 我猜,重入锁最常见的用途是在单个类中,其中一些方法可以从受保护和非受保护的代码段中调用。这实际上总是可以考虑的。 @user454322 当然,Semaphore
.
请原谅我的误解,但我不明白这与互斥锁有什么关系。假设不涉及多线程和锁定,A::foo()
在调用A::bar()
之前可能仍然使对象处于不一致状态。互斥量,递归与否,与这种情况有什么关系?
@SiyuanRen:问题在于能够在本地对代码进行推理。人们(至少我)被训练将锁定区域识别为不变维护,即在您获得锁定时没有其他线程正在修改状态,因此关键区域上的不变量保持不变。这不是一个硬性规则,您可以在不考虑不变量的情况下进行编码,但这只会使您的代码更难推理和维护。在没有互斥锁的单线程模式下也会发生同样的情况,但我们没有接受过在受保护区域周围进行本地推理的训练。【参考方案7】:
As written by Dave Butenhof himself:
“递归互斥锁最大的问题是 他们鼓励您完全忘记您的锁定方案,并且 范围。这是致命的。邪恶。这就是“吃线虫”。你持有锁 绝对最短的时间。时期。总是。如果你打电话 仅仅因为你不知道它被持有而持有锁的东西,或者 因为你不知道被调用者是否需要互斥锁,那么你就是 持有太久。您正在将霰弹枪瞄准您的应用程序,并且 扣动扳机。您大概开始使用线程来获取 并发;但你只是阻止了并发。”
【讨论】:
另请注意 Butenhof 回复中的最后一部分:...you're not DONE until they're [recursive mutex] all gone.. Or sit back and let someone else do the design.
他还告诉我们,使用单个全局递归互斥锁(他的观点是你只需要一个)可以作为有意识地推迟理解外部库不变性的艰苦工作的拐杖。在多线程代码中使用它。但是你不应该永远使用拐杖,而最终要花时间去理解和修复代码的并发不变量。所以我们可以解释说,使用递归互斥锁是技术债务。【参考方案8】:
使用正确的心智模型 互斥锁:互斥锁保护一个 不变的。
为什么您确定这是使用互斥锁的真正正确的心理模型? 我认为正确的模型是保护数据而不是不变量。
保护不变量的问题即使在单线程应用程序中也存在,并且与多线程和互斥锁没有共同点。
此外,如果您需要保护不变量,您仍然可以使用从不递归的二进制信号量。
【讨论】:
是的。有更好的机制来保护不变量。 这应该是对提供该声明的答案的评论。互斥锁不仅保护数据,还保护不变量。尝试用原子(数据保护自己)而不是互斥体来编写一些简单的容器(最简单的是堆栈),你就会理解这个语句。 互斥锁不保护数据,它们保护不变量。不过,该不变量可用于保护数据。以上是关于递归锁(互斥锁)与非递归锁(互斥锁)的主要内容,如果未能解决你的问题,请参考以下文章