一個人作菜時可以逐項進行,這是單一流程,為了有效利用時間,等待煮湯時可以先處理煮飯等,這是多個流程,只是要小心手忙腳亂,也要留意鍋碗瓢盆之分配。
如果有兩個人,分配好獨立工作,各自進行個別流程,也許可增加點效率。如果有龐大料理需求,將工作與食材切割好,交給各組人馬或各中央廚房,以協同合作方式來增加產能與降低風險。
低階的Thread處理
在先前的比喻中,一個人就相當於一個CPU核心,一個核心可以處理單一流程,也可以被細分為多個時間單位切換處理多個流程。當擁有多個核心時,就相當於有多個人可以分配工作流程。
對於單一程式中執行多個流程的需求,不少程式語言提供執行緒模型,以Java為例,提供Thread類別讓使用者將想獨立進行的流程,定義於run方法之中(或者實作Runnable並由Thread包裹執行)。
程式經常有建立多個流程的需求,以設計龜兔賽跑遊戲為例,若以單一流程設計,那程式碼中就會同時夾雜著龜兔的行進流程,如果將龜兔的行進各自設計在Thread(或Runnable)的run方法中,那麼各自流程就會清晰許多。
將單一流程處理拆開為多個流程,基本上就是要將「做什麼」與「何時做」分解開來。使用執行緒,基本上必須思考多個流程各自要做什麼,而何時讓CPU做就是Thread類別的職責。使用了執行緒,程式中的流程就會如線般交織。
而由於多個流程間可能會共用資源,因此,直接使用執行緒的問題在於,我們必須自行控制資源共用時引發的問題,必須瞭解資源是否有限,多個執行緒的存取是否互斥,必須小心保護資源,但又必須注意飢餓(Starvation)、死結(Deadlock)、活結(Livelock)等問題。
建立執行緒也是有負擔的,如何讓每個執行緒發揮最大效用,也是直接使用執行緒必要考量。若沒有處理好,效能非但不改進,反而還會低落,使用執行緒,我們須自行處理「何時做」資源控制。
高階並行處理的JSR166
1997年參與Java群集框架的Doug Lea,在《Concurrent Programming in Java》中,撰寫許多安全執行緒群集(Thread-safe collections),他也主持過JSR166,而那些群集實作後來成為JDK5中java.util.concurrent套件的一部份。開發者可以直接運用ConcurrentHashMap等類別,無需處理低階的同步、等待、通知等細節,並獲得更好的效能表現。
在必要進行資源同步的情境下,JSR166也提供了高階的鎖定模型,像是基本的ReentrantLock實作,就考量了相同執行緒再度取得鎖定的情況,也提供了非阻斷的(Nonblocking)的tryLock方法,適當運用可避免死結之類問題;ReadWriteLock的實作則定義了何時可取得讀取鎖定與寫入鎖定等行為。
對於執行緒的建立、重用等管理問題,JSR166使用了Executor來對Thread作了增強,並使用ExecutorService來提供執行緒管理服務,像是實作之一的ThreadPoolExecutor提供了執行緒池的功能,可依需求設定並自動維護執行緒,可用來執行不相依的運算。ExecutorService也實作了高階的並行處理模式,像是invokeAll等方法傳回的List包含Future實例,可取得未來運算的結果。
使用JSR166或JDK6之後補充的高階並行處理API,最主要的好處是,減輕使用執行緒時,必須注意低階的「何時做」資源控制之負擔。使用ConcurrentHashMap等類別,開發者不用去關切複雜的同步等問題,即使還是必要協調共用資源,也可以用較高階語義的Lock、Future等實作,這讓開發者可以更關注在每個流程必須「做什麼」。
JDK7的ForkJoin框架
JSR166等高階並行API,雖然可以用高階語義來處理「何時做」資源控制問題,然而要善用並行處理最好的方式,就是不共用資源,讓每個執行流程可以獨立運作,以獲得更好的產能表現。
現在電腦系統多半搭載多CPU(核心),如果沒有使用多執行緒,就只會運用到部份運算資源,如果可以讓每個執行緒獨立運作,分配於各個核心中處理非共用資料集合,就可以更快得到運算結果。單純使用ThreadPoolExecutor,基本上可以達到此一目的,然而各核心運算速度可能不一,像是執行緒得到的CPU運算時間不同,分析資料時需要的時間也不定,有的執行緒可能早於其他執行緒完成,該核心的運算資源因而閒置。
為了讓各執行緒保持忙碌,應該讓已完成工作的執行緒可以分擔其他執行緒的工作,至於執行緒「何時做」這件事,JDK7的ForkJoin框架讓執行緒實現了工作竊取(Work-stealing)機制,讓使用者不用親自處理執行緒何時該偷取其他執行緒的工作,只要關心執行緒分配到的獨立資料集合何時該進一步分割,以及該對夠小的分割做什麼。無論是會傳回結果的RecursiveTask,或是不傳回結果的RecursiveAction,基本上都是用來封裝要被分割的資料集合,以及進行分割或運算的流程。
顯然地,如果原先的多執行緒程式在資源上共用,或是要處理的資料間有相依,就沒辦法直接利用ForkJoin框架,原先的設計必須改變,流程必須被畫分為更多細小職責的流程,用來產生更多的中間產物,每個中間產物彼此間沒有共用或相依部份,才有辦法進一步封裝在RecursiveTask或RecursiveAction中,如果能做到這一點,那麼剩下的問題就是,對每個RecursiveTask或RecursiveAction分配到的資料集合,「何時做」資料切割,以及對夠小的資料集合「做什麼」的問題。
只關心「做什麼」的MapReduce
有些人將ForkJoin框架與MapReduce來對比,從實體層面來看,ForkJoin是執行在單一JVM,而MapReduce是運行於叢集(Cluster),ForkJoin將資料集合與任務分配至單一機器的各核心,而MapReduce將資料集合與任務分配至叢集的各機器;然而從處理流程來看,ForkJoin必須在乎「何時做」資料切割,以及對夠小的資料集合「做什麼」,而MapReduce只關心資料集合中每筆資料應當「做什麼」處理的問題。
以Hadoop實現的MapReduce為例,資料「何時做」切割已經封裝在底層,使用者要作的就是實作Mapper,無論輸入map方法的每筆資料是否有相依性,map內部須對應為彼此不相依的資料,如此才能進一步獨立分配給使用者實作的Reducer,以在reduce方法中,從Mapper產出的資料找出相關性。
以對比方式來說,MapReduce就像是將ForkJoin中RecursiveTask或RecursiveAction的compute方法中,何時該切割資料的部份抽出並封裝於底層,因此使用者現在只要關心夠小的資料應該「做什麼」。
在《Clean Code》書中第13章談到:平行化是不斷去除相依性(decoupling)的策略。它協助我們解開「做什麼」與「何時做」之間的相依性。從Thread、JSR166、ForkJoin甚至到MapReduce,可以看到「何時做」資源控制的低階與高階處理,甚至是「何時做」切割資料的議題,不斷從「做什麼」的流程中抽離,然而我們必須改變設計,避免碰觸「何時做」資源控制或資料分割的問題,這也是在運用ForkJoin或MapReduce時的極大門檻──這需要一個過程,不斷地將「做什麼」與「何時做」之間的相依性分離,原本流程都必須畫分為更細小、更單一的職責,循環地去除各個中間產物的共用與相依性,才能避免碰觸「何時做」的問題,讓各個流程或機器可以獨立地運算。
專欄作者
熱門新聞
2024-12-27
2024-12-24
2024-12-22
2024-11-29
2024-12-20