为什么将引用传递给Mutex类不是一个好的设计?

9
从这里开始:pthread中我定义的Mutex类和在生产者消费者程序中使用它的逻辑错误

你把对mutex类的引用随意传递,这明显会引起问题,它违反了任何一种封装。

为什么这是个问题?我应该传递值然后编写复制构造函数吗?

缺乏封装会带来什么危害?我应该如何封装?

还有,为什么将互斥锁类的引用传递给其他对象不是一个好的设计?

传递锁的引用是个坏主意——你不是“使用”锁,而是只获得然后归还。移动它会使跟踪(关键)资源的使用变得困难。传递互斥变量的引用而不是锁可能不是那么糟糕,但它仍然会使知道程序哪些部分可能会死锁变得更难,所以最好避免。

请用简单的语言和例子解释为什么传递引用是个坏主意?


2
通常情况下,您需要非常小心地使用互斥锁。当您锁定互斥锁时,您希望锁定时间非常短且经过精心选择,以避免死锁。传递锁的引用是一个不好的主意--您不会“使用”锁,只会获取然后归还它。将其移动使跟踪(关键)资源的使用变得困难。与传递锁而不是锁的引用相比,传递互斥锁变量的引用可能并不那么糟糕,但仍然使得更难知道程序的哪些部分可能会发生死锁,因此应该避免。通常情况下,您永远不需要这样做。 - Chris Beck
1
如果你特别将 "pthread_mutex &" 传递给你的互斥锁包装类,那么这有点丑陋...首先,如果你不小心,你可能会得到一个悬空引用;其次,你就会被绑定到 pthread 实现上。想必你的类的目的是为了隐藏实现细节,但在这里你却公然泄露了它们! - Chris Beck
@ChrisBeck,感谢您的尝试解释。我请求您在“答案”中解决问题及其解决方案。 - Aquarius_Girl
@TheIndependentAquarius 在看到这些评论之前,我已经开始写回答了,所以已经有一个详细解释了ChrisBeck提到的相同观点的答案 :) 如果Chris可以编辑答案,使其包括我们两个人的贡献,那将是一个更好的答案! - Curious
2个回答

7
我认为这既是糟糕的抽象又是糟糕的封装。一个 mutex 通常会被默认构造并禁用复制构造函数。拥有多个指向同一逻辑对象的互斥锁对象容易产生错误,即它可能导致死锁和其他竞态条件,因为程序员或读者可能会假设它们是不同的实体。
此外,通过指定你正在使用的内部互斥锁,你将暴露线程实现细节,从而破坏了 Mutex 类的抽象。如果你使用的是 pthread_mutex_t,那么你很可能正在使用内核线程(pthreads)。
封装也被破坏了,因为你的 Mutex 不是一个单独的封装实体,而是分散在几个(可能悬空的)引用中。
如果你想将 pthread_mutex_t 封装成一个类,可以这样做:
class Mutex {
public:
    void lock(); 
    void unlock();

    // Default and move constructors are good! 
    // You can store a mutex in STL containers with these
    Mutex();
    Mutex(Mutex&&);
    ~Mutex();

    // These can lead to deadlocks!
    Mutex(const Mutex&) = delete;
    Mutex& operator= (const Mutex&) = delete;
    Mutex& operator= (Mutex&&) = delete;

private:
    pthread_mutex_t internal_mutex;
};

互斥锁对象应该在实现文件中声明为共享作用域,而不是本地声明并在函数中作为引用传递。理想情况下,您只需要向线程构造函数传递所需参数。将在与函数(线程执行)相同“层次”的作用域中声明的对象的引用传递会导致代码错误。如果声明互斥锁的范围不存在了怎么办?mutex的析构函数是否会使互斥锁的内部实现失效?如果互斥锁通过传递方式到达另一个模块,并且该模块启动了自己的线程并认为互斥锁永远不会阻塞,那么这可能会导致恶性死锁。

此外,使用互斥锁移动构造函数的一种情况是互斥锁工厂模式。如果要创建新的互斥锁,则可以进行函数调用,该函数将返回一个互斥锁,然后将其添加到互斥锁列表中或将其传递给请求它的线程,通过某种共享数据(前面提到的列表对于这种共享数据是一个好主意)。然而,正确使用这样的互斥锁工厂模式可能会很棘手,因为您需要锁定对公共互斥锁列表的访问。这将是一个有趣的尝试!

如果作者的意图是避免全局范围,则在实现文件中声明一次静态对象就足够了。


非常感谢您的回答,我花了一些时间来理解它。我所理解的是,如果我们使用移动构造函数而不是通过引用传递参数,那么我们就不必担心外部局部对象被删除的问题。这是正确的吗?除此之外,您还有其他想要通过答案传达的内容吗? - Aquarius_Girl
是的,我想通过我的回答传达其他事情。首先,移动构造函数在互斥锁方面非常方便,不是因为你自己想要传递它们,而是在你有一个互斥锁向量并且你调整向量大小以添加新的互斥锁时,互斥锁需要从一个数组移动到另一个数组中。我想传达的第二个主要观点是,在大多数情况下,互斥对象应该是全局的。我想传达的第三个主要观点是,通过引用传递互斥锁会导致竞态条件。 - Curious
谢谢!我现在可以发布赏金了hhahaha。但回到第三点。我个人在我的第一个并发项目中编写了这段代码,所以我是通过经验来说的。我设置了以下代码。在线程中,如果有访问没有与之关联互斥量的磁盘块,我使用工厂模式给我一个新的互斥对象,然后将其作为引用传递给线程构造函数。然后,我继续分离线程。这导致互斥锁销毁时出现竞争,我不太满意:( 希望这解决了你的疑问 :) - Curious
如果不是这样,请再问我,我会尝试想出另一个类似的竞态条件。 - Curious

6
我会把它简化成两个问题: 1. 何时适合通过引用传递任何对象? 2. 何时适合共享互斥锁?
  1. 传递对象作为参数的方式反映了您期望在调用方和被调用方之间共享对象的生命周期。如果通过引用传递,您必须假设被调用方仅在调用期间使用该对象,或者如果引用由被调用方存储,则被调用方的生命周期短于引用。如果处理动态分配的对象,则应该使用智能指针,这样可以更明确地传达您的意图(请参见Herb Sutter's treatment)。

  2. 应避免共享互斥量。无论是通过引用还是其他方式,都是如此。通过共享互斥量,一个对象允许自己受到外部实体的内部影响。这违反了基本的封装原则,已经足够理由。 (有关封装优点,请参见任何面向对象编程的文本)。共享互斥量的一个真正后果是死锁的可能性。

    以这个简单的例子为例:

    • A 拥有互斥量
    • A 与 B 共享互斥量
    • B 在调用 A 的函数之前获得锁定
    • A 的函数试图获取锁定
    • 死锁..

从设计的角度来看,为什么要共享互斥量?互斥量保护一个可能被多个线程访问的资源。该互斥量应该被封装在控制该资源的类中并隐藏起来。互斥量只是这个类保护资源的一种方式;它是一个实现细节,只有该类应该知道。相反,共享控制资源的类的实例,并允许它以任何想要的方式确保自身的线程安全。


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