多核與異步並行

來源: http://www.parallellabs.com/2013/01/21/multicore-and-asynchronous-communication/

多核與異步並行


我們在設計多線程程序時往往有很多性能指標,例如低延遲(latency),高吞吐量(throughput),高響應度(responsiveness)等。隨着多核處理器上CPU核數的日益增加,如何高效地利用這些計算資源以滿足這些設計目標變得越來越重要。這次向大家介紹的異步並行就是一種幫助實現低延遲、高吞吐量和高響應度的並行編程技術。


原文發表於《程序員》雜誌2012年第9期,文字略有修改。

我們在設計多線程程序時往往有很多性能指標,例如低延遲(latency),高吞吐量(throughput),高響應度(responsiveness)等。隨着多核處理器上CPU核數的日益增加,如何高效地利用這些計算資源以滿足這些設計目標變得越來越重要。這次向大家介紹的異步並行就是一種幫助實現低延遲、高吞吐量和高響應度的並行編程技術。

讓我們先來看這樣一個例子。在下面的程序中,我們有一個do_something()的API,這個函數實現了將一個文件寫入磁盤的功能,所以改函數比較耗時。在調用這個函數時,最簡單的用法是對該函數進行同步調用,即下面程序中caller1()所採用的方式。這種寫法帶來的問題是,caller1需要阻塞等待do_something()的完成,期間CPU不能做任何其他的計算,從而導致CPU資源的空閒。與此相反,程序中的caller2就採用了異步調用do_something()的方式。這樣,caller2在將異步調用do_something的命令發送給worker線程之後,就可以立刻返回並開始執行other_work(),不僅能將other_work()提前完成,更提高了CPU利用率。

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
int do_something(doc)
{
    return write_document(doc); // 耗時的I/O寫操作
}
 
void caller1(doc) {
   result = do_something(doc); //同步調用do_something()
   other_work(); //這個操作需要等待do_something()的完成
   more_other_work();
}
void caller2() {
   worker.send(do_something_msg());//異步調用do_something()
   other_work(); //這個操作不需要等待do_something()的完成,因此提高了CPU的利用率
   more_other_work();
}

在現代計算機體系結構中,I/O設備的速度遠遠比不上CPU,我們在做計算時一個基本的設計原則就是在CPU等待I/O請求的同時,用足夠多的計算任務將CPU跑滿,從而掩蓋掉I/O請求造成的延遲。在單核時代,我們使用Multiplexing的方式將I/O任務與計算任務重疊在一起進而提高程序性能,即一個進程如果進入I/O等待,操作系統會將該進程放入等待隊列,並調度執行另一個進程的計算任務;多核時代來臨之後,CPU上的計算資源變得越來越多,通過使用異步並行技術充分利用CPU的計算資源,提升應用程序的延遲性、吞吐量、響應度也變得越來越普遍。下面讓我們通過幾個典型應用來對異步並行做更多的介紹。

GUI線程的異步並行設計

GUI線程是採用異步並行設計來提高響應度的一個經典例子。一個GUI程序的典型結構是使用一個循環來處理諸如用戶點擊了某個按鈕、系統產生了一箇中斷等事件。許多GUI系統還提供了諸如優先級隊列等數據結構以保證優先級高的事件能得到及時的相應。下例是一個典型的GUI系統僞代碼:

01
02
03
04
05
06
07
08
09
10
while( message = queue.receive() ) {
  if( it is a "保存文件" request ) {
    save_document(); // 這是一個會產生阻塞的同步調用
  }
  else if( it's a "打印文檔" request ) {
    print_document(); // 這是一個會產生阻塞的同步調用
  }
else
  ...
}

這個程序有一個非常常見的性能bug:它對save_document()和print_document()這兩個非常耗時的操作採用了同步調用的方式,這與GUI線程應該具備及時響應的設計初衷產生了直接矛盾。GUI線程的設計目標不僅僅是對相應的事件作出正確的響應,更重要的是這些響應必須非常及時。按照上面這個程序的邏輯,很可能會出現如下情況:用戶在點擊“保存文件”按鈕之後,程序需要花費幾秒鐘才能完成save_document()調用,因此該程序在這幾秒鐘時間內都不能再對其他任何事件作出響應;而這時如果用戶還想要調整窗口大小,這個操作在幾秒鐘之內都得不到響應,從而破壞用戶體驗。

一般來說,需要擁有高響應度的線程不應該直接執行可能帶來延遲或阻塞的操作。可能帶來延遲或阻塞的操作不僅僅包括保存文件、打印文件,還包括請求互斥鎖、等待其他線程某個操作的完成等。

我們有三種方式來將耗時的操作從需要保持高響應度的線程中轉移出去。下面讓我們繼續用GUI系統的例子來對這三種方法一一進行介紹,以分析它們各自適用的場景。

方式一:一個專用的工作線程

第一種將耗時操作從GUI線程中轉移出去的方式是,使用一個專門的工作線程來異步地處理GUI線程發送的耗時操作請求。如下圖所示,GUI線程依次將打印文檔(PrintDocument)和保存文檔(SaveDocument)兩個異步請求發送給工作線程之後就立刻返回,從而繼續對用戶的其他請求做出及時的相應(例如調整窗口大小、編輯文檔等);與此同時,工作線程依次對打印文檔和保持文檔進行順序處理,並在並在該異步請求完成到某一進度時(或者該異步請求完成時)向GUI線程發送相應的通知信號。

圖1. 使用專門的工作線程來處理GUI線程的異步請求圖1. 使用專門的工作線程來處理GUI線程的異步請求

讓我們來看看這種處理方式的代碼會長成什麼樣子:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 第一種方式:使用一個專門的工作線程來處理GUI線程的異步請求
// GUI線程:
while( message = queue.receive() ) {
   if( it's a "保存文檔" request ) {
      worker.send( new save_msg() ); // 發送異步請求
   }
   else if( it's a "保存文檔" completion notification ) {
     display(“保存文檔成功!”); // 接到異步請求的進度通知
   }
   else if( it's a "打印文檔" request ) {
      worker.send( new print_msg() ); //發送異步請求
   }
   else if( it's a "打印文檔" progress notification ) {
      if( percent < 100 ) // 接到異步請求的進度通知
         display_print_progress( percent );
      else
         display(“打印完畢!”);
   }
   else
   ...
}
 
// 工作線程:處理來自GUI線程的異步請求
while( message = workqueue.receive() ) {
   if( it's a "保存文檔" request )
      save_document(); // 保存文檔並在結束後向GUI線程發送通知
   else if( it's a "打印文檔 " request )
      print_document(); // 打印文檔並向GUI線程發送進度通知
   else
   ...
}

方式二:每一個異步請求分配一個工作線程

在第一種方法的基礎之上,我們可以做一些相應的擴展:對每一個GUi線程的異步請求都分配一個專門的工作線程,而不是隻用一個工作線程去處理所有異步請求。這個方式的好處很明顯,異步請求被多個線程分別並行處理,因此提升了處理速度。值得注意的是,我們需要及時對這些工作線程進行垃圾回收操作,否則大量線程會造成內存資源的緊張。

圖2. 爲每個GUI線程的異步請求分配一個工作線程圖2. 爲每個GUI線程的異步請求分配一個工作線程

這種模式的代碼如下所示。因爲對每個異步請求我們都啓動一個新的線程,我們可以充分地利用多核的計算資源,更快地完成相應的任務。

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
// 方式二:每一個異步請求分配一個線程
while( message = queue.receive() ) {
   if( it's a "保存文檔" request ) {
      ...  new Thread( [] { save_dcument(); } ); // 啓動新線程對異步請求進行處理
   }
   else if( it's a "打印文檔" request ) {
      new Thread( [] { print_document(); } );/ // 啓動新線程對異步請求進行處理
   }
   else if( it's a "保存文檔" notification ) { ... }
                                      // 同方式一
   else if( it's a "打印文檔" progress notification ) { ... }
                                      // 同方式一
   else
      ...
}

方式三:使用線程池來處理異步請求

第三種方式更進了一步:我們可以根據多核硬件資源的多少來啓動一個專門的線程池,用線程池來完成GUI線程的異步請求。這種方式的好處在於,我們可以在充分利用多核的硬件資源,以及並行地對異步請求進行高效處理間取得一個很好的平衡。該方式的工作示意圖如下所示:

圖3. 使用線程池來處理GUI線程的異步請求圖3. 使用線程池來處理GUI線程的異步請求

讓我們來看一下這種方式的僞代碼。需要注意的是,線程池的具體實現每個語言各有不同,因此下面的代碼只供大家參考之用。

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
// 方式三:使用線程池來處理異步請求
while( message = queue.receive() ) {
if( it's a "保存文檔" request ) {
pool.run( [] { save_document(); } ); // 線程池的異步調用
}
else if( it's a "打印文檔" request ) {
pool.run( [] { print_document(); } ); //線程池的異步調用
}
else if( it's a "保存文檔" notification ) { ... }
// 同前
else if( it's a "打印文檔" progress notification ) {  ... }
// 同前
else
...
}

Grand Central Dispatch的異步並行

Grand Central Dispatch(GCD)是蘋果於Mac OS X 10.6和iOS4中發佈的一項並行編程技術。對使用GCD的程序員來說,只需要將需要被處理的任務塊丟到一個全局的任務隊列中去就可以了,這個任務隊列中的任務會由操作系統自動地分配和調度多個線程來進行並行處理。將需要被處理的任務塊插入到任務隊列中去有兩種方式:同步插入和異步插入。

讓我們來看看一個使用GCD異步並行的實例。在下面的程序中,analyzeDocument函數需要完成的功能是對這個文檔的字數和段落數進行相關統計。在分析一個很小的文檔時,這個函數可能非常快就能執行完畢,因此在主線程中同步調用這個函數也不會有很大的性能問題。但是,如果這個文件非常的大,這個函數可能變得非常耗時,而如果仍然在主線程中同步調用該方法,就可能帶來很大的性能延遲,從而影響用戶體驗。

01
02
03
04
05
06
07
// 不使用GCD的版本
- (IBAction)analyzeDocument:(NSButton *)sender {
    NSDictionary *stats = [myDoc analyze];
    [myModel setDict:stats];
    [myStatsView setNeedsDisplay:YES];
    [stats release];
}

使用GCD的異步並行機制來優化這個函數非常簡單。如下所示,我們只需要在原來的代碼基礎上,先通過dispatch_get_global_queue來獲取全局隊列的引用,然後再將任務塊通過dispatch_async方法插入該隊列即可。任務塊的執行會交由操作系統去處理,並在該任務塊完成時通知主線程。一般來講,異步插入的方式擁有更高的性能,因爲在插入任務之後dispatch_async可以直接返回,不需要進行額外等待。

01
02
03
04
05
06
07
08
09
10
11
//使用GCD異步並行的版本
- (IBAction)analyzeDocument:(NSButton *)sender
{
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0ul);
dispatch_async(queue, ^{
         NSDictionary *stats = [myDoc analyze];
         [myModel setDict:stats];
         [myStatsView setNeedsDisplay:YES];
         [stats release];
     });
}

總結

本文對多核編程時常用的異步並行技術做了相關介紹。通過使用異步並行技術,我們可以將比較耗時的操作交給其他線程去處理,主線程因此可以去處理其他有意義的計算任務(例如相應用戶的其他請求,完成其他計算任務等),從而有效提高系統的延遲性、吞吐率和響應性。



發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章