前一篇文章寫得實在太挫,重新來一篇。
多線程環境下生命周期的管理
多線程環境下,跨線程對象的生命周期管理會有什么挑戰?我們拿生產者消費者模型來討論這個問題。
實現一個簡單的用於生產者消費者模型的隊列
生產者消費者模型的基本結構如下圖所示:
如果我們要實現這個隊列該怎么寫?首先我們先簡單挖掘下這個隊列的一些基本需求。
顯而易見,這個隊列需要支持多線程並發讀寫。
我們知道,多線程並發讀寫同一個對象,需要對讀寫操作進行同步以避免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呢?
所有的官方解釋(POSIX,MSDN,Wiki)都集中到了一個名詞:spurious wakeup。但是具體是什么導致的spurious wakeup,都沒有挑明。我第一次看到這個while的時候,當時分析的結果是,這個過程存在競態。
我們假設有兩個消費者(C1、C2)一個生產者(P)。並且此時隊列已空。接下來:
- C1執行pop,因為隊列為空,所以線程在cv_.wait處掛起
- P開始執行push,進入臨近區並且還未退出
- C2執行pop。因為P還沒有退出臨近區,所以C2在進入臨界區處掛起
- P插入數據后,退出臨界區並通知cv_
- C2先被喚醒,進入臨近區(可能性很大,因為push操作先退出臨近區,再通知cv_)
- 此時C1無法從cv_.wait中退出,因為無法成功鎖住mtx_
- C2消耗了P插入的數據,並從臨界區中退出
- C1從cv_.wait中返回
- 此時,隊列中已無數據
從這個角度分析同樣需要條件判斷為循環形式。當然,也不止我一個人這么認為。
多線程共享對象生命周期管理的挑戰
我們假定生產者對應的實現類叫做producer,消費者類叫consumer。那么producer和consumer類都應該有一個指向blocking_queue的指針(或者引用),知道該往哪讀寫數據。
接下來就有幾個問題需要我們考慮了:
- producer、consumer和blocking_queue之間是什么關系?
- 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為例):
- example對象必須是一個在堆上的對象
- example對象被shared_ptr管理
- example類必須繼承std::enable_shared_from_this
- 使用enable_shard_from_this::shared_from_this將this指針傳遞到其他線程中的對象
== 完 ==