12

我在一次采访中被问到这个问题,我回答不好。

更具体地说,赋值运算符所属的类如下所示:

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

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

4

3 回答 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>(或者std::auto_ptr<T>如果您不能使用 C++ 2011)是一个合适的选择。下面的代码只是复制指向的对象,尽管在堆上分配对象而不是使它们成为成员并没有多大意义。在一个真实的例子中,对象可能会实现一个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由于用于防止并发访问的互斥锁实际上并不是对象状态的一部分,但即使在读取对象状态时也需要更改,因此它是 make 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 指出问题。尽管解决问题的结果涉及更多代码,但我认为代码的每个单独部分实际上都更简单,并且可以调查其正确性。

于 2012-10-23T13:20:28.800 回答
4

首先,您必须了解没有操作是线程安全的,而是对给定资源的所有操作都可以是相互线程安全的。所以我们必须讨论非赋值运算符代码的行为。

最简单的解决方案是使数据不可变,编写一个使用 pImpl 类来存储不可变引用计数 A 的 Aref 类,并在 Aref 上使用变异方法导致创建新的 A。您可以通过让 A 的不可变引用计数组件(如 B 和 C)遵循类似的模式来实现粒度。基本上,Aref 成为 A 的 COW(写入时复制)pImpl 包装器(您可以包括优化以处理单引用情况以消除冗余副本)。

第二种方法是在 A 及其所有数据上创建一个整体锁(互斥锁或读写器)。在这种情况下,您要么需要对 A 实例的锁进行互斥排序(或类似技术)以创建无竞争的 operator=,要么接受可能令人惊讶的竞争条件可能性并执行 Dietmar 提到的复制交换习语。(复制移动也是可以接受的)(锁复制构造中的显式竞争条件,锁交换赋值运算符 =:线程 1 执行 X=Y。线程 2 执行 Y.flag = true,X.flag = true。之后的状态:X .flag 为假。即使 Thread2 在整个分配过程中同时锁定 X 和 Y,也可能发生这种情况。这会让许多程序员感到惊讶。)

在第一种情况下,非赋值代码必须遵守写时复制语义。在第二种情况下,非赋值代码必须服从整体锁。

至于异常安全,如果您假设您的复制构造函数是异常安全的,就像您的锁定代码一样,lock-copy-lock-swap 之一(第二个)是异常安全的。对于第一个,只要你的引用计数、锁克隆和数据修改代码是异常安全的,你就很好:无论哪种情况,operator= 代码都是相当脑死的。(确保您的锁是 RAII,将所有分配的内存存储在 std RAII 指针持有者中(如果您最终将其移交,则能够释放)等)

于 2012-10-26T17:37:00.430 回答
0

异常安全?对原语的操作不会抛出,所以我们可以免费获得。

原子?最简单的是 2x 的原子交换sizeof(void*)——我相信大多数平台都提供这个。如果他们不这样做,您将不得不求助于使用锁,或者有可以工作的无锁算法。

编辑:深拷贝,嗯?您必须将 A 和 B 复制到新的临时智能指针中,然后自动交换它们。

于 2012-10-23T13:05:38.220 回答