递归锁定(互斥锁)与非递归锁定(互斥锁)

时间:2008-10-09 15:19:14

标签: multithreading locking mutex deadlock recursive-mutex

POSIX允许互斥锁递归。这意味着同一个线程可以锁定相同的互斥锁两次并且不会死锁。当然它还需要解锁两次,否则没有其他线程可以获得互斥锁。并非所有支持pthread的系统都支持递归互斥锁,但如果它们想成为POSIX conform, they have to

其他API(更高级别的API)通常也提供互斥锁,通常称为锁定。一些系统/语言(例如Cocoa Objective-C)提供递归和非递归互斥体。有些语言也只提供一种或另一种语言。例如。在Java中,互斥锁总是递归的(同一个线程可能在同一个对象上“同步”两次)。根据它们提供的其他线程功能,没有递归互斥体可能没有问题,因为它们可以很容易地自己编写(我已经在更简单的互斥/条件操作的基础上自己实现了递归互斥体)。

我真的不明白:什么是非递归互斥量有用?如果它锁定相同的互斥锁两次,为什么我想要一个线程死锁?即使是可以避免这种情况的高级语言(例如测试它是否会死锁并抛出异常)通常也不会这样做。他们会让线程陷入僵局。

这是仅适用于我不小心将其锁定两次且仅解锁一次的情况,如果是递归互斥锁,则会更难找到问题,因此我立即将其解锁以查看错误锁定的位置出现?但是我不能在解锁时返回一个锁定计数器,在某种情况下,我确定我已经释放了最后一个锁并且计数器不为零,我可以抛出异常或记录问题吗?或者是否有其他更有用的非递归互斥体用例我看不到?或者它可能只是性能,因为非递归互斥体可能比递归互斥体略快?但是,我对此进行了测试,差异确实不大。

8 个答案:

答案 0 :(得分:141)

递归和非递归互斥体之间的区别与所有权有关。在递归互斥锁的情况下,内核必须跟踪第一次实际获得互斥锁的线程,以便它可以检测递归与应该阻塞的不同线程之间的差异。正如另一个答案所指出的那样,存储这个上下文的内存以及维护它所需的周期都存在额外开销的问题。

然而,此处还有其他注意事项。

因为递归互斥锁具有所有权感,所以抓取互斥锁的线程必须与释放互斥锁的线程相同。在非递归互斥锁的情况下,没有所有权感,任何线程通常都可以释放互斥锁,无论哪个线程最初使用互斥锁。在许多情况下,这种类型的“互斥体”实际上更像是一种信号量动作,你不一定使用互斥体作为排除设备,而是将它用作两个或多个线程之间的同步或信令设备。

在互斥锁中具有所有权感的另一个属性是支持优先级继承的能力。因为内核可以跟踪拥有互斥锁的线程以及所有阻塞程序的标识,所以在优先级线程系统中,可以将当前拥有互斥锁的线程的优先级升级到最高优先级线程的优先级目前阻止互斥锁。这种继承防止了在这种情况下可能发生的优先级倒置问题。 (请注意,并非所有系统都支持此类互斥锁上的优先级继承,但它是通过所有权概念实现的另一个功能。)

如果您参考经典的VxWorks RTOS内核,它们会定义三种机制:

  • 互斥锁 - 支持递归和可选的优先级继承
  • 二进制信号量 - 没有递归,没有继承,简单排除,接受者和给予者不必是同一个线程,可用广播版本
  • 计算信号量 - 没有递归或继承,充当来自任何所需初始计数的连贯资源计数器,线程仅阻止对资源的净计数为零。

同样,这在某种程度上因平台而异 - 特别是他们称之为这些东西,但这应该代表了概念和各种机制。

答案 1 :(得分:116)

答案是效率。不可重入的互斥体可以带来更好的代码。

示例:A :: foo()获取锁。然后调用B :: bar()。当你写它时,这很好用。但有时候有人改变B :: bar()来调用A :: baz(),它也获得了锁。

好吧,如果你没有递归的互斥锁,那么这种死锁。如果你有它们,它会运行,但它可能会中断。在调用bar()之前,A :: foo()可能使对象处于不一致状态,假设baz()无法运行,因为它也获取了互斥锁。但它可能不应该运行!编写A :: foo()的人假设没有人可以同时调用A :: baz() - 这就是这两个方法获得锁定的全部原因。

使用互斥锁的正确心智模型:互斥锁保护不变量。保持互斥锁时,不变量可能会发生变化,但在释放互斥锁之前,会重新建立不变量。重入锁定是危险的,因为第二次获得锁定时,您无法确定不变量是否为真。

如果您对可重入锁定感到满意,那只是因为您之前没有必要调试此类问题。到目前为止,Java在java.util.concurrent.locks中具有非重入锁。

答案 2 :(得分:85)

As written by Dave Butenhof himself

“递归互斥体的所有重大问题中最大的问题是 他们鼓励你完全忘记锁定方案 范围。这是致命的。邪恶。这是“线程吃”。你持有锁 绝对最短的时间。期。总是。如果你在打电话 锁定的东西只是因为你不知道它被持有,或者 因为你不知道被调用者是否需要互斥锁,那么你就是 持有太久了。你的目标就是瞄准你的霰弹枪 拉动扳机。你可能开始使用线程来获取 并发性;但你刚刚防止并发。“

答案 3 :(得分:13)

  

使用正确的心理模型   互斥体:互斥体可以保护互联网   不变的。

为什么你确定这是使用互斥锁的真正正确的心理模型? 我认为正确的模型是保护数据而不是不变量。

即使在单线程应用程序中也存在保护不变量的问题,并且在多线程和互斥体方面没有任何共同之处。

此外,如果你需要保护不变量,你仍然可以使用二进制信号量,它永远不会递归。

答案 4 :(得分:4)

递归互斥体有用的一个主要原因是在同一线程多次访问方法的情况下。例如,假设互斥锁正在保护银行A / C撤销,那么如果还有与该撤销相关的费用,则必须使用相同的互斥锁。

答案 5 :(得分:3)

递归互斥锁的唯一好用例是对象包含多个方法。当任何方法修改对象的内容时,因此必须在状态再次一致之前锁定对象。

如果方法使用其他方法(即:addNewArray()调用addNewPoint(),并使用recheckBounds()完成),但任何这些函数本身都需要锁定互斥锁,那么递归互斥是双赢的。

对于任何其他情况(解决不良编码,甚至在不同的对象中使用它)显然是错误的!

答案 6 :(得分:1)

非递归互斥锁有什么用?

当您必须在执行某些操作之前确保互斥锁已解锁时,它们绝对是不错的选择。这是因为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是非递归的,则保证上面的代码使用互斥锁 unlocked 调用bar()

因此,消除了bar()碰巧是未知的外部函数的情况下出现死锁的可能性,该外部函数很可能会执行某些操作,从而导致另一个线程尝试获取相同的互斥量。这种情况在基于线程池的应用程序和分布式应用程序中并不罕见,在这种情况下,进程间调用可能会产生一个新线程,而客户端程序员甚至没有意识到这一点。在所有这种情况下,最好仅在释放锁之后才调用所述外部函数。

如果g_mutex是递归的,则根本没有办法确保在拨打电话之前已将其解锁。

答案 7 :(得分: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() }
}

一旦事件或类似构造(访问者?!)开始起作用,我不希望通过某种非递归锁解决所有随之而来的设计问题。