195

POSIX 允许互斥体是递归的。这意味着同一个线程可以两次锁定同一个互斥体并且不会死锁。当然也需要解锁两次,否则其他线程无法获得互斥量。并非所有支持 pthread 的系统也支持递归互斥锁,但如果它们想要符合 POSIX 标准,它们必须.

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

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

这仅适用于我不小心将其锁定两次并且仅将其解锁一次的情况,并且在递归互斥锁的情况下,将更难找到问题,因此我立即将其死锁以查看不正确的锁定出现在哪里?但是我不能在解锁时返回一个锁计数器做同样的事情吗?在我确定我释放了最后一个锁并且计数器不为零的情况下,我可以抛出异常或记录问题吗?还是我没有看到其他更有用的非递归互斥锁用例?或者它可能只是性能,因为非递归互斥体可能比递归互斥体稍微快一点?但是,我对此进行了测试,差异确实没有那么大。

4

8 回答 8

162

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

但是,这里也有其他考虑因素。

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

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

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

  • mutex - 支持递归和可选的优先级继承。这种机制通常用于以连贯的方式保护数据的关键部分。
  • 二进制信号量- 无递归,无继承,简单排除,接受者和给予者不必是同一个线程,广播发布可用。这种机制可用于保护关键部分,但对于线程之间的连贯信号或同步也特别有用。
  • 计数信号量- 没有递归或继承,作为任何所需初始计数的一致资源计数器,线程仅在对资源的净计数为零时阻塞。

同样,这会因平台而有所不同——尤其是他们所说的这些东西,但这应该代表了正在发挥作用的概念和各种机制。

于 2008-10-10T01:09:43.910 回答
131

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

示例: A::foo() 获取锁。然后它调用 B::bar()。当你写它时,它工作得很好。但是不久之后有人将 B::bar() 更改为调用 A::baz(),它也获得了锁。

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

使用互斥锁的正确思维模型:互斥锁保护不变量。当互斥锁被持有时,不变量可能会改变,但在释放互斥锁之前,不变量会重新建立。可重入锁很危险,因为第二次获得锁时,您不能再确定不变量是否为真。

如果您对可重入锁感到满意,那只是因为您以前不必调试过这样的问题。顺便说一句,Java 现在在 java.util.concurrent.locks 中有不可重入锁。

于 2008-11-16T07:44:44.377 回答
96

正如戴夫·布滕霍夫本人所写

“递归互斥锁最大的问题是它们鼓励你完全忘记你的锁定方案和范围。这是致命的。邪恶的。它是“线程吞噬者”。你持有锁的时间绝对最短。句号。总是。如果你调用一个持有锁的东西仅仅是因为你不知道它被持有,或者因为你不知道被调用者是否需要互斥锁,那么你持有它的时间太长了。你将猎枪对准您的应用程序并扣动扳机。您大概开始使用线程来获得并发性;但您只是阻止了并发性。”

于 2009-08-07T14:20:38.433 回答
12

使用互斥锁的正确思维模型:互斥锁保护不变量。

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

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

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

于 2008-12-16T11:41:11.630 回答
6

递归互斥锁有用的一个主要原因是在同一线程多次访问方法的情况下。例如,如果互斥锁正在保护银行账户提款,那么如果提款也需要支付费用,那么就必须使用相同的互斥锁。

于 2015-05-29T13:41:01.073 回答
6

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

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

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

于 2015-11-20T14:30:40.813 回答
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是非递归的,则保证上面的代码bar()在互斥锁未锁定的情况下调用。

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

如果g_mutex是递归的,则根本无法确保在拨打电话之前它已解锁。

于 2020-06-03T17:05:41.777 回答
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() }
}

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

于 2020-10-04T20:10:35.107 回答