1. 程式人生 > >【muduo】執行緒安全的物件生命期管理

【muduo】執行緒安全的物件生命期管理

文章目錄

一、當解構函式遇到多執行緒

  當一個物件能被多個執行緒同時看到時,那麼物件的銷燬時機就會變得模糊不清,可能出現多種競態條件(race condition):

  • 在即將析構一個物件時,從何而知此刻是否有別的執行緒正在執行該物件的成員函式

  • 如何保證在執行成員函式期間物件不會在另一個執行緒被析構

  • 在呼叫某個物件的成員函式之前,如何得知這個物件還活著?它的解構函式會不會碰巧執行到一半?

  解決這些 race condition 是 C++ 多執行緒程式設計面臨的基本問題。本文試圖以shared_ptr 一勞永逸地解決這些問題。

執行緒安全的定義】:

  一個執行緒安全的 class 應當滿足以下三個條件:

  • 多個執行緒同時訪問時,其表現出正確的行為

  • 無論作業系統如何排程這些執行緒, 無論這些執行緒的執行順序如何交織

    (interleaving)。

  • 呼叫端程式碼無須額外的同步或其他協調動作

二、物件的建立很簡單

  物件構造要做到執行緒安全,唯一的要求是在構造期間不要洩露 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 的好搭檔,可以用作弱回撥、物件池、解決迴圈引用等。