【问题标题】:Designing a thread-safe copyable class设计一个线程安全的可复制类
【发布时间】:2011-07-01 12:10:29
【问题描述】:

使类线程安全的直接方法是添加互斥体属性并在访问器方法中锁定互斥体

class cMyClass {
  boost::mutex myMutex;
  cSomeClass A;
public:
  cSomeClass getA() {
    boost::mutex::scoped_lock lock( myMutex );
    return A;
  }
};

问题是这使得类不可复制。

我可以通过将互斥体设为静态来使事情正常进行。但是,这意味着当访问任何其他实例时,该类的每个实例都会阻塞,因为它们都共享相同的互斥体。

不知道有没有更好的办法?

我的结论是没有更好的方法。使用私有静态互斥体属性使类线程安全是“最好的”: - 它简单,有效,并且隐藏了尴尬的细节。

class cMyClass {
  static boost::mutex myMutex;
  cSomeClass A;
public:
  cSomeClass getA() {
    boost::mutex::scoped_lock lock( myMutex );
    return A;
  }
};

缺点是类的所有实例共享相同的互斥体,因此不必要地相互阻塞。这无法通过使 mutex 属性为非静态(因此为每个实例赋予其自己的 mutex)来解决,因为如果操作正确,复制和分配的复杂性将是噩梦般的。

如果需要,单个互斥锁必须由外部不可复制单例管理,并在创建时建立到每个实例的链接。


感谢所有回复。

有几个人提到过编写我自己的复制构造函数和赋值运算符。我试过这个。问题是我的真实班级有许多在开发过程中总是在变化的属性。维护复制构造函数和assignmet 运算符既繁琐又容易出错,错误会导致难以发现的错误。让编译器为复杂的类生成这些是巨大的节省时间和减少错误的方法。


许多响应都关心使复制构造函数和赋值运算符线程安全。这个要求给整个事情增加了更多的复杂性!对我来说幸运的是,我不需要它,因为所有的复制都是在设置过程中在一个线程中完成的。


我现在认为最好的方法是构建一个小类来保存互斥锁和关键属性。然后我可以为关键类编写一个小的复制构造函数和赋值运算符,让编译器来处理主类中的所有其他属性。
class cSafe {
  boost::mutex myMutex;
  cSomeClass A;
public:
  cSomeClass getA() {
    boost::mutex::scoped_lock lock( myMutex );
    return A;
  }
  (copy constructor)
  (assignment op )

};
class cMyClass {
  cSafe S;
  ( ... other attributes ... )
public:
  cSomeClass getA() {
    return S.getA();
  }
};

【问题讨论】:

  • 我曾经尝试过一次,但做得不太好。我想我共享了 boost::mutex (存储了对互斥锁的引用),但我不记得它的效果如何(以及与使其成为静态相同的问题)。我饶有兴趣地等待。
  • 互斥体不可复制,所以默认的复制构造函数和赋值操作符不起作用,但为什么不能自己写呢?
  • 确实意识到只有在一个对象被修改的同时它被用来克隆另一个对象时才需要这样做?我会怀疑以这种方式构建的程序是一个问题......
  • @outis:你可以。隐含的问题是如何使赋值运算符和复制构造函数线程安全。然而,这是一个完全不同的故事。
  • @André Caron 也许我应该解释一下我需要做什么。我需要能够将类实例存储在 std::vector 容器中,因此它们必须是可复制的。我还希望能够从多个线程访问类实例的属性。所以这个类必须是可复制的和线程安全的。我不认为需要使复制线程安全:在初始化期间复制仅从一个线程完成。

标签: c++ boost-thread


【解决方案1】:

问题可能很简单,但要正确解决问题却不是那么简单。对于初学者,我们可以使用简单的复制构造函数:

// almost pseudo code, mutex/lock/data types are synthetic
class test {
   mutable mutex m;
   data d;
public:
   test( test const & rhs ) {
      lock l(m);         // Lock the rhs to avoid race conditions,
                         // no need to lock this object.
      d = rhs.d;         // perform the copy, data might be many members
   }
};

现在创建赋值运算符更加复杂。想到的第一件事就是做同样的事情,但在这种情况下同时锁定 lhs 和 rhs:

class test { // wrong
   mutable mutex m;
   data d;
public:
   test( test const & );
   test& operator=( test const & rhs ) {
      lock l1( m );
      lock l2( rhs.m );
      d = rhs.d;
      return *this;
   }
};

够简单,但也错了。虽然我们保证在操作期间对对象(两者)的单线程访问,因此我们没有竞争条件,但我们有一个潜在的死锁:

test a, b;
// thr1              // thr2
void foo() {         void bar() {
   a = b;               b = a;
}                    }

这不是唯一潜在的死锁,代码对于自赋值是不安全的(大多数互斥锁不是递归的,尝试锁定同一个互斥锁两次会阻塞线程)。最简单的解决方法是自赋值:

test& test::operator=( test const & rhs ) {
   if ( this == &rhs ) return *this; // nothing to do
   // same (invalid) code here
}

对于问题的另一部分,您需要强制执行获取互斥锁的顺序。这可以通过不同的方式处理(为每个对象存储一个唯一标识符并进行比较......)

test & test::operator=( test const & rhs ) {
   mutex *first, *second;
   if ( unique_id(*this) < unique_id(rhs ) {
      first = &m;
      second = &rhs.m;
   } else {
      first = &rhs.m;
      second = &rhs.m;
   }
   lock l1( *first );
   lock l2( *second );
   d = rhs.d;
}

具体的顺序并不像您需要确保所有使用中的顺序相同这一事实重要,否则您可能会死锁线程。由于这很常见,一些库(包括即将推出的 c++ 标准)对它有特定的支持:

class test {
   mutable std::mutex m;
   data d;
public:
   test( const test & );
   test& operator=( test const & rhs ) {
      if ( this == &rhs ) return *this;        // avoid self deadlock
      std::lock( m, rhs.m );                   // acquire both mutexes or wait
      std::lock_guard<std::mutex> l1( m, std::adopt_lock );      // use RAII to release locks
      std::lock_guard<std::mutex> l2( rhs.m, std::adopt_lock );
      d = rhs.d;
      return *this;
   }
};

std::lock 函数将获取作为参数传入的所有锁,并确保获取的顺序相同,确保如果所有需要获取这两个互斥锁的代码都通过 std::lock不会陷入僵局。 (您仍然可以通过手动将它们单独锁定在其他地方来死锁)。接下来的两行将锁存储在实现 RAII 的对象中,以便在分配操作失败(抛出异常)时释放锁。

这可以通过使用std::unique_lock而不是std::lock_guard来进行不同的拼写:

std::unique_lock<std::mutex> l1( m, std::defer_lock );     // store in RAII, but do not lock
std::unique_lock<std::mutex> l2( rhs.m, std::defer_lock );
std::lock( l1, l2 );                                       // acquire the locks

我只是想到了一种不同的更简单的方法,我在这里画了草图。语义略有不同,但对于许多应用程序来说可能就足够了:

test& test::operator=( test copy ) // pass by value!
{
   lock l(m);
   swap( d, copy.d );   // swap is not thread safe
   return *this;
}

}

这两种方法存在语义差异,因为具有复制和交换习语的方法具有潜在的竞争条件(可能会或可能不会影响您的应用程序,但您应该注意这一点)。由于两个锁永远不会同时持有,因此对象可能会在第一个锁被释放(参数的副本完成)和第二个锁在operator= 内获得之间发生变化。

作为一个失败的例子,请考虑data 是一个整数,并且您有两个使用相同整数值初始化的对象。一个线程同时获取锁并增加值,而另一个线程将其中一个对象复制到另一个对象中:

test a(0), b(0); // ommited constructor that initializes the ints to the value
// Thr1
void loop() { // [1]
   while (true) {
      std::unique_lock<std::mutex> la( a.m, std::defer_lock );
      std::unique_lock<std::mutex> lb( b.m, std::defer_lock );
      std::lock( la, lb );
      ++a.d;
      ++b.d;
   }
}
// Thr1
void loop2() {
   while (true) {
      a = b; // [2]
   }
}
// [1] for the sake of simplicity, assume that this is a friend 
//     and has access to members

使用operator= 的实现同时对两个对象执行锁定,您可以在任何给定时间(通过获取两个锁定安全地执行线程)断言ab 是相同的,这似乎粗略阅读代码可以预期。如果operator= 是根据复制和交换习语来实现的,则不成立。问题是在标记为 [2] 的行中,b 被锁定并复制到临时文件中,然后释放锁定。然后第一个线程可以同时获取两个锁,并在a 被[2] 中的第二个线程锁定之前增加ab。然后ab 在增量之前的值覆盖。

【讨论】:

  • 啊-啊!这里的重点是,我不仅需要一个复制构造函数,而且还需要一个赋值运算符。那是我的错误。除此之外,我认为我不需要所有这些复杂性,因为复制/分配不需要是线程安全的。我想。
  • 很好地指出了复制赋值运算符的复杂性。有关此主题的更多信息(以及 C++0x 标准解决方案),请参阅:home.roadrunner.com/~hinnant/mutexes/locking.html。搜索“运营商=”。有一种解决方案是独占锁定,另一种是共享锁定。
  • @ravenspoint:如果您可以确保仅在没有并发的情况下(在被复制的两个对象上)执行复制,您可能会考虑这一点。但是请注意,提供非线程安全的实现可能会反击您:从现在开始,您可能需要在多线程环境中复制对象,并且复制运算符将在那里,您将只使用它,并且经常使用它您会注意到软件中的故障,但不明白问题是什么,甚至看起来与副本无关......
  • @dibreas 如果我正在为可重用库编写通用对象(例如字符串),这将是一个问题,当能够保证线程安全的可复制类可能值得复杂时。我正在设计特定于问题领域的一次性类,从监控线程更新并从 UI 线程访问的工具,因此它们不需要重复使用,简单性是一个重要的优点。
  • @ravenspoint:我只是想到了一种应该更简单的不同方法:test&amp; operator=( test rhs ) { lock l(m); d = rhs.d; }(或者使用非线程安全的swap。基本上它通过 读取到一个临时的——那里没有多线程问题——然后写入到实际的对象
【解决方案2】:

简单的事实是您不能通过在问题上喷出互斥体来使类线程安全。你不能使这项工作的原因是因为它不起作用,而不是因为你做错了这项技术。这是当多线程刚出现并开始屠戮 COW 字符串实现时大家注意到的。

线程设计发生在应用程序级别,而不是基于每个类。只有特定的资源管理类才应该在这个级别上具有线程安全性——而且对于它们,您无论如何都需要编写显式的复制构造函数/赋值运算符。

【讨论】:

  • 这个练习让我相信这是正确的答案。其他方法非常复杂,我永远无法确定我的代码是否正确。
【解决方案3】:

您可以定义自己的复制构造函数(和复制赋值运算符)。复制构造函数可能看起来像这样:

cMyClass(const cMyClass& x) : A(x.getA()) { }

请注意,getA() 需要经过 const 限定才能使其工作,这意味着互斥锁需要为 mutable;您可以将参数设为非常量引用,但是您不能复制临时对象,这通常是不可取的。

另外,请考虑在如此低的级别执行锁定并不总是一个好主意:如果您将互斥锁锁定在访问器和更改器函数中,您会失去很多功能。例如,您不能执行比较和交换,因为您无法使用互斥锁的单个锁获取和设置成员变量,并且如果您有多个受互斥锁控制的数据成员,则无法访问其中有多个已锁定互斥锁。

【讨论】:

  • 这种方法有一个非常糟糕的问题:在存在多个数据成员的情况下,互斥锁在成员分配之间被解锁。
  • @Andre:是的,但是在这种情况下,这是类接口的一个更普遍的问题:您不能锁定互斥锁并访问多个成员变量(甚至对成员变量)。它可以仅用于复制构造函数的实现(因为它是一个成员,它可以锁定互斥体并在不使用公共接口的情况下复制状态),但重构接口可能会更好。我在答案的末尾添加了一个注释。
  • @AndréCaron:将分组的数据成员重构为(私有)基类,该基类源于每个职责有一个类(在这种情况下管理与某些对象关联的互斥锁)。
猜你喜欢
  • 1970-01-01
  • 2021-09-28
  • 1970-01-01
  • 1970-01-01
  • 2013-06-23
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多