iThome 網站首載:從Thread到MapReduce
一個人作菜時可以逐項進行,這是單一流程,為了有效利用時間,等待煮湯時可以先處理煮飯等,這是多個流程,只是要小心手忙腳亂,也要留意鍋碗瓢盆之分配。如果有兩個人的話,分配好獨立工作,各自進行個別流程,也許可增加點效率。如果有龐大料理需求,將工作與食材切割好交給各組人馬或各中央廚房,以協同合作方式來增加產能與降低風險。
- 低階的Thread處理
在先前的比喻中,一個人就相當於一個CPU核心,一個核心可以處理單一流程,也可以被細分為多個時間單位切換處理多個流程。當擁有多個核心時,就相當於有多個人可以分配工作流程。對於單一程式中執行多個流程的需求,不少程式語言提供執行緒(Thread)模型,以Java為例,提供
Thread
類別讓使用者將想獨立進行的流程,定義於run
方法之中(或者實作Runnable
並由Thread
包裹執行)。程式經常有建立多個流程的需求,以設計龜兔賽跑遊戲為例,若以單一流程設計,那程式碼中就會同時夾雜著龜兔的行進流程,如果將龜兔的行進各自設計在
Thread
(或Runnable
)的run
方法中,那麼各自流程就會清晰許多。建立多個流程的需求也可能是為了善用CPU閒置時間,像是下載多個網站的檔案,由於下載速度不一,在等待某個網站回應的同時,讓CPU可以處理另一檔案下載流程,基本上可加快整體下載時間。將單一流程處理拆開為多個流程,基本上就是要將「做什麼」與「何時做」分解開來。使用執行緒,基本上必須思考多個流程各自要做什麼,而何時讓CPU做就是
Thread
類別的職責。使用了執行緒,程式中的流程就會如線般交織,這也許是當初英文命名為Thread
的由來。多個流程間可能會共用資源,直接使用執行緒的問題在於,我們必須自行控制資源共用時引發的問題,必須瞭解資源是否有限,多個執行緒的存取是否互斥,必須小心保護資源但又必須注意飢餓(Starvation)、死結(Deadlock)、活結(Livelock)等問題。建立執行緒也是有負擔的,如何讓每個執行緒發揮最大效用也是直接使用執行緒必要的考量。如果沒有處理好,效能非但不會改進,反而還會低落,使用執行緒,我們必須自行處理「何時做」資源控制的問題。- 高階並行處理的JSR166
1997年參與過Java群集框架的Doug Lea,在著作《Concurrent Programming in Java》中撰寫許多安全執行緒群集(Thread-safe collections),Doug Lea也主持過JSR166,而那些群集實作後來成為JDK5中
java.util.concurrent
套件的一部份。開發者可以直接運用ConcurrentHashMap
等類別,無需處理低階的同步(synchronized
)、等待(wait
)、通知(notify
)等細節,並獲得更好的效能表現。在必要進行資源同步的情境下,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時的極大門檻,這需要一個過程,不斷地將「做什麼」與「何時做」之間的相依性分離,原本流程都必須被劃分為更細小更單一的職責,循環地去除各個中間產物的共用與相依性,才能避免碰觸「何時做」的問題,讓各個流程或機器可以獨立地進行運算。