1. 程式人生 > >多執行緒優化思路(轉載的)

多執行緒優化思路(轉載的)

樣例程式

程式功能:求從1一直到 APPLE_MAX_VALUE (100000000) 相加累計的和,並賦值給 apple 的a 和b ;求 orange 資料結構中的 a[i]+b[i ] 的和,迴圈 ORANGE_MAX_VALUE(1000000) 次。

說明:

  1. 由於樣例程式是從實際應用中抽象出來的模型,所以本文不會進行 test.a=test.b= test.b+sum 、中間變數(查詢表)等類似的優化。
  2. 以下所有程式片斷均為部分程式碼,完整程式碼請參看本文最下面的附件。

清單 1. 樣例程式
  1. #define ORANGE_MAX_VALUE      1000000
  2. #define APPLE_MAX_VALUE       100000000
  3. #define MSECOND               1000000
  4. struct apple  
  5. {  
  6.      unsigned longlong a;  
  7.     unsigned longlong b;  
  8. };  
  9. struct orange  
  10. {  
  11.     int a[ORANGE_MAX_VALUE];  
  12.     int b[ORANGE_MAX_VALUE];  
  13. };  
  14. int main (int argc, constchar * argv[]) {  
  15.     // insert code here...
  16.      struct apple test;  
  17.     struct orange test1;  
  18.     for(sum=0;sum<APPLE_MAX_VALUE;sum++)  
  19.     {  
  20.         test.a += sum;  
  21.         test.b += sum;  
  22.     }  
  23.      sum=0;  
  24.     for(index=0;index<ORANGE_MAX_VALUE;index++)  
  25.     {  
  26.         sum += test1.a[index]+test1.b[index];  
  27.     }  
  28.      return 0;  
  29. }  

在檢測程式執行時間這個複雜問題上,將採用 Randal E.Bryant 和 David R. O’Hallaron 提出的 K 次最優測量方法。假設重複的執行一個程式,並紀錄 K 次最快的時間,如果發現測量的誤差 ε 很小,那麼用測量的最快值表示過程的真正執行時間, 稱這種方法為“ K 次最優(K-Best)方法”,要求設定三個引數:

K: 要求在某個接近最快值範圍內的測量值數量。

ε 測量值必須多大程度的接近,即測量值按照升序標號 V1, V2, V3, … , Vi, … ,同時必須滿足(1+ ε)Vi >= Vk

M: 在結束測試之前,測量值的最大數量。

按照升序的方式維護一個 K 個最快時間的陣列,對於每一個新的測量值,如果比當前 K 處的值更快,則用最新的值替換陣列中的元素 K ,然後再進行升序排序,持續不斷的進行該過程,並滿足誤差標準,此時就稱測量值已經收斂。如果 M 次後,不能滿足誤差標準,則稱為不能收斂。

在接下來的所有試驗中,採用 K=10,ε=2%,M=200 來獲取程式執行時間,同時也對 K 次最優測量方法進行了改進,不是採用最小值來表示程式執行的時間,而是採用 K 次測量值的平均值來表示程式的真正執行時間。由於採用的誤差 ε 比較大,在所有試驗程式的時間收集過程中,均能收斂,但也能說明問題。

為了可移植性,採用 gettimeofday() 來獲取系統時鐘(system clock)時間,可以精確到微秒。

測試環境

硬體:聯想 Dual-core 雙核機器,主頻 2.4G,記憶體 2G

軟體:Suse Linunx Enterprise 10,核心版本:linux-2.6.16

醫生治病首先要望聞問切,然後才確定病因,最後再對症下藥,如果胡亂醫治一通,不死也殘廢。說起來大家都懂的道理,但在軟體優化過程中,往往都喜歡犯這樣的錯誤。不分青紅皁白,一上來這裡改改,那裡改改,其結果往往不如人意。

一般將軟體優化可分為三個層次:系統層面,應用層面及微架構層面。首先從巨集觀進行考慮,進行望聞問切,即系統層面的優化,把所有與程式相關的資訊收集上來,確定病因。確定病因後,開始從微觀上進行優化,即進行應用層面和微架構方面的優化。

  1. 系統層面的優化:記憶體不夠,CPU 速度過慢,系統中程序過多等
  2. 應用層面的優化:演算法優化、並行設計等
  3. 微架構層面的優化:分支預測、資料結構優化、指令優化等

軟體優化可以在應用開發的任一階段進行,當然越早越好,這樣以後的麻煩就會少很多。

在實際應用程式中,採用最多的是應用層面的優化,也會採用微架構層面的優化。將某些優化和維護成本進行對比,往往選擇的都是後者。如分支預測優化和指令優化,在大型應用程式中,往往採用的比較少,因為維護成本過高。

本文將從應用層面和微架構層面,對樣例程式進行優化。對於應用層面的優化,將採用多執行緒和 CPU 親和力技術;在微架構層面,採用 Cache 優化。

並行設計

利用並行程式設計模型來設計應用程式,就必須把自己的思維從線性模型中拉出來,重新審視整個處理流程,從頭到尾梳理一遍,將能夠並行執行的部分識別出來。

可以將應用程式看成是眾多相互依賴的任務的集合。將應用程式劃分成多個獨立的任務,並確定這些任務之間的相互依賴關係,這個過程被稱為分解(Decomosition)。分解問題的方式主要有三種:任務分解、資料分解和資料流分解。關於這部分的詳細資料,請參看參考資料一。

仔細分析樣例程式,運用任務分解的方法 ,不難發現計算 apple 的值和計算 orange 的值,屬於完全不相關的兩個操作,因此可以並行。

改造後的兩執行緒程式:


清單 2. 兩執行緒程式
  1. void* add(void* x)  
  2. {         
  3.     for(sum=0;sum<APPLE_MAX_VALUE;sum++)  
  4.     {  
  5.         ((struct apple *)x)->a += sum;  
  6.         ((struct apple *)x)->b += sum;     
  7.     }  
  8.     return NULL;  
  9. }  
  10. int main (int argc, constchar * argv[]) {  
  11.         // insert code here...
  12.     struct apple test;  
  13.     struct orange test1={{0},{0}};  
  14.     pthread_t ThreadA;  
  15.     pthread_create(&ThreadA,NULL,add,&test);  
  16.     for(index=0;index<ORANGE_MAX_VALUE;index++)  
  17.     {  
  18.         sum += test1.a[index]+test1.b[index];  
  19.     }         
  20.      pthread_join(ThreadA,NULL);  
  21.     return 0;  
  22. }  

更甚一步,通過資料分解的方法,還可以發現,計算 apple 的值可以分解為兩個執行緒,一個用於計算 apple a 的值,另外一個執行緒用於計算 appleb 的值(說明:本方案抽象於實際的應用程式)。但兩個執行緒存在同時訪問 apple 的可能性,所以需要加鎖訪問該資料結構。

改造後的三執行緒程式如下:


清單 3. 三執行緒程式
  1. struct apple  
  2. {  
  3.      unsigned longlong a;  
  4.     unsigned longlong b;  
  5.     pthread_rwlock_t rwLock;  
  6. };  
  7. void* addx(void* x)  
  8. {  
  9.     pthread_rwlock_wrlock(&((struct apple *)x)->rwLock);  
  10.     for(sum=0;sum<APPLE_MAX_VALUE;sum++)  
  11.     {  
  12.         ((struct apple *)x)->a += sum;  
  13.     }  
  14.     pthread_rwlock_unlock(&((struct apple *)x)->rwLock);  
  15.     return NULL;  
  16. }  
  17. void* addy(void* y)  
  18. {  
  19.     pthread_rwlock_wrlock(&((struct apple *)y)->rwLock);  
  20.     for(sum=0;sum<APPLE_MAX_VALUE;sum++)  
  21.     {  
  22.         ((struct apple *)y)->b += sum;  
  23.     }  
  24.     pthread_rwlock_unlock(&((struct apple *)y)->rwLock);  
  25.     return NULL;  
  26. }  
  27. int main (int argc, constchar * argv[]) {  
  28.     // insert code here...
  29.      struct apple test;  
  30.     struct orange test1={{0},{0}};  
  31.     pthread_t ThreadA,ThreadB;  
  32.     pthread_create(&ThreadA,NULL,addx,&test);  
  33.     pthread_create(&ThreadB,NULL,addy,&test);  
  34.     for(index=0;index<ORANGE_MAX_VALUE;index++)  
  35.     {  
  36.         sum+=test1.a[index]+test1.b[index];  
  37.     }  
  38.      pthread_join(ThreadA,NULL);  
  39.      pthread_join(ThreadB,NULL);  
  40.      return 0;  
  41. }  

這樣改造後,真的能達到我們想要的效果嗎?通過 K-Best 測量方法,其結果讓我們大失所望,如下圖:


圖 1. 單執行緒與多執行緒耗時對比圖
單執行緒與多執行緒耗時對比圖

為什麼多執行緒會比單執行緒更耗時呢?其原因就在於,執行緒啟停以及執行緒上下文切換都會引起額外的開銷,所以消耗的時間比單執行緒多。

為什麼加鎖後的三執行緒比兩執行緒還慢呢?其原因也很簡單,那把讀寫鎖就是罪魁禍首。通過 Thread Viewer 也可以印證剛才的結果,實際情況並不是並行執行,反而成了序列執行,如圖2:


圖 2. 通過 Viewer 觀察三執行緒執行情況
通過 Viewer 觀察三執行緒執行情況

其中最下面那個執行緒是主執行緒,一個是 addx 執行緒,另外一個是 addy 執行緒,從圖中不難看出,其他兩個執行緒為序列執行。

通過資料分解來劃分多執行緒,還存在另外一種方式,一個執行緒計算從1到 APPLE_MAX_VALUE/2 的值,另外一個執行緒計算從 APPLE_MAX_VALUE/2+1 到 APPLE_MAX_VALUE 的值,但本文會棄用這種模型,有興趣的讀者可以試一試。

在採用多執行緒方法設計程式時,如果產生的額外開銷大於執行緒的工作任務,就沒有並行的必要。執行緒並不是越多越好,軟體執行緒的數量儘量能與硬體執行緒的數量相匹配。最好根據實際的需要,通過不斷的調優,來確定執行緒數量的最佳值。

針對加鎖的三執行緒方案,由於兩個執行緒訪問的是 apple 的不同元素,根本沒有加鎖的必要,所以修改 apple 的資料結構(刪除讀寫鎖程式碼),通過不加鎖來提高效能。

測試結果如下:


圖 3. 加鎖與不加鎖耗時對比圖
加鎖與不加鎖耗時對比圖

其結果再一次大跌眼鏡,可能有些人就會越來越糊塗了,怎麼不加鎖的效率反而更低呢?將在針對 Cache 的優化一節中細細分析其具體原因。

在實際測試過程中,不加鎖的三執行緒方案非常不穩定,有時所花費的時間相差4倍多。

要提高並行程式的效能,在設計時就需要在較少同步和較多同步之間尋求折中。同步太少會導致錯誤的結果,同步太多又會導致效率過低。儘量使用私有鎖,降低鎖的粒度。無鎖設計既有優點也有缺點,無鎖方案能充分提高效率,但使得設計更加複雜,維護操作困難,不得不借助其他機制來保證程式的正確性。

在序列程式設計過程中,為了節約頻寬或者儲存空間,比較直接的方法,就是對資料結構做一些針對性的設計,將資料壓縮 (pack) 的更緊湊,減少資料的移動,以此來提高程式的效能。但在多核多執行緒程式中,這種方法往往有時會適得其反。

資料不僅在執行核和儲存器