多線程程序設計的8個規則

  在Intel,並行化技術主要有四個步驟:分析,設計與實現,調試以及性能調優。這些步驟用來對一段串行代碼進行並行化。儘管這四個步驟中的第一、三、四步都已經有了很多相關文檔,但是關於怎樣進行設計與實現的卻不多。
  並行編程更像是一門藝術,而不是一門科學。這裏將會給出八條設計多線程程序的簡單規則,你可以把他們一一放進你的多線程程序設計百寶箱中。通過參考這些規則,你能寫出高質量、高效率的多線程程序。我努力試着將這些規則按照(半)時間順序組織起來,但是它們之間並沒有硬性的先後順序。就像“別在泳池邊奔跑”和“別在淺水區跳水”一樣,兩個都是好主意,但是後者也能放在前者的前面,反之亦然。
 
規則一:找到真正不相關的計算任務
  如果你將要執行的運算任務相互之間不獨立的話,你是不可能將它們並行化的。我可以很容易的舉出一些真實世界中相互獨立的任務如何爲了達成同一個目的而工作的例子。比如說一個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算法要少很多。
 
總結
  我們已經列出了八條簡單的規則,在把串行程序並行化的過程中你應該時刻記住它們。通過遵循這些規則以及一些實際的編程規則,你應該可以更容易的創造更健壯的並行化解決方案,同時能包含更少的並行化時的問題,以及在更短的時間裏得到最好的性能。

發佈了51 篇原創文章 · 獲贊 7 · 訪問量 40萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章