54

我看到有些人讨厌recursive_mutex

http://www.zaval.org/resources/library/butenhof1.html

但是在考虑如何实现一个线程安全的类(互斥锁保护)时,在我看来很难证明每个应该受互斥锁保护的方法都是互斥锁保护的,并且互斥锁最多被锁定一次。

因此,对于面向对象的设计,是否应该std::recursive_mutex默认并std::mutex在一般情况下将其视为性能优化,除非它仅用于一个地方(仅保护一种资源)?

为了清楚起见,我说的是一个私有的非静态互斥体。所以每个类实例只有一个互斥锁。

在每个公共方法的开头:

{
    std::scoped_lock<std::recursive_mutex> sl;
4

3 回答 3

93

大多数时候,如果您认为需要递归互斥锁,那么您的设计是错误的,因此绝对不应该是默认设置。

对于具有单个互斥锁保护数据成员的类,互斥锁应在所有public成员函数中锁定,并且所有private成员函数应假定互斥锁已被锁定。

如果一个public成员函数需要调用另一个public成员函数,则将第二个成员函数一分为二:一个private执行工作的实现函数,以及一个public仅锁定互斥体并调用互斥体的成员函数private。然后第一个成员函数也可以调用实现函数,而不必担心递归锁定。

例如

class X {
    std::mutex m;
    int data;
    int const max=50;

    void increment_data() {
        if (data >= max)
            throw std::runtime_error("too big");
        ++data;
    }
public:
    X():data(0){}
    int fetch_count() {
        std::lock_guard<std::mutex> guard(m);
        return data;
    }
    void increase_count() {
        std::lock_guard<std::mutex> guard(m);
        increment_data();
    } 
    int increase_count_and_return() {
        std::lock_guard<std::mutex> guard(m);
        increment_data();
        return data;
    } 
};

这当然是一个简单的人为示例,但该increment_data函数在两个公共成员函数之间共享,每个成员函数都锁定互斥锁。在单线程代码中,它可以内联到increase_count,并且increase_count_and_return可以调用它,但我们不能在多线程代码中这样做。

这只是一个良好设计原则的应用:公共成员函数负责锁定互斥锁,并将完成工作的责任委托给私有成员函数。

这样做的好处是public成员函数只需要在类处于一致状态时处理被调用:互斥锁被解锁,一旦它被锁定,那么所有不变量都成立。如果您public互相调用成员函数,那么他们必须处理互斥锁已被锁定的情况,并且不变量不一定成立。

这也意味着条件变量等待之类的事情将起作用:如果您将递归互斥锁上的锁传递给条件变量,那么(a)您需要使用std::condition_variable_any因为std::condition_variable不起作用,并且(b)只释放一级锁,所以你可能仍然持有锁,因此死锁,因为会触发谓词并执行通知的线程无法获取锁。

我很难想到需要递归互斥锁的场景。

于 2013-01-30T09:46:33.337 回答
27

应该std::recursive_mutex是默认的并被std::mutex视为性能优化?

不是真的,不。使用非递归锁的优点不仅仅是性能优化,它意味着你的代码可以自我检查叶级原子操作是否真的是叶级的,它们不会调用使用锁的其他东西。

有一种相当常见的情况:

  • 实现一些需要序列化的操作的函数,因此它需要互斥体并执行此操作。
  • 另一个实现更大的序列化操作的函数,并且想要调用第一个函数来执行它的一个步骤,同时它为更大的操作持有锁。

为了一个具体的例子,也许第一个函数从列表中原子地删除一个节点,而第二个函数从一个列表中原子地删除两个节点(并且您永远不希望另一个线程看到仅使用两个节点中的一个的列表出去)。

为此,您不需要递归互斥锁。例如,您可以将第一个函数重构为一个公共函数,该函数获取锁并调用一个“不安全”执行操作的私有函数。然后第二个函数可以调用相同的私有函数。

但是,有时使用递归互斥锁会很方便。这种设计仍然存在一个问题:在类不变量不成立的地方remove_two_nodes调用remove_one_node(第二次调用它时,列表正好处于我们不想公开的状态)。但是假设我们知道它remove_one_node不依赖于那个不变量,这不是设计中的致命错误,只是我们使我们的规则比理想的“所有类不变量总是在任何公共函数时都成立”更复杂一点。进入”。

所以,这个技巧有时很有用,我并不像文章那样讨厌递归互斥锁。我没有历史知识来争论将它们包含在 Posix 中的原因与文章所说的不同,“展示互斥体属性和线程扩展”。不过,我当然不认为它们是默认值。

我认为可以肯定地说,如果在您的设计中您不确定是否需要递归锁,那么您的设计就是不完整的。稍后您会后悔您正在编写代码并且您不知道是否允许已经持有锁这样的根本重要的事情。所以不要“以防万一”放置递归锁。

如果你知道你需要一个,就用一个。如果您知道自己不需要,那么使用非递归锁不仅仅是一种优化,它还有助于强制执行设计约束。第二把锁失败比它成功并隐藏你不小心做了一些你的设计认为不应该发生的事情更有用。但是,如果您遵循您的设计,并且从不双重锁定互斥锁,那么您将永远无法确定它是否是递归的,因此递归互斥锁不会直接有害。

这个类比可能会失败,但这是另一种看待它的方式。想象一下,您可以在两种指针之间进行选择:一种在您取消引用空指针时使用堆栈跟踪中止程序,另一种返回0(或将其扩展为更多类型:表现得好像指针引用了一个值-初始化对象)。非递归互斥锁有点像中止的互斥锁,递归互斥锁有点像返回 0 的互斥锁。它们都有各自的用途——人们有时会竭尽全力实现“安静的非-值”值。但是,如果您的代码设计为从不取消引用空指针,则默认情况下您不想使用静默允许这种情况发生的版本。

于 2013-01-24T10:35:58.937 回答
8

我不打算直接讨论 mutex 与 recursive_mutex 的争论,但我认为分享一个 recursive_mutex 对设计绝对至关重要的场景会很好。

在使用 Boost::asio、Boost::coroutine(可能还有诸如 NT Fibers 之类的东西,尽管我对它们不太熟悉)时,即使没有重入设计问题,您的互斥锁也是递归的绝对必要的。

原因是基于协程的方法在其设计上会暂停一个例程的执行,然后再恢复它。这意味着一个类的两个顶级方法可能“在同一个线程上同时被调用”而没有进行任何子调用。

于 2015-02-26T11:32:07.930 回答