1. 程式人生 > >多執行緒程式設計的8個規則

多執行緒程式設計的8個規則

最近在學習網際網路方面相關的內容,找到一篇關於多執行緒設計原則的,我覺得挺好的,跟大家一塊分享出來;

規則一:找到真正不相關的計算任務
  如果你將要執行的運算任務相互之間不獨立的話,你是不可能將它們並行化的。我可以很容易的舉出一些真實世界中相互獨立的任務如何為了達成同一個目的而工作的例子。比如說一個DVD出租店,它先把收到的求租電影的訂單分給員工們,員工再從存放電影DVD的地方根據訂單找到影片拷貝。當一個員工取出一張古典音樂喜劇的拷貝時,他並不影響另一個尋找最近科幻電影大作的員工,也不影響另一個尋找某熱門犯罪連續劇第二季花絮的員工(我假設所有不能被滿足的訂單在遞交給DVD出租店之前就已經被處理過了)。同樣的,每個訂單的打包和郵遞工作也不會影響其他訂單的查詢、運送和處理工作。


  你也可能會遇到某些不能被並行化的而只能序列執行的計算任務,它們大多數是因為迴圈之間或者計算步驟之間有依賴關係從而導致它們只能按照特定的順序序列執行。一個很好的例子是馴鹿懷孕的過程。通常馴鹿需要八個月來生小馴鹿,你不可能為了早點生個小馴鹿就讓八個馴鹿來一起來生,想一個月就生出一個來。但是,如果聖誕老人希望儘快的擴充雪橇隊伍,他可以讓八隻馴鹿一起生,這樣八個月後就能有八隻小馴鹿了(注:可以理解為儘管單個任務的執行時間沒縮短,但是吞吐量卻大了)。

規則二:儘可能地在最高層進行並行化
  在對一段序列程式碼進行並行化時我們有兩種方法可以選擇,一個是自底向上,另一個是自頂向下。在對我們的程式碼進行分析的過程中,我們先找到花費了最多執行時間的程式熱點(hotspots)。對這些程式碼段進行並行化是使我們獲得最大的效能提升的最好辦法。

  在自底向上的方法中,你可以考慮先直接對那些程式熱點進行並行化。如果這不太可能實現的話,我們可以順著它的呼叫棧(call stack)向上查詢,看看能不能找到其他的可以並行化的程式熱點。假如你的程式熱點在一個巢狀迴圈的最裡層,我們可以從內向外的逐一檢查每一層迴圈,看看某一層是否能被並行執行。即使我們能一開始就很順利的把程式熱點並行化了,我們仍然應該去檢查一下是否可能在呼叫棧中更高的某一層上實現並行化。這樣做能提高每個執行緒所執行的任務的粒度。(注:每個執行緒所執行的任務的粒度可以理解為成功並行化了的部分在整個程式中所佔的比例,根據Amdahl定律,並行化的部分越多,程式的整體效能越高)
  為了更清楚的描述這條規則,讓我們舉一個對視訊編碼程式進行並行化的例子。如果你的程式熱點是針對每個畫素的計算,你可以先找到對一幀視訊中的每個畫素進行計算的迴圈,並考慮對它進行並行化。以此為基礎向“上”找,你可能會發現對每一幀進行處理的迴圈也是可以被並行化的,這意味著每個執行緒都可以以幀為單位對一組資料進行獨立的處理。如果這個視訊編碼程式同時要對好幾個視訊進行處理,那麼讓每個執行緒單獨處理一個視訊流將會是最高層的並行化。

  在另一種自頂向下的並行化方法中,我們可以先對整個程式以及計算的流程(為了完成計算任務而依序組合起來的各個程式模組)進行分析。如果並行化的機會不是很明顯,我們可以挑出那些包含了程式熱點的模組並對他們進行分析,如果不行就再分析更小的程式熱點模組,直到能找到獨立的計算任務為止。
  對視訊編碼程式的例子來說,如果你的程式熱點是針對單個畫素的計算,採用自頂向下的方法時就可以首先考慮該程式對多個不同的視訊流進行編碼的情況(每個編碼任務都包含了畫素計算的任務)。如果你能在這一層成功進行並行化,那麼你已經得到了最高層的並行。如果沒能成功,那我們可以向“下”找,看看每個視訊流的不同幀的計算是否能被並行處理,最後看看每個幀的不同畫素的計算是否能被並行處理。
  並行任務的粒度可以理解成在進行同步之前所需要完成的計算量。同步之間執行的時間越長,粒度越大。細粒度的並行存在的隱患就是給每個執行緒分配的任務可能不夠多,以至於都不夠彌補使用多執行緒所帶來的開銷。此時,在計算量不變的情況下使用更多的執行緒只會讓情況變得更加糟糕。粗粒度的並行化擁有相對來說更少的執行緒開銷,並且更可能線上程增多的情況下仍然有很好的可擴充套件性。儘可能的在最高層對程式熱點實現並行化是實現對多執行緒的粗粒度任務劃分的主要方法之一。

規則三:儘早針對眾核趨勢做好可伸縮性的規劃
  當我寫這本書的時候,四核處理器已經成為了主流。未來處理器的核心數量只會越來越多。所以你應該在你的軟體中為這個發展趨勢做好規劃。可伸縮性(scalability)被用來用來衡量一個程式應對變化的能力,典型的變化有系統資源(例如核心數量,記憶體大小,匯流排速度)或資料集大小的增加等。在面對越來越多的可用核心時,你必須寫出能靈活高效的利用不同數量的核心的程式碼。
  C. Northcote Parkinson說過,“資料的增長是為了適應處理能力的增加”。這意味著隨著計算能力的增長加(核心數量的增加),很有可能我們會有更多的資料需要處理。我們永遠會有更多的計算任務需要完成。不管是增加科學模擬中的建模精度,還是處理更清晰的高清視訊,又或者搜尋許多更大的資料庫,如果你擁有了更多的計算資源,總會有人想要處理更多的資料。
  用資料分解(data decomposition)的方法來設計和實現並行化能給你提供更多的高可擴充套件性的解決方案。任務分解(task decomposition)的方法可能會面臨程式中可獨立執行的函式或者程式碼段數量有限或者數量固定的問題。等到每一個獨立的任務已經在單獨的執行緒和核心上執行的時候,再想通過增加執行緒的數量來利用空閒的多餘核心的方法就提高不了程式的效能了。因為在一個程式中資料的大小比獨立的計算任務的數量更有可能增加,所以基於資料分解的設計將更有可能獲得很好的可伸縮性。
  即使有的程式已經基於任務分解的模式給每個執行緒分配了不同的計算任務,我們仍然可以在需要處理的資料增加的時候利用更多的執行緒來完成工作。例如我們需要修建一個雜貨店,這項工程由一些不同的任務組成。如果開發商又買了一塊相鄰的地皮,並且商店要蓋的樓層數翻倍了,我們可以僱傭更多的工人去完成這些的任務,比如說更多的油漆工,更多的蓋頂工,更多的電工。因此,我們應該注意是否能對增加了的資料進行資料分解,以便利用空閒核心上的可用執行緒來完成這個工作,哪怕是在我們已經採用了任務分解的方式的程式中。

規則四:儘可能利用已有的執行緒安全庫
  如果你的程式熱點的計算任務能通過庫函式呼叫來完成,強烈建議你考慮使用同等功能的庫函式,而不是呼叫自己手寫的程式碼。即使是序列程式,“重新造輪子”來完成已經被高度優化的庫函式實現了的功能仍不是一個好主意。許多的庫,例如Intel Math Kernel Library(Intel MKL)和Intel Integrated Performance Primitives (Intel IPP),提供了能更好的利用多核處理器的並行版本的函式。
  比使用並行版本的函式庫更重要的一點是:我們需要確保所有的庫函式呼叫都是執行緒安全的(thread-safe)。如果你已經把你序列程式碼中的程式熱點替換成了一個庫函式呼叫,你仍有可能在呼叫樹(call tree)的更高層上發現能把程式分解成獨立的計算任務的程式碼段。當你有好幾個並行的計算任務,並且它們都同時呼叫了庫函式(特別是第三方函式庫),那麼函式庫中引用並更新共享變數的函式可能會造成資料競爭(data race)。記得好好檢查你在並行程式設計中所呼叫的函式庫的文件中關於執行緒安全性的描述。當你在設計和編寫自己的用於並行執行的函式庫時,請務必確保函式是可重入(reentrant)的。如果不能確保的話,你應該給共享的資源加上同步機制。

規則五:使用合適的多執行緒模型
  如果並行版的函式庫不足以完成程式的並行化,而你又想使用可以自己控制的執行緒,在隱式的多執行緒模型能滿足你的功能需求的前提下請儘量使用該模型(例如OpenMP或者Intel Thread Building Block)而不是顯式的多執行緒模型(例如Pthread)。顯式的多執行緒模型確實能提供對執行緒的更精確的控制。但是,如果你僅僅是想把你的計算密集型迴圈給並行化,或者你不需要顯式多執行緒模型提供的諸多特性,那麼我們最好還是能滿足需要就好。實現的複雜度越高,犯錯誤的機率就越大,以後程式碼的維護難度也會越大。
  OpenMP採用的是資料分解的方法,它尤其適合並行化那些需要處理大量資料的迴圈。儘管這種型別的並行化可能是唯一一種你能引入的並行模式,但是可能還會有其他的要求(例如由你的僱主或者管理層所決定的工程方案)讓你不能使用OpenMP。如果是那樣的話,我建議你先使用OpenMP來快速開發出並行化後的模型,估算一下可能的效能提升、可擴充套件性以及大概需要多少時間才能把這些序列程式碼用顯式多執行緒庫給並行化。

規則六:永遠不要假設具體的執行順序
  在序列程式中我們可以非常容易地預測某個程式的當前狀態結束之後它會變成什麼狀態。然而,多個執行緒的執行順序卻是不確定的,它是由作業系統的排程器(scheduler)決定的。這意味著我們不可能準確的預測兩個執行狀態之間多個執行緒的執行順序,甚至連預測哪個執行緒會在下一步被排程執行也不能。這樣的機制主要是為了隱藏程式執行時的延遲,特別是當執行的執行緒的數量多於核心的數量時。例如,如果一個執行緒因為要訪問不在cache中的地址,或者需要處理一個I/O請求而被阻塞了(blocked),那麼作業系統的排程器就會把該執行緒排程到等待佇列裡,同時把另一個等待執行的執行緒排程進來並執行它。
  資料競爭(data race)就是由這種排程的不確定性造成的。如果你假設一個執行緒對共享變數的寫操作會在另一個執行緒對該共享變數的讀操作之前完成,你的預測可能會一直正確,有可能有些時候會正確,也有可能從來都不會正確。如果你足夠幸運的話,有時候在一個特定平臺上每次你執行這個程式時執行緒的執行順序都不會改變。但是系統間的每個不同(例如資料在磁碟上儲存的位置,記憶體的速度或者插座中的交流電源)都有可能影響執行緒的排程。對一段需要特定的執行緒執行順序的程式碼來說,如果僅僅依靠樂觀的估計而不採取任何實質性的措施的話,很有可能會受到資料競爭,死鎖等問題的困擾。
  從效能的角度來講,最好的情形當然是讓所有的執行緒儘可能沒有約束的執行,就像比賽中的賽馬或獵犬的一樣。除非必要的話,儘可能不要規定一個特定的執行順序。你需要找到那些確實需要規定執行順序的地方,並且實現一些必要的同步方法來調整執行緒間的執行順序。
  拿接力賽跑來說,第一棒的選手會竭盡全力的奔跑。但是為了成功的完成接力賽,第二個,第三個和最後一棒都需要先等到拿到接力棒之後才能開始跑他們的賽段。接力棒的交接就是他們的同步機制,這樣就確保了接力過程中的“執行”順序。

規則七:儘可能使用執行緒本地儲存或者對特定的資料加鎖
  同步(Synchronization)本身並不屬於計算任務,它只是為了確保程式的並行執行能得到正確的結果所產生的額外開銷。雖然它產生了額外的開銷但是又不可或缺。因此我們還是要儘可能的把同步所產生的開銷降低到最低。你可以使用執行緒私有的儲存空間或者獨佔的記憶體地址(例如一個用執行緒ID來進行索引的陣列)來達到這個目的。
  那些很少需要線上程間共享的臨時變數可以被每個執行緒單獨地在本地進行宣告或分配。那些儲存著每個執行緒的部分結果(partial result)的變數也應該是執行緒私有的。但是在把每個執行緒的部分結果儲存到一個共享的變數的時候就需要採取一些適當的同步措施了。如果我們能確保這樣的共享更新操作能儘可能少的進行,我們就可以把同步的額外開銷降到最低了。如果我們使用顯式的執行緒程式設計模型的話,我們可以使用那些執行緒本地儲存(Thread Local Storage)的API來保證執行緒私有變數在多個並行區域的執行過程中,或者在一個並行函式的多次呼叫的過程中的一致性。
  如果執行緒本地儲存不可行,而且你必須用同步的物件(例如鎖)來協調對共享資源的訪問的話,請確保對資料進行了適當的鎖操作。最簡單的方法就是對鎖和資料物件採取一一對應的分配策略。如果對變數的記憶體地址的訪問都是在同一個臨界區進行的話,我們就可以使用一把鎖來對多個數據進行保護。
  如果你有大量的資料需要保護,例如由一萬個資料的陣列,我們該怎麼辦呢?如果我們對整個陣列只用一個鎖來進行保護的話,很可能會造成嚴重的鎖競爭從而導致效能瓶頸。那麼我們是不是可以給每個陣列元素建立一個鎖呢?然而即使是有32個或者64個執行緒在同時訪問這個陣列,這樣做看起來也浪費了很多的記憶體空間來保護那些只有百分之一不到的發生概率的訪問衝突。不過有一種折中的解決方案,叫做“取模鎖”(modulo lock)。取模鎖是用來保護資料集合中的所有的第N個元素,其中N是鎖的數量。例如,有兩個鎖,一個保護所有的奇數個的元素,另一個保護所有的偶數個元素。當需要訪問一個被保護的變數時,執行緒需要先對要訪問的地址進行取模操作,然後再去獲得對應的取模鎖。使用的鎖的數量應該是基於執行緒的數量以及兩個執行緒同時訪問相同元素的可能性來決定。
  但是,當你決定用鎖來對資料進行保護時,請一定不要用多於一個的鎖來給一個單獨的元素進行加鎖。西格爾定律告訴我們“一個人看著一個表能知道現在幾點了,但是他要是有兩個表那麼他就確定不了時間了”。如果兩個不同的鎖都對同一個變數進行了保護,那麼可能出現程式碼中的某一部分通過第一個鎖來進行訪問的同時,程式碼中的另一部分通過第二個鎖也進行了訪問。正在執行這兩個程式碼段的多個執行緒就可能發生資料競爭,因為它們都以為它們對這個被保護的變數有獨佔的訪問許可權。

規則八:敢於更換更易並行化的演算法
  當比較序列或者並行程式的效能的時候,執行時間就是衡量的首要標準。程式設計師會根據演算法的時間複雜度來進行選擇。時間複雜度和一個程式的效能是息息相關的。它的含義就是,當其他的一切條件都一樣時,完成同樣功能的時間複雜度為O(NlogN)的演算法(例如快速排序)要比O(n^2)的演算法(例如選擇排序)要快。
  在並行程式中,擁有更好的時間複雜度的演算法也會更快一些。然而,有些時候時間複雜度更好的演算法卻不是很容易被並行化。如果演算法的熱點不太容易被並行化的話(而且在呼叫棧的更高層中你又找不到能很容易被並行化的熱點),那麼你可以嘗試換一個稍微慢一點但是卻更容易被並行化的演算法。當然,還有可能一些其他的改動措施也能讓你比較輕鬆的把某一段程式碼給並行化了。
  這裡我們可以給出一個線性代數中兩個矩陣相乘的例子。Strassen的演算法擁有最好的時間複雜度:O(n^2.81)。這當然比傳統的三重迴圈的O(n^3)的演算法要好。Strassen的演算法把每個分成四部分,然後進行七次遞迴呼叫來對n/2 x n/2的子矩陣進行乘運算。如果想把這七次遞迴呼叫並行化的話,我們可以在每次的遞迴呼叫的時候建立一個新執行緒來進行運算,直到子矩陣到達一個預設的大小為止。這樣的話執行緒的數量就會指數級的成倍增長。隨著子矩陣越來越小,給新建立的執行緒分配的計算任務就會越來越少。還有另一種方法,就是先建立一個有七個執行緒的執行緒池。七次子矩陣相乘的運算任務可以分別分配給這七個執行緒以完成並行化。這樣的話執行緒池就會跟序列版本的程式一樣遞迴呼叫Strassen演算法來對子矩陣進行乘運算。然而,這種方法的缺點就在於對一個擁有大於八個核的系統來說,永遠只有七個核在工作,其他的資源都被浪費了。
  另一個更容易被並行化的矩陣乘法就是三重迴圈的演算法了。我們可以有很多方法來對矩陣進行資料分解(按行分解,按列分解或者按塊分解)然後再把它們分配給不同的執行緒。通過用OpenMP在某一層迴圈中加上編譯指示,或者用顯式執行緒模型實現矩陣分割,我們很容易的就能完成並行化。只需要更少的程式碼改動就可以對這個簡單的序列演算法完成並行化,並且程式碼的整體結構改動也會比Strassen演算法要少很多。

總結
  我們已經列出了八條簡單的規則,在把序列程式並行化的過程中你應該時刻記住它們。通過遵循這些規則以及一些實際的程式設計規則,你應該可以更容易的創造更健壯的並行化解決方案,同時能包含更少的並行化時的問題,以及在更短的時間裡得到最好的效能。