【muduo】執行緒安全的物件生命期管理
文章目錄
一、當解構函式遇到多執行緒
當一個物件能被多個執行緒同時看到時,那麼物件的銷燬時機就會變得模糊不清,可能出現多種競態條件(race condition):
-
在即將析構一個物件時,從何而知此刻是否有別的執行緒正在執行該物件的成員函式?
-
如何保證在執行成員函式期間,物件不會在另一個執行緒被析構?
-
在呼叫某個物件的成員函式之前,如何得知這個物件還活著?它的解構函式會不會碰巧執行到一半?
解決這些 race condition 是 C++ 多執行緒程式設計面臨的基本問題。本文試圖以shared_ptr 一勞永逸地解決這些問題。
【執行緒安全的定義】:
一個執行緒安全的 class 應當滿足以下三個條件:
-
多個執行緒同時訪問時,其表現出正確的行為。
-
無論作業系統如何排程這些執行緒, 無論這些執行緒的執行順序如何交織
-
呼叫端程式碼無須額外的同步或其他協調動作。
二、物件的建立很簡單
物件構造要做到執行緒安全,唯一的要求是在構造期間不要洩露 this 指標,即:
-
不要在建構函式中註冊任何回撥。
-
也不要在建構函式中把 this 傳給跨執行緒的物件。
-
即便在建構函式的最後一行也不行。
// 不要這麼做( Don't do this.)
class Foo : public Observer
{
public:
Foo(Observable* s)
{
s- >register_(this); // 錯誤,非執行緒安全
}
virtual void update();
};
// 物件構造的正確方法:
// 要這麼做(Do this)
class Foo : public Observer
{
public:
Foo();
virtual void update();
// 另外定義一個函式,在構造之後執行回撥函式的註冊工作
void observe(Observable* s)
{
s->register_(this);
}
};
Foo* pFoo = new Foo;
Observable* s = getSubject();
pFoo->observe(s); // 二段式構造,或者直接寫 s->register_(pFoo);
三、銷燬太難
mutex 只能保證函式一個接一個地執行,考慮下面的程式碼,它試圖用互斥鎖來保護解構函式:(注意程式碼中的 (1) 和 (2) 兩處標記。)
儘管執行緒 A 在銷燬物件之後把指標置為了 NULL,儘管執行緒 B 在呼叫 x 的成員函式之前檢查了指標 x 的值,但還是無法避免一種 race condition:
-
執行緒 A 執行到了解構函式的 (1) 處,已經持有了互斥鎖,即將繼續往下執行。
-
執行緒 B 通過了 if (x) 檢測,阻塞在 (2) 處。
四、原始指標有何不妥
假如執行緒A通過p1將object物件銷燬了,這時候p2就變成了野指標或者叫空懸指標,這是一種典型的記憶體錯誤:
【Note】:
-
指向物件的原始指標( raw pointer)是壞的,尤其當暴露給別的執行緒時。
-
要想安全地銷燬物件,最好在別人(執行緒)都看不到的情況下,偷偷地做。
五、神器 shared_ptr/weak_ptr
shared_ptr是引用計數型智慧指標,當引用計數變為0時,物件被銷燬。weak_ptr(主要是解決shared_ptr迴圈引用的問題,將其中一個shared_ptr換成weak_ptr即可)也是引用計數型智慧指標,但是它不增加物件的引用次數,即弱引用。
1、shared_ptr的關鍵點
-
shared_ptr 控制物件的生命期。 shared_ptr 是強引用(想象成用鐵絲綁住堆上的物件),只要有一個指向 x 物件的 shared_ptr 存在,該 x 物件就不會析構。當指向物件 x 的最後一個 shared_ptr 析構或 reset() 的時候, x 保證會被銷燬。
-
weak_ptr 不控制物件的生命期,但是它知道物件是否還活著(想象成用棉線輕輕拴住堆上的物件)。如果物件還活著,那麼它可以提升( promote)為有效的shared_ptr;如果物件已經死了,提升會失敗,返回一個空的shared_ptr。“提升/lock()”行為是執行緒安全的。
-
shared_ptr/weak_ptr 的“計數”在主流平臺上是原子操作,沒有用鎖,效能不俗。
-
shared_ptr/weak_ptr 的執行緒安全級別與 std::string 和 STL 容器一樣。
2、shared_ptr的執行緒安全
雖然我們借 shared_ptr 來實現執行緒安全的物件釋放,但是 shared_ptr 本身不是100% 執行緒安全的。它的引用計數本身是安全且無鎖的,但物件的讀寫則不是,因為shared_ptr 有兩個資料成員,讀寫操作不能原子化。根據文件 11,shared_ptr 的執行緒安全級別和內建型別、標準庫容器、 std::string 一樣,即:
-
一個 shared_ptr 物件實體可被多個執行緒同時讀取;
-
兩個 shared_ptr 物件實體可以被兩個執行緒同時寫入,“析構”算寫操作;
-
如果要從多個執行緒讀寫同一個 shared_ptr 物件,那麼需要加鎖。
請注意,以上是 shared_ptr 物件本身的執行緒安全級別,不是它管理的物件的執行緒安全級別。
六、系統地避免各種指標錯誤
-
緩衝區溢位:用 std::vector/std::string 或自己編寫 Buffer class 來管理緩衝區,自動記住用緩衝區的長度,並通過成員函式而不是裸指標來修改緩衝區。
-
空懸指標/野指標:用 shared_ptr/weak_ptr,這正是本章的主題。
-
重複釋放:用 unique_ptr,只在物件析構的時候釋放一次。
-
記憶體洩漏:用 unique_ptr,物件析構的時候自動釋放記憶體。
-
不配對的 new[]/delete:把 new[] 統統替換為 std::vector/scoped_array。
七、shared_ptr 技術與陷阱
-
意外延長物件的生命期:shared_ptr 是強引用(“鐵絲”綁的),只要有一個指向 x 物件的 shared_ptr 存在,該物件就不會析構。
-
函式引數:因為要修改引用計數(而且拷貝的時候通常要加鎖), shared_ptr 的拷貝開銷比拷貝原始指標要高,但是需要拷貝的時候並不多。
-
析構動作在建立時被捕獲:這意味著解構函式可以定製,虛解構函式不是必須的。
-
迴圈引用:可以用weak_ptr解決。
八、小結
-
原始指標暴露給多個執行緒往往會造成 race condition 或額外的簿記負擔。
-
統一用 shared_ptr/unique_ptr來管理物件的生命期,在多執行緒中尤其重要。
-
shared_ptr 是值語意,當心意外延長物件的生命期。例如 boost::bind 和容器都可能拷貝 shared_ptr。
-
weak_ptr 是 shared_ptr 的好搭檔,可以用作弱回撥、物件池、解決迴圈引用等。