1. 程式人生 > 其它 >C++標準庫_18.4 執行緒同步化與Concurrency(併發)問題

C++標準庫_18.4 執行緒同步化與Concurrency(併發)問題

18.4 執行緒同步化與Concurrency(併發)問題

使用多執行緒幾乎總是會伴隨“資料的併發訪問”(concurrent data access)。多個執行緒彼此毫無關係是很罕見的。執行緒有可能提供資料給其他執行緒處理,或是備妥必要的先決條件(precondition)用以啟動其他程序(process)。

這就是執行緒的棘手的原因。許多事情有可能往錯誤的方向走,或換個角度來說,許多事情也許和新手甚至經驗豐富的程式設計師的預期不同。

在討論同步化執行緒(synchronized thread)和併發資料處理的技術之前,必須先了解問題所在。然後在討論執行緒同步化技術:

  • Mutex和lock(互斥體和鎖)
  • Condition variable(條件變數)
  • Atomic(原子操作)

18.4.1 當心Concurrency(併發)

若只學一條“如何面對多執行緒”機制,那應該是:

多個執行緒併發處理相同的資料而又不曾同步化(synchronization),那麼唯一安全的情況是:所有執行緒只讀取資料。

所謂“相同的資料”,是指使用相同記憶體區(same memory location)。若不同執行緒併發處理它們手上不同的變數或物件或成員,不會有問題,因為自C++11起,每個變數都保證擁有自己的記憶體區,唯一例外是bitfield;不同的bitfield有可能共享同一塊記憶體區。因此,訪問不同的bitfield其實意味著彼此分享對同一塊資料的訪問。

位域是C/C++中常用的資料結構。 在某些情況下合理的使用位域可以節省儲存空間,提高執行效率並提高程式的可讀行。通常以下情況下會優先考慮使用位域。
1、有很多的狀態標記,需要集中儲存,比如tcp連結的狀態 2、協議棧相關的資料結構,尤其是底層通訊協議中很多情況使用一個或者幾個bit來表示某種狀態,資料長度等等。這時候就會使用到位域。

struct SNField{
unsigned char seq:7 ; // frame sequnce
unsigned char startbit:1 ; // indicate if it's starting frame 1 for yes.
};

struct STField{
    unsigned int flag_tt:20 ;        // frame sequnce
    unsigned int flag_ee:12 ;   // indicate if it's starting frame 1 for yes.
};       

這裡要注意的位域為單位的成員和其他的struct的成員,都是按照從低地址到高地址的方式分配記憶體的。

然後,當兩個或更多執行緒併發處理相同的變數或物件或成員,而且至少其中一個執行緒改動了它,而你又不曾同步後該處理動作,你就可能有了深深的麻煩。這就是C++所謂的data race。C++11標準中定義的data race是“不同執行緒中的兩個互相沖突的動作,其中至少一個動作不是atomic(不可切割的),而且無一個動作發生在另一個動作之前。” Data race總是導致不可預期的行為。

就像處於data race情況一樣,問題在於,程式碼也許經常能夠如你所願,但卻不是永遠如此。這正是程式設計時面對的最難纏的問題之一。或進入生產模式,或換到另一個平臺,或你的程式拓然就完蛋了。所以,如使用多執行緒,要特別小心concurrent data access。

18.4.2 concurrent data access為什麼造成問題

我們必須瞭解當我們使用concurrency時,C++給了什麼保證。

注意:一個程式語言如C++,總是個抽象層,用以支援不同的平臺和硬體,後者根據其體系結構和目的提供不同的能力和介面。因此,一個像C++這樣的標準具體描述了語句和操作的影響,但並非等同於其所產生的彙編碼。標準描述的是what而不是how。

事實上,行為甚至有可能不被明確定義,例如:函式呼叫時其實參核值(argument evalution)次序就沒有具體說明。
重要問題:語言給了什麼保證?

c++ - “as-if”規則究竟是什麼?究竟什麼是“假設”規則?一個典型的答案是:
允許任何和所有程式碼轉換不會改變程式的可觀察行為的規則。
事實上,關於所謂的as-if規則:每個編譯器都可以將程式碼無限優化,只要程式行為外觀上相同。因此,被生成的程式碼是個黑盒子,是可以變化的,只要可觀測行為保持穩定。以下摘自C++ standard:

任何實現(implementation)可以自由忽略國際標準的任何規定,只要最終成果貌似遵守了那些規定——這可以有程式的可觀測行為加以判斷。例如:一個現實實現(actual implementation)不需要核算表示式的某一部分——如果它可以推演而知其值未被使用且又不至於影響程式所產生的可觀測行為。

未定義行為之所以存在,是為了給予編譯器和硬體廠商自由度和能力去生成最佳程式碼,不論他們的“最佳”標準在哪裡。是的,它適用於兩端:編譯器有可能展開迴圈,重新安排語句,去除無用程式碼,預先獲取資料,而在現代體系結構中,一個硬體實現的buffer有可能重新安排(reorder)load或store。

重新安排次序(reordering)對於改善程式速度也許有幫助,但它們亦有可能產生破壞行為。為了受益於“最快的速度”,“安全性”也許不在預設考慮範圍內。因此,特別針對concurrent data access,我們必須瞭解我們手上有些什麼保證。

18.4.3 什麼情況下可能出錯

事實上,在C++中我們可能會遭遇到以下問題:

  • Unsynchronized data access(未同步化的資料訪問):並行執行的兩個執行緒讀和寫同一筆資料,不知道哪個語句先來;

  • Half-written data(寫至半途的資料):某個執行緒正在讀資料,另一個執行緒改動它,於是讀取中的執行緒甚至可能讀到改了一半的資料,讀到一個半新半舊值。

  • Reordered statement(重新安排的語句):語句和操作有可能被重新安排次序(reordered)。也許對於每一個單執行緒正確,但對於多個執行緒的組合卻破壞了預期的行為。

  • 請牢記以下文字 *

除非另 有說明,C++標準庫提供的函式通常不支援“寫或讀”動作與另一個“寫”動作(寫至同一筆資料)併發執行。

也就說,除非另有說明,來自多執行緒“對同一object的多次呼叫”會導致不可預期的行為。

然而C++標準庫對於執行緒安全還是挺了若干保證,如:

  1. 併發處理同一容器內的不同元素是可以的(vector例外)。因此,不同的執行緒可以併發讀和/或寫同一容器的不同元素。例如:每個執行緒可以處理某些事,然後將結果儲存於一個共享的vector內專屬該執行緒的元素。

  2. 併發處理string stream、file stream或stream buffer會導致不可預期的行為。但格式化輸入自和輸出至某個標準stream是可以的,雖然這可能導致插敘的字元。

load是取後面地址單元的內容,放到前面地址單元裡面去。
store是把前面地址的內容儲存到後面地址單元裡面去。
一前一後。

即使是基本資料型別如int或bool,標準也不保證讀或寫是atomic(不可切割的),意指獨佔而不可被打斷(exclusive noninterruptable)。Data race不是那麼有可能發生,但如果想完全消除其可能性,就必須採取手段

相同情況也適用於複雜的資料結構,即使由C++標準庫提供,如:std::list<>,程式設計師有權決定是否確保“當某個執行緒正在安插或刪除元素時,容器不會被另一個執行緒改動”。否則,其它執行緒便有可能用到這個list的不一致狀態。如:“前向指標”已修改但“後向指標”尚未被改。

long data;
bool readyFlag = false;
一種天真做法是,將“某執行緒中對data的設定”和“另一執行緒中對data的消費”同步化。於是,供應端這麼呼叫:
data = 42;
readyFlag = true;

而消費端這麼呼叫:
while(!readyFlag) {
;
}
foo(data);

在不知任何細節的情況下,幾乎每個程式設計師一開始都會認為第二執行緒必是在data有值42之後,才呼叫foo()。他們認為“多foo()的呼叫”只有在readyFlag是true的前提下才能觸及,而那又唯有發生在第一執行緒將42賦值給data之後,因此賦值之後才令readyFlag變成true。

但其實這並非必要。事實上,第二執行緒的輸出有可能是data“在第一執行緒賦值42之前”的舊值(甚至任何值,因為42賦值動作有可能只做了一半)【假設賦值操作兩次store】

也就是說,編譯器和/或硬體有可能重新安排語句,使得實際執行以下動作:
readyFlag = true;
data = 42;
一般而言,基於C++規則,這樣的重新安排(reordering)是允許的。因為C++只要求編譯所得的程式碼在單一執行緒內的可觀測行為(observable behavior inside a thread)正確。

對於第一執行緒,並不在意改變readyFlag還是data,從這個執行緒的角度看,兩個語句毫不相干。因此,重新安排語句是被允許的,只要單一執行緒的可視效果相同。

再強調一次,允許如此更改,原因是預設情況下C++編譯器應該有能力生成高度優化程式碼,而某些優化行為可能需要重新排列語句。預設情況下,這些優化並未被要求在意“是否存在其他執行緒”,這樣能讓優化更容易些,因為這種情況下,只需要區域性分析(local analysis)便足夠。

18.4.4 解決問題所需要的性質(Feature)

為了解決concurrent data access的三個主要問題,我們需要先建立以下概念:

  • Atomicity(不可切割性):這意味著寫或讀一個變數,或是一連串語句,其行為時獨佔的,排他的,無任何打斷,因此一個執行緒不可能讀到“因另一個執行緒而造成的”中間狀態。

  • Order(次序):我們需要一些方法保證“具體指定之語句”的次序。

C++標準庫提供了多種方法來處理這個概念,讓程式在concurrent access方面獲得額外的保證:

  1. 可以使用future和promise,它們都保證atomicity和order:一定是在形成成果(返回值或異常)之後才設定shared state,這意味著讀和寫不會併發發生

2.可使用mutex和lock來處理critical section或protected zone,藉此得以授予獨佔權利,使得(例如)一個“檢查動作”和一個“依賴該檢查結果的操作”之間不會發生任何事。Lock提供atomicity,它會阻塞(blocking)所有“使用second lock”的處理行為,直到作用於相同資源身上的first lock被釋放。更精確得說,被某個執行緒獲得的lock object,它“被另一執行緒獲得”之前必須先被成功釋放。然而,如果兩個執行緒使用lock來處理資料,每次執行的次序都有可能發生變化。

  1. 可以使用condition variable有效地令某執行緒等待若干“被另一個執行緒控制的”判斷式(predicate)成為true。這有助於應付多執行緒間的次序,允許一或多個執行緒處理其他一或多個執行緒所提供的資料或狀態。

  2. 可以使用atomic data type,確保每次對變數或物件的訪問動作都是不可切割的(atomic)——只要atomic type上的操作次序保持穩定(stable)

  3. 可以使用atomic data type的底層介面,它允許專家放寬(relex)atomic語句的次序或針對記憶體訪問使用手製藩籬(manual barrier,所謂fence)。

原則上,這份清單由高階排列到底層。高階特徵如future何promise或mutex和lock很容易使用,風險較低。

底層特性如atomic和(特別是)其底層介面,也許能夠提供較佳效能,因為它們有較低的潛在因素並因此有較高的可伸縮性(scalability),但也大幅增加了誤用的風險。儘管如此,底層特性有時候可以為某些特定的高階問題提供簡單解法。

有了atomic,我們得以進入lock-free(免鎖)程式設計,而那是專家偶爾也會出錯的領域。以下文字摘自Herb Sutter的【Sutter:LockFree】“lock-free code即使對專家都很困難。我們很容易寫出似乎可執行的lock-free code,但很難寫出不但正確而且執行良好的lock-free code。甚至優秀的雜誌和期刊都曾刊出大量lock-free code而實際上卻在微妙處失敗。”

volatile和concurrency

注意,我並沒有說volatile是個“用來解決concurrent data access問題”的性質(feature),雖然你可能因為以下原因而有那樣的期盼:

  • volatile是C++關鍵字,用來阻止“過度優化”
  • 在java中,volatile對於atomicity和order提供了某些保證

在c++中,volatile * “只” * 具體表示對外部資源(像是共享記憶體)的訪問不該被優化掉。如果沒有volatile,編譯器也許會消除對同一塊共享記憶體區看似多餘的load,只因它在整個程式中看不到這個區域的任何改變。但是在C++,volatile既不提供atomicity也不提供特別的order。因此,volatile的語義在C++和Java之間如今有些差異。

探討“當mutex被用來在一個迴圈(loop)內讀取資料”時,為什麼通常不要求使用volatile?

18.5 Mutex和Lock

Mutex全名mutual exclusion(互斥體),是個object,用來協助採取獨佔排他(exclusive)方式控制“對資源的併發訪問”。這裡所謂的“資源”可能是個object,或多個object的組合。為了獲得獨佔式的資源訪問能力,相應的執行緒必須鎖定(lock)mutex,這樣可以防止其他執行緒也鎖定mutex,直到第一個執行緒解鎖(unlock)mutex。

18.5.1 使用Mutex和Lock

有一點很重要:凡是可能發生concurrent access的地方都該使用同一個mutex,不論讀或寫皆如此。

這個簡單的辦法,有可能演變得十分複雜。舉例:你應該確保異常——它會終止獨佔——也解除(unlock)相應的mutex,否則資源就有可能被永遠鎖住。此外,也可能出現deadlock情景:兩個執行緒在釋放它們自己的lock之前,彼此等待對方的lock。

C++標準庫試圖處理這些問題(但目前仍無法從概念上根本解決)。舉例:面對異常,你不該自己lock/unlock mutex,應該使用RAII守則(Resource Acquisition Is Initialization),循此,建構函式將獲得資源,而解構函式——甚至當“異常造成生命結束”它也總是會被呼叫——則負責為我們釋放資源。為了這個目的,C++標準庫提供了class std::lock_guard,如:
int val;
std::mutext valMutex;
{
std::lock_guardstd::mutex lg(valMutext);
++val;
} // ensure that lock gets released here

注意:這樣的lock應該被限制在可能之最短週期內,因為它們會阻塞(block)其他程式碼的並行執行機會。由於解構函式會釋放這個lock,你或許會想明確安插大括號,令lock在更進一步語句被處理前先被釋放。

這只是第一個例子,但可以看出,整個主題很容易變得很繁複。一如既往,程式設計師應該知道在併發模式(concurrent mode)下他們的所有行為。此外,C++存在著不同的mutex和lock,稍後討論。

所謂將輸出同步化,就是令每次對print()的呼叫都獨佔地寫出所有字元,為此引入mutex給print使用,以及一個lock guardy用來鎖定被保護區。

lock guard的建構函式會呼叫mutex的lock(),如果資源(亦即mutex)已被取走,它會block(阻塞),直到“對保護區的訪問”再次獲得允許。然後,lock的次序仍舊不明確,因此,print的三行輸出有可能以任何次序出現。

遞迴的(Recursive)Lock

有時候,遞迴鎖定(to lock recursively)是必要的。典型例子是:active object或monitor,它們在每個public函式內放一個mutex並取得其lock,用以防止data race腐蝕物件的內部狀態。例如:一個數據庫介面可以像這樣:
每一個public都含有