shared_ptr和多線程


前一篇文章寫得實在太挫,重新來一篇。

多線程環境下生命周期的管理

多線程環境下,跨線程對象的生命周期管理會有什么挑戰?我們拿生產者消費者模型來討論這個問題。

實現一個簡單的用於生產者消費者模型的隊列

生產者消費者模型的基本結構如下圖所示:

如果我們要實現這個隊列該怎么寫?首先我們先簡單挖掘下這個隊列的一些基本需求。

顯而易見,這個隊列需要支持多線程並發讀寫。

我們知道,多線程並發讀寫同一個對象,需要對讀寫操作進行同步以避免data race[1]。在C++11里,我們可以借助mutex。

另外當隊列為空時,消費者來讀取數據時,期望的結果應該是消費者線程被掛起,而不是不停地進行重試看隊列是否非空。當生產者插入數據后,喚醒消費者,數據已經生成了。這個喚醒的機制可以通過條件變量來實現,condition_variable。

在分析基本的需求和了解了相關的技術支持后,我們可以着手設計這個隊列的基本接口了。它應該至少包含下面三個對外接口:

  • push
  • pop
  • size

我們也可以考慮基於模板的方式來實現這個類。因此,程序看起來會是這樣:

template <typename T, typename CONTAINER_TYPE = std::queue<T>>
class blocking_queue
{
public:
    blocking_queue();
    ~blocking_queue();

    void push(const T&);
    T pop();
    size_t size() const;

private:
    std::mutex mtx_;
    std::condition_variable cv_;
    CONTAINER_TYPE queue_;

    blocking_queue(const blocking_queue&) = delete;
    blocking_queue& operator =(const blocking_queue&) = delete;
};

這里我特意屏蔽了拷貝構造和賦值操作。咱的這個隊列從語義上不應該支持copy這件事。我們接下來看如何實現其中最主要的push和pop函數。

push操作相對簡單些,使用mtx_進行操作同步,然后插入數據。數據插入后進行通知。

void push(const T& element)
{
    {
        std::lock_guard<std::mutex> lock(mtx_);
        queue_.push(element);
    }
    cv_.notify_once();
}

pop函數會稍微復雜點。

T pop()
{
    std::unique_lock lock(mtx_);
    while (0 == queue_.size())
    {
        cv_.wait(lock);
    }

    T ret = queue_.front();
    queue_.pop();

    return ret;
}

另外,condition_variable::wait有兩個重載函數,這里的while循環還可以寫成:

cv_.wait(lock, [this]() -> bool { return queue_.size() > 0; }

這里我們岔開下話題稍微多說一下pop函數。主要是pop函數中的那個while。

while loop associated with the condition variable

條件變量的應用中,這個while語句已經是一個標配了。有人說條件變量的使用是最不容易出錯的,因為正確的使用方式就這么一種,必須得配while。

那為什么一定要用while呢?

所有的官方解釋(POSIXMSDNWiki)都集中到了一個名詞:spurious wakeup。但是具體是什么導致的spurious wakeup,都沒有挑明。我第一次看到這個while的時候,當時分析的結果是,這個過程存在競態。

我們假設有兩個消費者(C1、C2)一個生產者(P)。並且此時隊列已空。接下來:

  1. C1執行pop,因為隊列為空,所以線程在cv_.wait處掛起
  2. P開始執行push,進入臨近區並且還未退出
  3. C2執行pop。因為P還沒有退出臨近區,所以C2在進入臨界區處掛起
  4. P插入數據后,退出臨界區並通知cv_
  5. C2先被喚醒,進入臨近區(可能性很大,因為push操作先退出臨近區,再通知cv_)
  6. 此時C1無法從cv_.wait中退出,因為無法成功鎖住mtx_
  7. C2消耗了P插入的數據,並從臨界區中退出
  8. C1從cv_.wait中返回
  9. 此時,隊列中已無數據

從這個角度分析同樣需要條件判斷為循環形式。當然,也不止我一個人這么認為

多線程共享對象生命周期管理的挑戰

我們假定生產者對應的實現類叫做producer,消費者類叫consumer。那么producer和consumer類都應該有一個指向blocking_queue的指針(或者引用),知道該往哪讀寫數據。

接下來就有幾個問題需要我們考慮了:

  1. producer、consumer和blocking_queue之間是什么關系?
  2. producer、consumer中的blocking_queue指針是raw指針么?

我們先來思考第一問題。可以確定的一點是,blocking_queue不會同時被producer和consumer管理整個生命周期,這樣沒法管。同時producer和consumer並不需要知道對方的存在。所以勢必有一方和blocking_queue之間是關聯關系。我們就假定producer和blocking_queue之間是關聯關系。

再來思考第二個問題。簡單起見,先假定producer保存的是指向blocking_queue的指針,類型為blocking_queue *。

現在我們回到多線程環境里來思考producer對象的處境。

多個producer線程寫一個共享的blocking_queue對象。producer通過blocking_queue *指針如何知道這個blocking_queue對象是有效的?這個問題產生的本源就是這兩者之間是關聯關系,相互之間的耦合並不十分強烈。blocking_queue對象的創建和銷毀對於producer來說都是透明的。這個問題也可以簡單歸結為通過一個指針,如何判斷指向的內存是否有效?

很不幸,這個問題在C/C++里是無解的(這里誇大了,事實上應該是可以使用二級指針來解決這個問題的)。這種有效性無法通過if語句判斷。指針非空並不意味着指向的內存塊保存的是有效的對象。既然如此,我們就需要使用新的解決方案。

既然指針不行,那我們是不是可以實現一個對象管理類,專門用於管理blocking_queue對象,並且提供一個queue_is_valid()成員函數來判斷blocking_queue對象的合法性。要實現這個方案,必須保該這個對象的生命周期比blocking_queue長。我們暫且把這個類稱為manager。通過manager來管理這個blocking_queue對象指針的生命周期。

那么,producer就需要有一份manager對象的拷貝(why? 如果是指針,問題是不是又回來了?)。既然如此,那么有多少個producer對象,就有多少個manager對象的拷貝。所以就引入了新的問題,這些manager拷貝如何共享同一個blocking_queue指針的相關信息?當其中一個manager對象釋放了這個blocking_queue,其他manager對象如何知道呢?

如何做好信息的同步是解決這個問題的手段。從這個角度出發,我們希望看到的情況應該是,當有人在用它,那么它就應該是活的;如果已經沒有人用它了,那么它就沒有必要存在了。類似於GC。所有人都不使用的東西,肯定是垃圾了。那么比較自然的解決方案就是引用計數。

這就是C++11中引入的shared_ptr。

我們用shared_ptr管理blocking_queue對象,並且將該shared_ptr對象保存到每一個producer對象中。多線程共享對象的生命周期問題完美解決。producer類看起來可能是這樣的:

class producer
{
public:
    // constructor & destructor
// other public interfaces
private:
    std::shared_ptr<blocking_queue> product_queue_;
    // other stuff
    …
};

等等,這里應該還有個問題。之前我們明明說好了producer不參與blocking_queue對象的生命周期管理。但是現在來看,似乎producer會對blocking_queue對象的生命周期產生非常大的影響。即便某一時刻我們認為blocking_queue對象需要被終結,但是因為producer對象的存在,這個blocking_queue始終無法被銷毀。

shared_ptr帶來的新問題

通過剛才的分析我們已經知道shared_ptr如何幫助我們解決線程共享對象的生命周期管理問題。但是問題解決的同時也引入了副作用,刻意延長了對象的生命周期。按照之前我的設計想法,顯然在這里出現了一些出入。這里,我們更期望的結果是,如果這個隊列對象還活着,那么producer可以向隊列插入數據,如果隊列已經死亡,那么producer啥事都不做。簡單地說,就是shared_ptr提供了除檢測對象有效性的功能外,還提供了生命周期的管理功能(生命周期的管理使得有效性的判斷變得比較隱含)。但我們僅需要有效性的判斷即可。

這需要借助weak_ptr。

使用weak_ptr檢測對象的有效性

weak_ptr如何檢查對象的有效性?

作為和shared_ptr一起被引入的智能指針,weak_ptr和shared_ptr可以說是一對搭檔。shared_ptr專職提供生命周期管理,weak_ptr專職提供對象有效性判斷。

weak_ptr的接口等基本信息和用法可以參考這里

從weak_ptr的構造函數可以知道,weak_ptr需要借力shared_ptr。它需要和一個shared_ptr對象關聯,檢測這個shared_ptr管理的對象是否還存活。

對象有效性的檢測可以通過weak_ptr::expired或者weak_ptr::lock的返回值來看。一般來說,使用lock的情況更普遍,因為對象有效,我們常常需要更進一步的操作。lock可以直接返回給我們一個shared_ptr對象。通過判斷這個shared_ptr對象我們可以知道被管理的內存對象是否還存在。

那么shared_ptr和weak_ptr該如何配合使用,這其中的基本原則是怎樣的呢?

一般來說,父對象持有子對象的shared_ptr,子對象持有父對象的weak_ptr(Wiki)。

this指針的跨線程傳遞

我們吧問題再說得廣一點。前面說到的都是普通的指針,在C++里還有一個特殊的指針this。如果我們要將this跨線程傳遞怎么辦?根據前面的分析,我們已經知道raw指針的跨線程傳遞是非常危險的。除此以外,this指針的跨線程傳遞還有跟多要考慮的東西。

構造函數中,能否將this指針傳遞出去?

不可以!因為對象還沒有創建完成!你無法預知其他線程中的對象會在什么樣的情況下使用這個this指針。

既然不能傳遞this指針,那么我們就需要將this指針shared_ptr化。但是直接shared_ptr(this)又是不對的。舉個例子:

class example;
int main()
{
    example *e = new example;
    std::shared_ptr<example> sp1(e);
    std::shared_ptr<example> sp2(e);

    return 0;
}

sp1和sp2雖然都指向e,但是他們相互之間並不知道對方。如果要讓shared_ptr相互了解對方,那么除了第一個shared_ptr對象是從raw指針創建除來的之外,其他shared_ptr都必須是從和這個shared_ptr對象相關的shared_ptr或者weak_ptr創建出來的。這其中的本質原因就是他們使用的不是同一份引用計數對象。

shared_ptr(this),遇到的問題是一樣的。

如果確定要將this指針能夠跨線程傳遞,那么必須(以example為例):

  1. example對象必須是一個在堆上的對象
  2. example對象被shared_ptr管理
  3. example類必須繼承std::enable_shared_from_this
  4. 使用enable_shard_from_this::shared_from_this將this指針傳遞到其他線程中的對象

== 完 ==


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM