部落格地址:joey771.cn/2018/10/25/…
spark的執行原理在大資料開發崗面試過程中是經常被問到的一個問題,我第一次被問到這個問題的時候有點摸不著頭腦,這麼大的一個問題我究竟應該怎樣回答呢?是去描述一下spark的架構組成還是說一下底層的呼叫細節?後來查詢了一些資料,看了一些書之後對這個問題有了一些理解,其實提這個問題的人可能最希望我們回答的是Spark執行的過程細節,簡單來說就是把某個Spark程式從提交到執行完成中間經歷了哪些步驟描述出來。如果在描述的過程中能夠加入一些對Spark底層原始碼細節的解釋會給提問者留下比較好的印象,認為你不僅僅是停留在使用Spark上,還對底層原始碼的原理有所瞭解。
簡單描述Spark的執行原理
使用者使用spark-submit提交一個作業之後,會首先啟動一個driver程式,driver程式會向叢集管理器(standalone、YARN、Mesos)申請本次執行所需要的資源(這裡的資源包括core和memory,可以在spark-submit的引數中進行設定),叢集管理器會根據我們需要的引數在各個節點上啟動executor。申請到對應資源之後,driver程式就會開始排程和執行我們編寫的作業程式碼。作業會被提交給DAGScheduler,DAGScheduler會根據作業中RDD的依賴關係將作業拆分成多個stage,拆分的原則就是根據是否出現了寬依賴,每個stage當中都會盡可能多的包含連續的窄依賴。每個stage都包含了作業的一部分,會生成一個TaskSet提交給底層排程器TaskScheduler,TaskScheduler會把TaskSet提交到叢集當中由executor進行執行。Task的劃分是根據資料的partition進行劃分,一個partition會劃分為一個task。如此迴圈往復,直至執行完編寫的driver程式的所有程式碼邏輯,並且計算完所有的資料。
簡單的執行流程如下圖:
圖一 spark執行流程
SparkContext
Spark程式的整個執行過程都是圍繞spark driver程式展開的,spark driver程式當中最重要的一個部分就是SparkContext,SparkContext的初始化是為了準備Spark應用程式的執行環境,SparkContext主要是負責與叢集進行通訊、向叢集管理器申請資源、任務的分配和監控等。
driver與worker之間的架構如下圖,driver負責向worker分發任務,worker將處理好的結果返回給driver。
圖二 driver架構
SparkContext的核心作用是初始化Spark應用程式執行所需要的核心元件,包括高層排程器DAGScheduler、底層排程器TaskScheduler和排程器的通訊終端SchedulerBackend,同時還會負責Spark程式向Master註冊程式等。Spark應用當中的RDD是由SparkContext進行建立的,例如通過SparkContext.textFile()、SparkContext.parallel()等這些API。執行流程當中提及的向叢集管理器Cluster Manager申請計算資源也是由SparkContext產生的物件來申請的。接下來我們從原始碼的角度學習一下SparkContext,關於SparkContext建立的各種元件,在SparkContext類中有這樣一段程式碼來建立這些元件:
DAGScheduler
DAGScheduler是一個高層排程器,能夠將DAG的各個RDD劃分到不同的Stage,並構建這些Stage之間的父子關係,最後將每個Stage根據partition劃分為多個Task,並以TaskSet的形式提交給底層排程器TaskScheduler。Stage的劃分按照的是RDD依賴關係中是否出現了寬依賴,寬依賴指的是父RDD中的一個partition被子RDD的多個partition所依賴,簡單來說就是父RDD的partition的出度大於1,同理,窄依賴指的就是父RDD的一個partition只被子RDD的一個partition所依賴,也就是父RDD的partition的出度都是1。每個Stage當中都會盡可能包含多的窄依賴,將各個窄依賴的運算元形成一整個pipeline進行執行,可以減少各個運算元之間RDD的讀寫,不像MapReduce當中每個job只包含一個Map任務和一個Reduce任務,下一個Map任務都需要等待上一個Reduce任務全部都結束才能執行,pipeline形式的執行過程中沒有產生shuffle,放在一起執行明顯效率更高。Stage與Stage之間會出現shuffle,這裡shuffle也是一個常常考察的點,另外的文章會詳細說明。DAGScheduler還需要記錄哪些RDD被存入磁碟等物化動作,同時要尋求Task的最優化排程,如在Stage內部資料的本地性等。DAGScheduler還需要監控因為shuffle跨節點輸出可能導致的失敗,如果發現這個Stage失敗,可能就要重新提交該Stage。
DAGScheduler具體呼叫過程
當一個job被提交時,DAGScheduler便會開始其工作,spark中job的提交是由RDD的action觸發的,當發生action時,RDD中的action方法會呼叫其SparkContext的runJob方法,經過多次過載之後會呼叫到DAGScheduler的runJob方法。
DAGScheduler類當中runJob是提交job的入口函式,其中會呼叫submitJob方法返回一個JobWaiter來等待作業排程的結果,之後根據作業的成功或者失敗列印相關的結果日誌資訊。
submitJob方法會獲取jobId以及校驗partitions是否存在,並向eventProcessLoop傳送了一個case class JobSubmitted物件,JobSubmitted物件封裝了jobId、最後一個RDD,對RDD操作的函式,哪些partition需要被計算等內容。eventProcessLoop當中有一個eventThread執行緒,是一個deamon執行緒,用於接收通過post方法傳送到該執行緒的JobSubmitted物件,放入其中的一個eventQueue阻塞佇列進行處理,從eventQueue中take出來的event會呼叫onReceive方法(該方法由eventProcessLoop實現),onReceive方法中又會呼叫doOnReceive方法,按照不同的event型別進行不同的處理。
這裡讀原始碼的時候可能會有一個疑問,為何不直接在DAGScheduler呼叫submitJob的時候直接呼叫doOnReceive來處理job,為何要新啟一個執行緒來進行處理,並且自己給自己發訊息進行處理(eventProcessLoop是DAGScheduler內部的一個物件)。這裡實際上是一個執行緒的非同步通訊方式,只是將訊息以執行緒通訊的方式post(這裡的執行緒通訊方式實際上是用了一個阻塞佇列)給另一個執行緒,submitJob的方法能夠立刻返回,不會阻塞在處理event的過程當中。這裡我們不要淺顯的認為DAGScheduler當中自己在給自己發訊息,實際上還有別的元件會給DAGScheduler發訊息,這種採用一個守護執行緒的方式進行訊息處理可以將這兩者統一起來,兩者處理的邏輯都是一致的,擴充套件性非常好,使用訊息迴圈器,就能統一處理所有的訊息,保證處理的業務邏輯都是一致的。這裡的eventProcessLoop實際上能夠處理多種訊息,不僅僅是JobSubmitted,原始碼當中能看到有如下多種event的處理:
- JobSubmitted(jobId, rdd, func, partitions, callSite, listener, properties)
- MapStageSubmitted(jobId, dependency, callSite, listener, properties)
- StageCancelled(stageId, reason)
- JobCancelled(jobId, reason)
- JobGroupCancelled(groupId)
- AllJobsCancelled
- ExecutorAdded(execId, host)
- ExecutorLost(execId, reason)
- WorkerRemoved(workerId, host, message)
- BeginEvent(task, taskInfo)
- SpeculativeTaskSubmitted(task)
- GettingResultEvent(taskInfo)
- completion
- TaskSetFailed(taskSet, reason, exception)
- ResubmitFailedStages
JobSubmitted會去呼叫DAGScheduler中的handleJobSubmitted方法,該方法是構建Stage的開始階段,會建立Stage中的最後一個Stage——ResultStage,而其他Stage為ShuffleMapStage。ResultStage的建立是由createResultStage這個函式完成的,其中的getOrCreateParentStage方法將會獲取或建立一個給定RDD的父Stages列表,這個方法就是我們之前所說的具體劃分Stage的方法,這個方法的原始碼很簡單,如下:
其中呼叫了一個函式getShuffleDependencies用來返回給定RDD的父節點中直接的shuffle依賴,其原始碼如下:
這裡有三個主要的資料結構,為兩個HashSet——parents和visited,還有一個Stack——waitingForVisit,程式碼中首先將傳入的RDD加入用於進行棧式訪問的waitingForVisit中,這裡使用棧我們也可以看出這是一個深度優先搜尋的策略,visited用於記錄訪問過的節點保證不會重複訪問,接下來對訪問的RDD的依賴進行區分,如果是shuffleDep(即寬依賴),就將依賴加入parents,如果是dependency(窄依賴),則將依賴的RDD加入waitingForVisit進行深度優先搜尋遍歷,這裡最終將返回parents,產生的結果就是parents當中記錄的都是shuffleDep,即兩個Stage之間的依賴。之後根據得到的shuffle dependency來呼叫getOrCreateShuffleMapStage產生ShuffleMapStage,產生的ShuffleMapStage會儲存在shuffleIdToMapStage這個HashMap當中,如果在該資料結構中已經存在建立過的ShuffleMapStage就直接返回,不存在則呼叫createShuffleMapStage進行建立,建立的時候會呼叫getMissingAncestorShuffleDependencies去搜尋祖先shuffle dependency,先將依賴的Stage進行建立。
Stage建立完畢之後,handleJobSubmitted將會呼叫submitStage來提交finalStage,submitStage將會遞迴優先提交父Stage,父Stage是通過getMissingParentStages來獲取的,並按照Stage的id進行排序,優先提交id小的Stage。
具體例子說明
如下圖所示是5個RDD的轉換圖,假設RDD E最後出發了一個action(比如collect),接下來按照圖中的關係仔細講解一下DAGScheduler對Stage的生成過程。
- RDD.collect方法會出發SparkContext.runJob方法,之後呼叫到DAGScheduler.runJob方法,繼而呼叫submitJob方法將這個事件封裝成JobSubmitted事件進行處理,呼叫到handleJobSubmitted,在這個方法中會呼叫createResultStage。
- createResultStage會基於jobId建立ResultStage(ResultStage中的rdd即是出發action的那個RDD,即finalRDD)。呼叫getOrCreateResultStages建立所有父Stage,返回parents: List[Stage]作為父Stage,將parents傳入ResultStage,例項化生成ResultStage。在示意圖中即是RDD E呼叫createResultStage,通過getOrCreateResultStages獲取Stage1、Stage2,然後建立自己的Stage3。
- getOrCreateParentStages方法中的getShuffleDependencies會獲取RDD E的所有直接款依賴集合RDD B和RDD D,然後對這兩個RDD分別呼叫getOrCreateShuffleMapStage,由於這兩個RDD都沒有父Stage,則getMissingAncestorShuffleDependencies會返回為空,會建立這兩個ShuffleMapStage,最後再將這兩個Stage作為Stage3的父Stage,建立Stage3。
- 之後會呼叫handleJobSubmitted中的submitStage來提交Stage,提交的時候採用從後往前回溯的方式,優先提交前面的Stage,並且按照Stage的id優先提交Stage的id小的,後面的Stage依賴於前面的Stage,只有前面的Stage計算完畢才會去計算後面的Stage。
SchedulerBackend和TaskScheduler
之前講到的TaskScheduler和SchedulerBackend都只是一個trait,TaskScheduler的具體實現類是TaskSchedulerImpl,而SchedulerBackend的子類包括有:
- LocalSchedulerBackend
- StandaloneSchedulerBackend
- CoarseGrainedSchedulerBackend
- MesosCoarseGrainedSchedulerBackend
- YarnSchedulerBackend
不同的SchedulerBackend對應不同的Spark執行模式。傳給createTaskScheduler不同的master引數就會輸出不同的SchedulerBackend,在這裡spark實際上是根據master傳入的字串進行正則匹配來生成不同的SchedulerBackend。這裡採用了設計模式當中的策略模式,根據不同的需要來建立不同的SchedulerBackend的子類,如果使用的是本地模式,就會建立LocalSchedulerBackend,而standalone叢集模式則會建立StandaloneSchedulerBackend。StandaloneSchedulerBackend中有一個重要的方法start,首先會呼叫其父類的start方法,之後定義了一個Command物件command,其中有個物件成員mainClass為org.apache.spark.executor.CoarseGrainedExecutorBackend,這個類非常重要,我們在執行spark應用時會在worker節點上看到名稱為CoarseGrainedExecutorBackend的JVM程式,這裡的程式就可以理解為executor程式,master發指令給worker去啟動executor所有的程式時載入的Main方法所在的入口類就是這個CoarseGrainedExecutorBackend,在CoarseGrainedExecutorBackend中啟動executor,executor通過構建執行緒池來併發地執行task,然後再呼叫它的run方法。在start方法中還會建立一個十分重要的物件StandaloneAppClient,會呼叫它的start方法,在該方法中會建立一個ClientEndpoint物件,這是一個RpcEndPoint,會向Master註冊。
SchedulerBackend實際上是由TaskScheduler來進行管理的,createTaskScheduler方法中都會呼叫TaskScheduler的initialize方法將SchedulerBackend作為引數輸入,繫結兩者的關係。
initialize方法當中還會建立一個Pool來初始定義資源的分佈模式Scheduling Mode,預設是先進先出(FIFO)模式,還有一種支援的模式是公平(FAIR)模式。FIFO模式指的是任務誰先提交誰就先執行,後面的任務需要等待前面的任務執行,FAIR模式支援在排程池中為任務進行分組,不同的排程池權重不同,任務可以按照權重來決定執行的先後順序。
TaskScheduler的核心任務是提交TaskSet到叢集運算運算並彙報結果。我們知道,之前所講的DAGScheduler會將任務劃分成一系列的Stage,而每個Stage當中會封裝一個TaskSet,這些TaskSet會按照先後順序提交給底層排程器TaskScheduler進行執行。TaskScheduler接收的TaskSet是DAGScheduler中的submitMissingTasks方法傳遞過來的,具體呼叫的函式為TaskScheduler.submitTasks,TaskScheduler會為每一個TaskSet初始化一個TaskSetManager對其生命週期進行管理,當TaskScheduler得到Worker節點上的Executor計算資源的時候,TaskSetManager便會傳送具體的Task到Executor上進行執行。如果Task執行的過程中出現失敗的情況,TaskSetManager也會負責進行處理,會通知DAGScheduler結束當前的Task,並將失敗的Task再次新增到待執行佇列當中進行後續的再次計算,重試次數預設為4次,處理該邏輯的方法為TaskSetManager.handleFailedTask。Task執行完畢,TaskSetManager會將結果反饋給DAGScheduler進行後續處理。
TaskScheduler的具體實現類為TaskSchedulerImpl,其中有一個方法為submitTasks非常重要,該方法原始碼如下所示:
該方法中會建立TaskSetManager,並通過一個HashMap將stage的id和TaskSetManager進行對應管理。之後會呼叫SchedulableBuilder的addTaskSetManager方法將建立的TaskSetManager加入其中,SchedulableBuilder會確定TaskSetManager的排程順序並確定每個Task具體執行在哪個ExecutorBackend中。submitTasks方法的最後會呼叫backend.receiveOffer,該backend具體型別一般為CoarseGrainedSchedulerBackend,是SchedulerBackend的一個子類,其reviveOffers方法中呼叫的是driverEndPoint.send方法,這個方法會給DriverEndPoint傳送ReceiveOffers訊息,會觸發底層資源排程。 driverEndPoint的receive方法匹配到ReceiveOffers訊息,就呼叫makeOffers方法,該方法如下所示:
該方法會獲取活動的Executor,根據activeExecutor生成所有可用於計算的workOffers,workOffers在建立時會傳入Executor的id,host和可用的core等資訊,可用的記憶體資訊在其他地方已經獲取。makeOffers方法中還呼叫了scheduler的resourceOffers方法,這個方法便是給用於計算的workOffers提供資源,均勻的將任務分發給每個workOffer(Executor)進行計算。在這裡我曾經有一個疑問,就是任務是不是按順序發給每個Executor進行計算的,即假設有100個Task,5個Executor,分發任務的時候是不是總是按照0號Executor、1號Executor、2號Executor……這樣的順序進行分發的,也就是0號Executor總是拿到id為TaskId % 5的任務,1號Executor總是拿到id為TaskId % 5 + 1的任務。但閱讀原始碼發現,其中有一個環節是進行shuffle操作,呼叫的是Random.shuffle(offers),即把workOffers(Executors)在Seq中的順序進行洗牌,避免總是把任務放在同一組worker節點,這一點我們在後續的resourceOfferSingleTaskSet方法中可以很清楚的看到任務具體分發的過程其實就是按照workerOffers在Seq中的順序進行的,在原始碼中就是對workerOffers的一個簡單的for遍歷進行讀取可用的core資源並將可用的資源分發給TaskSetManager用於對應的TaskSet的計算:
原始碼中按照shuffledOffers的索引進行順序遍歷,因為之前已經進行過shuffle操作,workerOffer的順序每次都是打亂的,所以在這裡分配任務時不會總是按照一定的順序給workerOffer分配對應id號的Task,而是會以隨機亂序的方式給workerOffer分配Task,但是任務的分配還會考慮任務的本地性,在分配任務時會將對應的Executor資源輸入給TaskSetManager的resourceOffer方法,該方法會返回需要計算的Task的TaskDescription,這裡很重要的一個依據就是儘量給Executor分配計算本地性高的任務。資料的本地優先順序從高到低依次為:PROCESS_LOCAL、NODE_LOCAL、NO_PREF、RACK_LOCAL、ANY,因此對於一些任務,其資料一直處於某個節點上,因而該任務也會一直分配給該節點上的Executor進行計算,之前對workerOffer進行打亂分配的效果可能看起來就不是特別明顯了,會發現一些任務一直處於某些節點上進行計算。DAGScheduler也會有本地性的考慮,但是DAGScheduler是從資料的層面進行考慮的,從RDD的層面確定就可以,而TaskScheduler是從具體計算的角度考慮計算的本地性,是更具體的底層排程,滿足資料本地性和計算本地性。
在resourceOfferSingleTaskSet方法中我們看到有一個變數CPUS_PER_TASK,之前我一直理解的是一個Task是由一個cpu core進行執行的,但是這個變數實際上來源於配置引數spark.task.cpus,當我們將這個引數設定為2時一個Task會被分配到2個core,從Stack Overflow當中瞭解到這個引數的設定實際上是為了滿足一些特殊的Task的需求,有些Task內部可能會出現多執行緒的情況,或者啟動額外的執行緒進行其他互動操作,這樣設定能夠確保對core資源的總需求在按照設定的情況執行時不會超過一定的設定值(但這裡並沒有強制要求,如果Task啟動的執行緒數大於設定的spark.task.cpus也不會有問題,但可能會因為超過一定的值造成資源搶佔,影響效率)。
進行資源分配的taskSet實際上是有一定的順序的,在TaskSchedulerImpl.resourceOffers方法中呼叫了rootPool.getSortedTaskSetQueue獲取按照一定規則排序後的taskSet進行遍歷處理,這裡的規則就是之前所說的FIFO或FAIR,指的是屬於一個Stage的TaskSet的計算的優先順序。resourceOffers函式一開始也會對每一個活著的slave進行標記,記錄其主機名並跟蹤是否增加了新的Executor,這裡可能的情況是有一些Executor掛掉了重新啟動了新的,需要在有新的TaskSet計算請求時加入到計算資源資訊記錄當中。
任務的資源分配好之後,會獲得Task的TaskDescription,接下來CoarseGrainedSchedulerBackend呼叫launchTasks方法把任務傳送給對應的ExecutorBackend進行執行。
如果任務序列化之後大小超過了maxRpcMessageSize(預設128M)會丟棄,否則根據TaskDescription中記錄的執行該Task的executorId獲取executorData,將其中的freeCores減去執行任務需要的core數,並使用executorEndPoint的send方法傳送LaunchTask給指定的Executor的ExecutorBackend進行執行,LaunchTask是一個case class,其中儲存的內容就是序列化的Task。
參考文獻:
- Spark大資料商業實戰三部曲/王家林,段智華,夏陽編著. 北京:清華大學出版社,2018
- stackoverflow.com/questions/3…