我在接受采访时被问到这个问题,我无法回答这个问题。
更具体地说,赋值运算符所属的类如下所示:
class A {
private:
B* pb;
C* pc;
....
public:
....
}
如何为此类实现原子(线程安全)和异常安全,深层复制赋值运算符?
答案 0 :(得分:12)
有两个独立的问题(线程安全和异常安全),最好分别解决它们。为了允许构造函数在初始化成员时将另一个对象作为参数获取锁,有必要将数据成员分解为一个单独的类:这样就可以在初始化子对象时获取锁,并保持实际数据的类可以忽略任何并发问题。因此,该类将分为两部分:class A
来处理并发问题,class A_unlocked
来维护数据。由于A_unlocked
的成员函数没有任何并发保护,因此不应在接口中直接公开它们,因此A_unlocked
成为A
的私有成员。 / p>
利用复制构造函数,可以直接创建异常安全的赋值运算符。复制参数并交换成员:
A_unlocked& A_unlocked::operator= (A_unlocked const& other) {
A_unlocked(other).swap(*this);
return *this;
}
当然,这意味着实现了合适的复制构造函数和swap()
成员。通过为每个对象提供合适的资源处理程序,最简单地处理多个资源的分配,例如,在堆上分配的多个对象。如果不使用资源处理程序,在抛出异常时正确清理所有资源会很快变得非常混乱。为了维护堆分配的内存std::unique_ptr<T>
(如果不能使用C ++ 2011,则为std::auto_ptr<T>
)是一个合适的选择。下面的代码只是复制指向的对象,尽管在堆上分配对象而不是将它们作为成员没有多大意义。在一个真实的例子中,对象可能会实现一个clone()
方法或一些其他机制来创建一个正确类型的对象:
class A_unlocked {
private:
std::unique_ptr<B> pb;
std::unique_ptr<C> pc;
// ...
public:
A_unlocked(/*...*/);
A_unlocked(A_unlocked const& other);
A_unlocked& operator= (A_unlocked const& other);
void swap(A_unlocked& other);
// ...
};
A_unlocked::A_unlocked(A_unlocked const& other)
: pb(new B(*other.pb))
, pc(new C(*other.pc))
{
}
void A_unlocked::swap(A_unlocked& other) {
using std::swap;
swap(this->pb, other.pb);
swap(this->pc, other.pc);
}
对于线程安全位,有必要知道没有其他线程正在弄乱复制的对象。这样做的方法是使用互斥锁。也就是说,class A
看起来像这样:
class A {
private:
mutable std::mutex d_mutex;
A_unlocked d_data;
public:
A(/*...*/);
A(A const& other);
A& operator= (A const& other);
// ...
};
请注意,如果要在没有外部锁定的情况下使用A
类型的对象,A
的所有成员都需要执行一些并发保护。由于用于防止并发访问的互斥锁实际上不是对象状态的一部分,但即使在读取对象的状态时也需要更改,因此它是mutable
。有了这个,创建一个复制构造函数是直截了当的:
A::A(A const& other)
: d_data((std::unique_lock<std::mutex>(other.d_mutex), other.d_data)) {
}
这会将参数的互斥锁和委托锁定到成员的复制构造函数。无论复制是成功还是抛出异常,锁都会在表达式结束时自动释放。正在构造的对象不需要任何锁定,因为其他线程无法知道这个对象。
赋值运算符的核心逻辑也只是使用赋值运算符委托给基数。棘手的一点是有两个需要锁定的互斥锁:一个用于被分配的对象,另一个用于参数。由于另一个线程可以以相反的方式分配这两个对象,因此存在死锁的可能性。方便的是,标准C ++库提供std::lock()
算法,该算法以适当的方式获取锁,以避免死锁。使用此算法的一种方法是传入未锁定的std::unique_lock<std::mutex>
对象,每个对象需要获取一个互斥锁:
A& A::operator= (A const& other) {
if (this != &other) {
std::unique_lock<std::mutex> guard_this(this->d_mutex, std::defer_lock);
std::unique_lock<std::mutex> guard_other(other.d_mutex, std::defer_lock);
std::lock(guard_this, guard_other);
*this->d_data = other.d_data;
}
return *this;
}
如果在分配期间的任何时刻抛出异常,锁定保护将释放互斥锁,资源处理程序将释放任何新分配的资源。因此,上述方法实现了强大的异常保证。有趣的是,复制分配需要进行自我分配检查以防止锁定相同的互斥锁两次。通常,我认为必要的自我赋值检查表明赋值运算符不是异常安全的,但我认为上面的代码是异常安全的。
这是对答案的重大改写。此答案的早期版本要么容易丢失更新,要么发生死锁。感谢Yakk指出了问题所在。虽然解决问题的结果涉及更多代码,但我认为代码的每个部分实际上都更简单,可以进行调查以确保正确性。
答案 1 :(得分:4)
首先,您必须了解任何操作都不是线程安全的,而是对给定资源的所有操作都可以是相互线程安全的。所以我们必须讨论非赋值运算符代码的行为。
最简单的解决方案是使数据不可变,编写一个使用pImpl类来存储不可变引用计数A的Aref类,并在Aref上使用变异方法导致创建新的A.您可以通过使A的不可变引用计数组件(如B和C)遵循类似的模式来实现粒度。基本上,Aref成为A的一个COW(写入时复制)pImpl包装器(您可以包括优化来处理单引用情况以消除冗余副本)。
第二种方法是在A及其所有数据上创建单片锁(互斥锁或读写器)。在这种情况下,您需要对A(或类似技术)的实例锁定进行互斥排序,以创建无竞争的运算符=,或接受可能令人惊讶的竞争条件的可能性,并执行Dietmar提到的复制交换习惯。 (复制 - 移动也是可以接受的)(lock-copyconstruct中的显式竞争条件,锁定交换赋值运算符=:Thread1执行X = Y.线程2执行Y.flag = true,X.flag = true。后续状态:X .flag是假的。即使Thread2在整个赋值中同时锁定X和Y,也会发生这种情况。这会让很多程序员感到惊讶。)
在第一种情况下,非赋值代码必须遵守写时复制语义。在第二种情况下,非赋值代码必须服从单片锁。
对于异常安全性,如果你假设你的拷贝构造函数是异常安全的,就像你的锁码一样,lock-copy-lock-swap one(第二个)是异常安全的。对于第一个,只要您的引用计数,锁定克隆和数据修改代码是异常安全的,您就是好的:在任何一种情况下,operator =代码都非常大脑死亡。 (确保你的锁是RAII,将所有已分配的内存存储在std RAII指针持有者中(如果最终将其关闭,则能够释放),等等。)
答案 2 :(得分:0)
异常安全?基元的操作不会抛出,所以我们可以免费获得。
原子?最简单的是2x sizeof(void*)
的原子交换 - 我相信大多数平台都提供此功能。如果他们不这样做,你就不得不求助于使用锁,或者有无锁算法可以使用。
编辑:深拷贝,对吧?你必须将A和B复制到新的临时智能指针中,然后以原子方式交换它们。