设计一个线程安全的可复制类

时间:2011-02-21 19:13:22

标签: c++ boost-thread

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

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;
  }
};

缺点是类的所有实例共享相同的互斥锁,因此不必要地相互阻塞。这不能通过使互斥属性非静态(因此为每个实例提供其自己的互斥锁)来解决,因为如果正确完成,复制和赋值的复杂性是噩梦。

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


感谢所有回复。

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


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

<小时/> 我现在认为最好的方法是构建一个小类来保存互斥锁和关键属性。然后我可以为关键类编写一个小的复制构造函数和赋值运算符,让编译器来管理主类中的所有其他属性。

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();
  }
};

3 个答案:

答案 0 :(得分:4)

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

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

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

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

答案 1 :(得分:4)

问题可能很简单,正确的做法并非如此简单。对于初学者,我们可以使用简单的复制构造函数:

// 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函数将获取作为参数传入的所有锁,并确保获取顺序相同,确保所有需要获取这两个互斥锁的代码都通过{{1没有死锁。 (您仍然可以通过单独手动将它们锁定在其他位置来实现死锁)。接下来的两行将锁存储在实现RAII的对象中,这样如果赋值操作失败(抛出异常),则释放锁。

使用std::lock代替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=a的任何给定时间断言(通过获取两个锁来安全地进行线程化)是相同的,这似乎是粗略阅读代码所期望的。如果b是根据复制和交换习惯用法实现的,那就不成立。问题是在标记为[2]的行中,operator=被锁定并复制到临时,然后释放锁。然后,第一个线程可以同时获取两个锁,并在b被[2]中的第二个线程锁定之前递增ab。然后使用a在增量之前的值覆盖a

答案 2 :(得分:3)

一个简单的事实是你不能通过在问题上注入互斥量来使类线程安全。你无法完成这项工作的原因是因为它不起作用,不是因为你做错了这个技术。当多线程首次出现并开始屠宰COW字符串实现时,这是每个人都注意到的。

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