在多核心處理器普及的現在,平行程式設計逐漸受到重視,新的程式語言在推出或者既有程式語言在演化的過程中,無不將平行程式設計的特性視為重點之一,就技術上而言,開發者並不欠缺可用的平行方案,需要的是對平行處理的認識,以及平行的設計思維之培養。
區別並行與平行
開發者多半知道多執行緒程式設計,也就是在程式設計上可獨立設計多個流程,在某些時間點讓多個流程並行。最常見的應用場合,就是在循序流程發生阻斷時,例如下載多個網頁,由於網頁下載過程中,需要開啟網路連結、協定交換、網路傳輸、存檔等動作,而這些動作需要等待,若能分多個流程來下載多個網頁,在等待某個下載動作完成的時間,處理器可看看另一流程是否可進行運算,避免閒置,從而縮短全部網頁下載完成時間。
也就是說,就處理器來說,同時間還是只有一個執行緒在核心中運行,這類設計稱為並行程式設計(Concurrent programming),由於只是在邏輯上(Logically),或者說等待的同時做了其他事情,基本上適合非計算密集的任務,若是計算密集任務,並不會獲得減少處理器閒置時間的好處,反而會因為切換執行緒的成本,使得花費的時間更長。
相對於並行程式設計,平行程式設計(Parallel programming)是在實體上(Physically)同時進行多個流程,因而需要硬體上的支援,就單一主機而言,基本上需要具備多處理器或多核心處理器,就技術上而言,需要程式語言的語法,或者是程式庫、框架來支援。
由於平行程式設計,可真正在多個處理器或核心中,同時執行多個流程,沒有切換流程的成本,只要子任務分配適當,完成整個任務需要的時間就可以縮短,只不過長期以來,開發者多半接受循序式、命令式思維的教育,後來在「同時處理」的這類任務,多半先接觸的又是多執行緒,通常只會在循序流程受到阻斷時,才會試著(以執行緒的觀點)思考多個流程的可能性。
平行處理的出發點
平行處理的出發點,並不是優先思考平行處理的可能性,或想著這個任務可以用幾個處理器來做平行處理來進行設計,這將會使得架構被寫死而不易擴展,如果子任務之間必須溝通,也可能造成複雜的溝通機制,使得除錯不易(難以在單一流程的情況下除錯),或者因不當的鎖定而效能低落。
平行處理的出發點是子任務的分解,每個子任務應該能被獨立地賦予任務、執行並達到成果,相同子任務應該要有相同的結果,而且子任務之間不會互相影響。舉例來說,每個函式設計為無副作用,甚至使用純函數式典範,讓每個運算步驟都無副作用,無副作用並不代表它們不能產生輸入輸出,相同的資料檔案來源,產生相同的檔案輸出,也可算是一種無副作用。
這樣的設計方式下,有些技術可以簡單地將子任務平行化,甚至只要調整某些參數,就可以控制使用的處理器或核心數量,像是Go語言中,可以使用go關鍵字就讓某個函式進行並行或平行處理(Go 1.5預設會使用所有的CPU核心),在C++領域有個OpenMP,甚至可使用#pragma omp parallel for,將for迴圈的本體進行平行化,另一個好處是,程式仍是可以循序執行,在需要除錯的場合,可以降低除錯的難度。
在談及平行處理的場合,經常會出現兩個名詞:資料平行(Data parallelism)、任務平行(Task parallelism)。前者是指有大量資料要處理,將資料分解並丟給各個處理器處理,看似簡單,然而,難處在於得先有個可分解的資料集,開發者得去除資料集中各筆資料間的相依性,或者找出資料集中可改為平行分量的部份,然後將之化為子任務去執行運算,這在我先前專欄〈執行資料平行處理的效能考量〉曾經提過。
至於任務平行,是指同一個資料集上要進行不同的任務,例如,在同一個天氣資料集中找出最高溫度、最低溫度、平均溫度,傳統循序作法中,為了所謂的效能,可能在一次讀取資料集的過程中,就將這三個任務完成,然而,任務平行的做法中,可能是分三個子任務,每個子任務讀取一次資料集,各自專心處理最高溫度、最低溫度、平均溫度的取得,也就是一次只處理一件事,想當然爾,後者可以在需要平行處理的場合中,將三個子任務分配給不同的處理器。
無副作用的子任務、資料的相依性去除、一次只做一件事,這是平行處理的出發點,而這些在純函數式語言中是基本而重要甚至是強迫性的,這也就是為什麼純函數式語言,會在後來被重視,且視為適合平行處理,或視為訓練平行思維的語言。
三個定律的本質
在談到平行處理議題時,往往會提到三個定律:Moore's law、Amdahl's law、Gustafson's law。第一個定律是大家熟悉的「積體電路上可容納的電晶體數目,約每隔24個月便會增加一倍」,後來David House則說「預計18個月會將晶片的效能提高一倍」。只不過,半導體終究有物理上的限制,Moore's law被預期將遇到極限,依賴單一處理器的速度也就有其瓶頸。
也因此,後來人們開始關注平行處理,根據Amdahl's law,如果任務中有能夠平行處理的分量,只要能將序列處理中的一半改為平行處理,在不考慮投入的核心數下,效率就會增加為兩倍,不過,當處理器增加到一定數量之後,後續增加的處理器對效能的貢獻就會趨近於零,最終會受到程式中循序部份的主導。
Gustafson's law則提出了一個修正,由於電腦運算能力越來越強大,人們也就給予越來越龐大的任務,如果能從這龐大任務中,分出越多可平行處理的部份,處理器越多就代表著能平行處理掉的量就越大,循序的部份在這類日益龐大的任務中,影響就能逐漸減少。
Amdahl's law與Gustafson's law看似衝突,實際上二者本質上一致,前者是在定量任務下,談到就算處理器不斷增加,最終效能還是受到循序部份的影響,因而重要的是「降低循序部份」,後者是談到越是龐大的任務,分出越多平行分量給更多處理器處理,就越能不受循序部份的控制,因而重要的是「增加平行部份」,降低循序與增加平行不就是一體兩面!
留意適當語言與工具
當然,最終開發者還是要以某種語言或技術實現程式,現在有不少的語言、程式庫或框架,提供有平行程式設計上的支援,妥善納入考量,可以減少許多實作上的難度,方才提到的Go語言是個例子,而在Java 8中想做資料平行化,可以考慮Stream API,利用以上原則,在單一流程下可以執行任務,接著就只要將stream改為parallelStream,看看是否改進了效能。
實際上有些場合,使用了執行緒的動作也可以平行化,像是方才提到的多個網頁下載,這是因為過去語言或技術上對平行的支援欠缺,只好使用執行緒,在多處理器的環境中,如果需要大量執行這類任務,可以考慮修改為真正的平行化,Python中特意將multiprocessing模組中一些API,設計成為threading模組API,這時就可以善加利用,不過由於執行緒比較輕量級,改成行程負擔較重,實際上還是要看看哪個較具效能。
平行程式設計是現代與未來開發者,都必須關注的課題,有看過Multiple CPU dance嗎?在嘴角失守的同時,或許可以先想想看,自己寫的程式能否避免同樣的場景?