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

206

POSIX允许互斥锁具有递归性。这意味着同一线程可以两次锁定同一互斥锁而不会死锁。当然,它还需要两次解锁,否则没有其他线程可以获得该互斥锁。并非所有支持pthread的系统都支持递归互斥锁,但如果他们想成为POSIX兼容,他们必须支持。

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

我真正不理解的是:非递归互斥锁有什么用?为什么我希望线程锁定同一互斥锁两次会导致死锁?即使是可以避免这种情况的高级语言(例如,测试是否会死锁并在发生死锁时引发异常),通常也不会这样做。它们将让线程死锁。

这只是针对我意外两次锁定它,只解锁一次,并且在递归互斥锁的情况下,很难找到问题,因此我让它立即死锁以查看哪个位置出现了错误锁吗?但是,如果在解锁时返回一个锁计数器,在我确定释放了最后一个锁并且计数器不为零的情况下,我可以抛出异常或记录问题吗?还是有其他更有用的非递归互斥锁用例我没有看到?或者只是性能问题,因为非递归互斥锁可能比递归互斥锁稍微快一点?但是,我测试了一下,差异确实不是很大。

8个回答

167
递归互斥锁和非递归互斥锁的区别在于所有权。在递归互斥锁的情况下,内核必须跟踪实际获取该互斥锁的线程,以便区分递归和应该阻塞的不同线程。正如另一个答案所指出的那样,这涉及到额外的开销,无论是用于存储此上下文的内存还是维护它所需的周期。
然而,这里还有其他考虑因素。由于递归互斥锁具有所有权意识,因此获取互斥锁的线程必须是释放互斥锁的同一线程。对于非递归互斥锁,没有所有权意识,任何线程通常都可以释放互斥锁,无论最初哪个线程获取了互斥锁。在许多情况下,“互斥锁”更像是信号量操作,在这种情况下,您不一定要将互斥锁用作排他设备,而是将其用作两个或多个线程之间的同步或信号设备。
互斥锁所有权的另一个属性是支持优先级继承的能力。由于内核可以跟踪拥有互斥锁的线程以及所有阻塞者的身份,在具有优先级的线程系统中,可以将当前拥有互斥锁的线程的优先级提升到当前在互斥锁上阻塞的最高优先级线程的优先级。这种继承可以防止在这种情况下可能发生的优先级反转问题。(请注意,并非所有系统都支持此类互斥锁的优先级继承,但这是通过所有权概念变得可能的另一个特性)。
如果您参考经典的VxWorks RTOS内核,则定义了三种机制: - 互斥锁 - 支持递归,可选支持优先级继承。此机制通常用于以一致的方式保护关键数据的临界区域。 - 二进制信号量 - 没有递归和继承,简单的排除,接收方和发送方不必是同一线程,广播释放可用。该机制可用于保护关键部分,但也特别适用于线程之间的同步或信号传递。
  • 计数信号量 - 没有递归或继承,作为任意初始计数的协同资源计数器,线程只在资源净计数为零时阻塞。
  • 同样,这在不同平台上会有所不同,特别是它们称呼这些东西的方式,但这应该代表了相关概念和各种机制的基本思路。


    15
    你对非递归互斥锁的解释听起来更像信号量。互斥锁(无论是递归还是非递归)都有所有权的概念。 - Jay D
    16
    @Pacerier 相关的标准。例如,这个答案对于posix(pthreads)是错误的,在该标准下,在除了锁定它的线程之外的线程中解锁普通互斥量是未定义行为,而对于错误检查或递归互斥量,则会产生可预测的错误代码。其他系统和标准可能表现出截然不同的行为。 - nos
    也许这很幼稚,但我认为互斥锁的核心思想是锁定线程解锁互斥锁,然后其他线程可以做同样的事情。 - user657862
    按下回车键太快了:来自 https://computing.llnl.gov/tutorials/pthreads/: "如果由拥有线程调用,pthread_mutex_unlock()将解锁互斥锁。在受保护的数据使用完成后,如果其他线程要使用该受保护的数据,则需要调用此例程以释放互斥锁。如果出现以下错误,则会返回错误: 如果互斥锁已经被解锁 如果互斥锁被另一个线程占用" - user657862
    2
    @curiousguy - 广播释放会释放所有被信号量阻塞的线程,而不需要显式地给出它(保持为空),而普通的二进制信号量只会释放等待队列头部的线程(假设有一个被阻塞)。 - Tall Jeff
    显示剩余2条评论

    135

    答案并不是效率。非可重入锁可以导致更好的代码。

    例如:A::foo()获取锁,然后调用B::bar()。当你编写时,这很好。但是过一段时间后,有人将B::bar()更改为调用A::baz(),它也会获取锁。

    如果没有可重入锁,那么会发生死锁。如果使用了可重入锁,它将运行,但可能会出现问题。在调用bar()之前,A::foo()可能已将对象留在不一致的状态下,假设无法运行baz(),因为它也获取互斥锁。但它可能会运行!编写A::foo()的人假设没有人可以同时调用A::baz()——这正是这两种方法都获取锁的原因。

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

    如果您对可重入锁感到满意,那只是因为您以前没有遇到像这样的问题。顺便说一下,Java现在在java.util.concurrent.locks中拥有非可重入锁。


    4
    理解你所说的当你第二次获取锁时不变式无效的观点花了我一段时间。很好的观点!如果它是一个读写锁(像Java的ReadWriteLock),并且您在同一线程中先获取读锁,然后再次获取读锁,那么您不会在获取读锁后使不变式无效,对吗?因此,当您获取第二个读锁时,不变式仍然成立。 - dgrant
    1
    我猜,可重入锁最常见的用途是在单个类内部,其中一些方法可以从受保护和非受保护的代码片段中调用。实际上,这总是可以被分解出来。@user454322 确定,“Semaphore”。 - maaartinus
    1
    对我曾经的误解请多包涵,但我不明白这与互斥量有何关系。即使没有涉及多线程和锁定,A::foo()在调用A::bar()之前仍然可能会将对象留在不一致状态下。递归或非递归的互斥锁与此案例有何关系? - Siyuan Ren
    1
    @SiyuanRen:问题在于能否对代码进行本地推理。人们(至少我)接受的培训是将锁定区域识别为不变量维护,也就是在获取锁时没有其他线程修改状态,因此关键区域上的不变量保持不变。这不是一个硬性规定,你可以编写不考虑不变量的代码,但那只会使你的代码更难以推理和维护。在单线程模式下没有互斥锁的情况下也会出现同样的情况,但我们没有接受本地推理受保护区域的培训。 - David Rodríguez - dribeas
    1
    这个答案有一个错误:“编写A::foo()的人假设没有人能同时调用A::baz()”。如果是同一个线程,那么就不是同时。递归互斥锁在效率以外的每个方面仍然是正确的。 - Adrian May
    显示剩余5条评论

    100

    Dave Butenhof本人的原话如下:

    “递归互斥锁最大的问题在于,它们会让你完全失去对锁定方案和作用范围的掌控。这是致命的,邪恶的。它是“线程食人魔”。你应该尽可能短暂地持有锁定。就是这样。永远如此。如果你因为不知道是否已经持有互斥锁或者不确定被调用者是否需要互斥锁而在保持锁定时调用某些东西,那么你就把锁保持得太久了。这就像拿着一支霰弹枪对着你的应用程序扣动扳机。你最初使用线程是为了获得并发性,但现在却阻止了并发性。”


    11
    请注意Butenhof回复中的最后一部分:“...直到所有递归互斥量都消失,你才算完成。 或者坐下来让别人来设计。” - user454322
    2
    他还说,使用单个全局递归互斥锁(他认为只需要一个)作为一种支架来有意识地推迟理解外部库不变性的艰苦工作是可以接受的,当你开始在多线程代码中使用它时。但是你不应该永远依赖于这种支架,而是最终投入时间去理解和修复代码的并发不变性。因此,我们可以这样概括,使用递归互斥锁是技术债务。 - FooF

    12
    正确使用互斥锁的心理模型:互斥锁保护不变量。
    你为什么确定这是正确使用互斥锁的心理模型呢?我认为正确的模型是保护数据而不是不变量。
    保护不变量的问题甚至在单线程应用程序中也存在,与多线程和互斥锁无关。
    此外,如果您需要保护不变量,仍然可以使用二进制信号量,它永远不会递归。

    正确。有更好的机制来保护不变量。 - ActiveTrayPrntrTagDataStrDrvr
    11
    这应该是对提供了该声明的答案的评论。互斥锁不仅保护数据,还保护不变量。尝试编写一些简单的容器(最简单的是堆栈),使用原子操作来实现(其中数据本身保护自己),而不是使用互斥锁,你就会理解这个说法。 - David Rodríguez - dribeas
    互斥锁不是用于保护数据,而是用于保护一种不变量。然而,该不变量可以用于保护数据。 - Jon Hanna

    6

    递归锁之所以有用的一个主要原因是在同一线程多次访问方法的情况下。例如,假设互斥锁保护着银行账户的提现操作,如果该提现操作还有附带费用,则必须使用同一把互斥锁。


    6
    递归互斥锁的唯一好用例是当一个对象包含多个方法时。当任何一个方法修改对象的内容,并且在状态再次一致之前必须锁定对象时。
    如果这些方法使用其他方法(例如:addNewArray() 调用 addNewPoint(),并最终通过 recheckBounds() 完成),但其中任何一个函数本身需要锁定互斥锁,则递归互斥锁是一个双赢的选择。
    对于任何其他情况(仅解决糟糕的编码,在不同的对象中使用它)都是明显错误的!

    我完全同意。这里只有糟糕的选择:1. 不要在成员函数中使用任何锁 - 而是在调用任何函数之前让调用代码进行锁定(“不是我的问题”方法)。2. 为每个需要锁定的类发明一些“同一线程已经拥有锁定”的程序逻辑。更多的代码,难以正确实现(竞争),维护者仍然必须知道如何正确实现。3. 设计为不可变性(当修改您的10000000个元素列表时返回一个新列表)(出于效率原因不能使用开箱即用的类型)。4. 客户讨厌你经常死锁的应用程序。 - BitTickler
    是的,这就是为什么递归互斥锁被发明出来的原因。 - Abhishek Sagar

    2

    在我看来,大多数反对递归锁的论点(在我进行了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() }
    }
    

    一旦事件或类似结构(访问者?!)出现,我不希望通过非递归锁来解决所有随之而来的设计问题。

    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是递归的,则根本无法确保在进行调用之前已解锁。

    这并不是一种健康的方法。例如:class foo { ensureContains(item); hasItem(item); addItem(); } 如果 ensureContains() 使用了 hasItem()addItem(),你在调用其他人之前解锁可能会防止自动死锁,但也会导致在存在多个线程的情况下不正确。这有点像你根本没有锁定一样。 - BitTickler
    @BitTickler,当然可以!毫无疑问,有些场景中调用其他方法时互斥锁必须保持锁定,您的示例就是其中之一。但是,如果出于任何原因,在调用前必须解锁互斥锁,则非递归互斥锁是唯一可行的选择。实际上,这正是本答案的主要思路。 - Igor G

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接