本文參考自陳碩《LinuxC++多線程服務端編程 使用muduo C++網絡庫》
C++中實現線程安全的一個類是很困難的,在某種意義上甚至是不可能的。
[JCP]中線程安全的定義
- 多個線程同時訪問,表現出正確的行為
- 無論操作系統如何調度線程,無論線程執行順序如何交織,表現出正確的行為
- 調用端代碼無需任何額外的同步或協調操作
根據這個定義,C++ STL中的類基本都是線程不安全的。
對象的線程安全大致可以分為三部分:
- 安全的創建
- 安全的調用
- 安全的銷毀。
安全的創建
對象構造要做到線程安全很簡單,唯一要求是構造時不要泄露this指針,即:
- 不要在構造中注冊任何回調。
- 不要在構造中把this傳給其他線程的對象。
- 即便在構造函數的最后一行也不行。
構造函數執行期間對象沒有完成初始化,this指針被泄露給其他對象可能會導致問題。
第三條要求意思是該類可能是一個基類,基類執行完成后可能還需要執行子類的構造,對象仍在構造中。
安全的調用
這個比較簡單,使用mutex加鎖即可。注意避免死鎖。
死鎖的常見情況
-
線程1和2均需要獲取AB兩個鎖,但是線程1獲取了A,線程2獲取了B,於是死鎖。
例如:一個類內部使用mutex保護數據來實現線程安全調用。同時讀寫這個類的兩個對象就有可能死鎖,比如線程1 a=b,線程2 b=a,這樣就可能造成死鎖。
解決方法:嘗試加鎖,失敗后全部釋放然后再次嘗試。或者加鎖時按照某個固定順序加鎖,比如先加鎖地址較小的那個。
-
鎖重入,一個鎖被lock兩次。
例如:類A內部使用mutex保護數據,A::fun1()和A::fun2()都使用了mutex,如果func1中調用了func2,就會發生鎖重入。
一種更加隱蔽的重入:A支持指定一個回調函數,然后A::fun1內部會調用這個回調。然而指定的回調中調用了A::func2(),重入便發生了。
安全的銷毀
這是C++中最困難的一點。如果一個對象能夠被多個線程同時訪問,那么對象的銷毀時機將會變得模糊不清,出現多種競態條件:
- 析構一個對象時,如何得知其他線程是否正在訪問這個對象?
- 調用一個對象時,如何判斷這個對象是否存活?
- 析構函數執行到一半,其他進程調用了這個對象,會發生什么情況?
類內部mutex無法解決問題1和問題2。在單線程中,可以通過在析構對象后設置指針為nullpr來標記這個對象已經被銷毀。多線程中需要增加一個類外的mutex來處理。但是這就比較麻煩了。
問題3體現出了一個常常被忽略的隱含條件:使用類內部mutex進行數據保護時,這個mutex必須時有效的。一般情況下這個條件是滿足的,但是當涉及析構函數時就會發生一個問題,析構函數可能會把這個mutex給銷毀,mutex被銷毀后其他線程准備獲取該鎖時會發生什么情況就只有天知道了。
類內部的mutex只能保證安全的調用,不能保證安全的析構,特別的,當調用到基類的析構時,子類的析構已經執行完成,基類擁有的mutex自然無法保護
必須要在所有線程都不訪問這個對象時,才能安全的銷毀一個對象。但是在C++中實現這一點很困難,我們必須手動銷毀對象,卻沒法通過指針判斷這個對象是否有人需要使用。自動垃圾回收在多線程編程中會起到重要作用,可以極大的減輕程序員的心智負擔,比如java就可以很容易的處理1和2。所有人都用不到的東西一定是垃圾,這正式自動垃圾回收的原理。
使用智能指針來處理對象銷毀問題
C++11中引入的智能指針可以很好的處理這個問題,主要的兩個類是:shared_ptr和weak_ptr.
判斷一個指針是否合法沒有任何高效的方法,這是一切C++指針問題的根源。
智能指針解決了這個問題。智能指針作為一個中間層,將調用方和被調用方隔離開來,shared_ptr采用了引用計數的方式判斷指針是否合法,每當增加一個調用方,計數加一,當調用方使用完畢后(比如調用方死亡)引用技術減一,當引用計數為0,就意味者已經沒有人需要這個對象,對象就可以被銷毀了。
智能指針的線程安全
智能指針是值語義,一般用作棧上對象,正常情況不會有shared_ptr* sp = new shared_ptr()
這樣的用法。所以它不會有銷毀時的前兩個問題
智能指針的引用計數是線程安全的,在主流平台上都是原子操作,沒有用鎖,性能優異。
如果要從多個線程讀寫同一個shared_ptr對象(析構也算寫操作),那么需要加鎖。
智能指針不負責保護其管理的對象的線程安全,被管理的對象的線程安全性不會因為通過智能指針調用而發生變化。
智能指針也會帶來一些其他問題,詳見智能指針一節。