繁体   English   中英

如何实现原子(线程安全)和异常安全的深层复制赋值运算符?

[英]How to implement an atomic (thread-safe) and exception-safe deep copy assignment operator?

我在接受采访时被问到这个问题,我无法回答这个问题。

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

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> (或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所有成员都需要执行一些并发保护。 由于用于防止并发访问的互斥锁实际上不是对象状态的一部分,但即使在读取对象的状态时也需要更改它,因此它是mutable 有了这个,创建一个复制构造函数是直截了当的:

A::A(A const& other)
    : d_data((std::unique_lock<std::mutex>(other.d_mutex), other.d_data)) {
}

这会将参数的互斥锁和委托锁定到成员的复制构造函数。 无论复制是成功还是抛出异常,锁都会在表达式结束时自动释放。 正在构造的对象不需要任何锁定,因为其他线程无法知道该对象。

赋值运算符的核心逻辑也只是使用赋值运算符委托给基数。 棘手的一点是有两个需要锁定的互斥锁:一个用于被分配的对象,另一个用于参数。 由于另一个线程可以以相反的方式分配这两个对象,因此存在死锁的可能性。 方便的是,标准C ++库提供了std::lock()算法,该算法以适当的方式获取锁,以避免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指出了问题所在。 虽然解决问题的结果涉及更多代码,但我认为代码的每个单独部分实际上都更简单并且可以进行调查以确保正确性。

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

最简单的解决方案是使数据不可变,编写一个使用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指针支架中(如果最终将其关闭,则可以释放),等等)

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

原子? 最简单的是2x sizeof(void*)的原子交换 - 我相信大多数平台都提供此功能。 如果他们不这样做,你必须使用锁,或者有无锁算法可以工作。

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

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM