[英]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.