我在一次面试中被问到这个问题,但我回答得不好。
更具体地说,赋值运算符所属的类看起来像这样:
class A {
private:
B* pb;
C* pc;
....
public:
....
}
如何为这个类实现一个原子(线程安全)和异常安全的深拷贝赋值运算符?
我在一次面试中被问到这个问题,但我回答得不好。
更具体地说,赋值运算符所属的类看起来像这样:
class A {
private:
B* pb;
C* pc;
....
public:
....
}
如何为这个类实现一个原子(线程安全)和异常安全的深拷贝赋值运算符?
有两个独立的问题(线程安全和异常安全),最好分别解决。为了允许构造函数接受另一个对象作为参数,并在初始化成员时获取锁,需要将数据成员分解为一个单独的类:这样就可以在子对象初始化时获取锁,而维护实际数据的类则可以忽略任何并发问题。因此,该类将被分成两部分:class A
处理并发问题,class A_unlocked
维护数据。由于A_unlocked
的成员函数没有任何并发保护,它们不应该直接暴露在接口中,因此A_unlocked
被设置为A
的私有成员。
创建一个具有异常安全赋值运算符很简单,可以利用复制构造函数。将参数复制并交换成员即可:
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)) {
}
这个操作锁定了参数的互斥体(mutex),并委托给成员函数的复制构造函数(copy constructor)。无论复制是否成功或抛出异常,互斥体都会在表达式结束时自动释放。被构造的对象不需要任何锁定,因为没有其他线程知道该对象的存在。
赋值运算符的核心逻辑也是委托给基类,并使用其赋值运算符。关键在于有两个互斥体需要锁定:一个是被赋值的对象的互斥体,另一个是参数的互斥体。由于另一个线程可以以相反的方式分配这两个对象,可能会出现死锁的情况。方便的是,标准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 指出这些问题。虽然解决问题的结果涉及更多代码,但我认为代码的每个单独部分实际上更简单,可以进行正确性验证。
std::unique_lock<std::mutex> kerberos(this->d_mutex); A tmp(other); // needs to lock other's mutex
我们先锁定了this,然后在持有此锁的同时锁定了other。因此,如果线程1执行X=Y,线程2执行Y=X,那么这两个线程可能会发生死锁。随意锁定多个互斥量是不应该的。 - Yakk - Adam Nevraumontd_data
两次使用是一个笔误(已修复)。A_unlock
的复制构造函数只接受一个 A_unlock
。然而,该调用使用逗号运算符首先使用临时的 std::unique_lock<std::mutex>(this->d_mutex)
锁定互斥量,然后生成传递给复制构造函数的参数。这就是为什么有一个额外的括号的原因。 - Dietmar Kühl异常安全?对于原始类型的操作不会抛出异常,因此我们可以免费获得。
原子性?最简单的方法是使用2xsizeof(void*)
的原子交换-我相信大多数平台都提供了这个功能。如果没有,您将不得不使用锁定,或者使用无锁算法。
编辑:深拷贝,嗯?您需要将A和B复制到新的临时智能指针中,然后以原子方式交换它们。