如何实现一个原子(线程安全)且异常安全的深拷贝赋值运算符?

12

我在一次面试中被问到这个问题,但我回答得不好。

更具体地说,赋值运算符所属的类看起来像这样:

class A {
private:
    B* pb;
    C* pc;
    ....
public:
    ....
}

如何为这个类实现一个原子(线程安全)和异常安全的深拷贝赋值运算符?


5
一个线程安全的赋值运算符?那些面试官确实在推动难度。 - stijn
2
如何实现一个“万无一失”的方案?当然不能使用普通的C指针并且搞乱它们所指向的对象的副本……我们有RAII和智能指针来确保安全,不是吗? - leftaroundabout
8
这里不需要进行深拷贝,因为没有裸指针应该拥有任何对象,而只应该进行观察。 ;) - Xeo
2
@Xeo 尽管实际的工业代码库经常正确使用 RAII 是多么可爱,但事实是有大量现有代码使用原始指针作为拥有指针。试图改进这样的代码库以达到更好的实践会消耗大量时间,可能会导致意想不到的错误,并可能在政治上失败。所以无论您认为这是否“有意义”,C++维护者都可以从知道如何在这些不太理想的情况下工作中受益。 - WeirdlyCheezy
4
但是在这些不理想的情况下工作,是否会导致以下情况之一:#1 使情况变得更糟;#2 使其更接近理想状态? - R. Martinho Fernandes
显示剩余4条评论
3个回答

12

有两个独立的问题(线程安全和异常安全),最好分别解决。为了允许构造函数接受另一个对象作为参数,并在初始化成员时获取锁,需要将数据成员分解为一个单独的类:这样就可以在子对象初始化时获取锁,而维护实际数据的类则可以忽略任何并发问题。因此,该类将被分成两部分: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 指出这些问题。虽然解决问题的结果涉及更多代码,但我认为代码的每个单独部分实际上更简单,可以进行正确性验证。


2
复制右侧是原子性的,因为在复制完成时它被锁定。在获取锁之前将左侧赋值是合法的,这在概念上等同于在复制之前获取锁,除了自我赋值的情况。当自我赋值时存在潜在问题,即在复制放置之前,可能会发生不同线程更改对象的情况。这是一个有趣的地方,可以检查自我赋值是否合理。自我赋值实际上不应该发生,但如果发生了,也不应该导致程序失败。 - Dietmar Kühl
这看起来很可靠。我只想补充一点,根据所述复制构造函数的实现方式,这个解决方案(可能是大多数/所有实际解决方案?)最好能提供强异常保证,因为它可能会动态分配B、C的副本。此外,如果确实想要提供强保证,它需要小心不要在发生异常的情况下保留互斥锁。 - WeirdlyCheezy
1
以上技术可能会遇到的意外情况是Thread1执行Y=X,Thread2执行(锁定X和Y),然后X.B = nullptr,Y.B = nullptr,然后解锁。在两个线程都完成后,您可能会发现Y.B!= nullptr。实质上,这不是原子读写赋值,而只是原子读取后跟随原子写入。 - Yakk - Adam Nevraumont
2
这篇文章现在包含了一个潜在的死锁,而不是竞争条件,就在这里: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 Nevraumont
1
@jogojapan:d_data 两次使用是一个笔误(已修复)。A_unlock 的复制构造函数只接受一个 A_unlock。然而,该调用使用逗号运算符首先使用临时的 std::unique_lock<std::mutex>(this->d_mutex) 锁定互斥量,然后生成传递给复制构造函数的参数。这就是为什么有一个额外的括号的原因。 - Dietmar Kühl
显示剩余6条评论

4
首先,您必须了解没有任何操作是线程安全的,而是给定资源上的所有操作都可以相互线程安全。因此,我们必须讨论非赋值运算符代码的行为。
最简单的解决方案是使数据不可变,编写一个使用pImpl类存储不可变引用计数A的Aref类,并将对Aref的突变方法导致创建新的A。您可以通过使A的不可变引用计数组件(如B和C)遵循类似的模式来实现细粒度。基本上,Aref成为A的COW(写时复制)pImpl包装器(您可以包含优化以处理单个参考案例以消除冗余副本)。
第二种方法是在A及其所有数据上创建单体锁(互斥锁或读取器-写入器)。在这种情况下,您需要对A实例的锁进行互斥排序(或类似技术),以创建无竞争的operator=,或者接受可能令人惊讶的竞争条件可能性并执行Dietmar提到的副本交换语法。 (复制移动也可接受)(锁 - 拷贝构造函数,锁 - 交换赋值运算符=中的显式竞争条件:Thread1执行X = Y。Thread2执行Y.flag = true,X.flag = true。之后的状态:X.flag为false。即使Thread2在整个分配过程中都锁定了X和Y,这也可能发生。这会让许多程序员感到惊讶。)
在第一种情况下,非赋值代码必须遵守写时复制语义。在第二种情况下,非赋值代码必须遵守单体锁定。
至于异常安全性,如果您假设您的拷贝构造函数和锁代码是异常安全的,则锁-复制-锁-交换(第二个)是异常安全的。对于第一个,只要您的引用计数,锁克隆和数据修改代码是异常安全的,您就可以很好地处理:无论哪种情况下,operator=代码都相当简单。 (确保您的锁是RAII,将所有已分配的内存存储在std RAII指针持有者中(具有释放功能,以防您结束时将其移交),等等。)

0

异常安全?对于原始类型的操作不会抛出异常,因此我们可以免费获得。

原子性?最简单的方法是使用2xsizeof(void*)的原子交换-我相信大多数平台都提供了这个功能。如果没有,您将不得不使用锁定,或者使用无锁算法。

编辑:深拷贝,嗯?您需要将A和B复制到新的临时智能指针中,然后以原子方式交换它们。


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