Spark-Job執行流程分析

在“Application啓動流程分析”文章的第4步提到了,driver接收Executor發送RegisterExecutor消息之後,通過makeOffers()任務隨機分發給Executor。Executor(即CoarseGrainedExecutorBackend)收到後會將Task封裝成TaskRunner對象,然後提交到Executor的線程池中去執行。Executor的線程池是:newCachedThreadPool

 

Spark執行作業的大致流程爲:

Spark中的作業會按照RDD間的依賴關係劃分成多個stage,stage的劃分是由DAGScheduler來完成的,劃分的依據是寬依賴(是不是寬依賴根據對RDD操作使用哪個算子決定的,比如map就不是寬依賴)。然後DAGSchedule會按照調度依據數據本地性將任務交給TaskScheduler,由它將任務發送到Work節點,交給Executor執行。

如果某階段失敗,由DAGScheduler調度重新執行,如果某個Task失敗,由TaskScheduler調度重新執行。DAGSchedule還記錄RDD被存到磁盤,數據本地性,監控任務階段等操作。TaskSchedule發現某一任務沒有運行完,可能啓動另外一個相同的任務,哪個先完成哪個結果(這個後續再分析下吧...目前沒看到相關的代碼)。

Spark中的Job分成兩類: 一類是action操作觸發的,另外一個是shuffle操作觸發的,前者返回的是ResultStage,後者返回的是ShuffleMapStage。看代碼的時候可以看到只有Shuffle操作的中間結果會寫磁盤,其餘窄依賴的中間結果不會保存的!

 

作業執行源碼分析:

接下來以這個簡單的代碼爲例,對執行作業的源碼進行分析,作業代碼如下:

發現parallelize和map,filter等函數基本上都會調用到withScope()和sc.clean(f)這兩個函數,先來了解下這兩個函數是幹嘛用的。

 

WithScope{}:

這個方法是做DAG可視化用的,看下它的註釋,發現它是想讓所有created RDD的操作都在這個方法中進行。方法內部對輸入的方法沒有進行過任何的改動,所以不會影響方法的執行邏輯,所以這個函數我們可以不管它。

從代碼中可以看出,map函數的返回其實是一個MapPartitionRDD,所以map會被封裝在WithScope裏面

 

sc.clean(f):

它是爲了在分佈式環境上正確的閉包。閉包會把它對外的引用保存到自己內部,這樣閉包就可以單獨使用,而不用擔心它脫離了當前作用域。但是在分佈式環境中,如果外部引用是無法serializable的,就不能正確被髮送到Worker節點上去了。這個函數就是清理外圍類中無用域,降低序列化的開銷,防止不必要的不可序列化異常。(瞭解下就可以了,畢竟不是我們要關注的重點)

 

1.構建各個RDD之間的依賴關係,調用算子時完成。

parallelize()構造的RDD,返回一個ParallelCollectionRDD,這個沒啥好說了,new了一個對象而已。

沒有指定partition數量的情況下,默認生成多少個partition是由***SchedulerBackend類中根據如下方法計算的:

當代碼跑到count()操作的時候,我們發現在最後那個RDD(代碼中filter()方法返回的那個RDD)中已經完整的記錄了與所有父類RDD的依賴關係,並且記錄父類RDD是調用哪些方法生成的,對應的代碼在哪兒:

後來調細了之後才發現,rdd.map(***)的代碼最終會跳轉到RDD.scala裏面,然後構建RDD的依賴關係。也就是說創建RDD對象的時候,就已經記錄了該RDD和RDD的依賴關係,並且根據Dependency類型可以知道是寬依賴還是依賴

可以看到,最終會使用當前RDD的引用構建一個OneToOneDependency對象,Dependency意思是RDD依賴關係,OneToOneDependency代表窄依賴,RDD對象之間的依賴關係官方術語叫Lineage。

 

2.action操作觸發Job計算,將rddcleandFunc、所有partitions對應 編號整理partition結果集的函數等信息傳遞給dagScheduler這一步是Job執行前的準備

在提交任務時已經構造好了存放返回結果集的數據結構,每個partition對應一個結果,所有的結果彙總到一個Array[U]中。如下圖所示:輸入是Partition編號,輸出是res,res是任意類型,然後將res賦值給results(index)

任務交給dagScheduler進行調度(全部完成之後做清理和檢查是否要checkPoint()的操作):

 

3.dagScheduler執行submitJob()方法將DAGScheduler自身引用jobIdpartition size以及處理partition結果集函數這四個信息封裝到一個JobWaiter對象,並將這個對象交eventProcessLoop內部類進行提交然後提交任務的線程會阻塞等待Job完成,然後根據Job的返回狀態打印對應日誌。(ps:JobWaiter對象還可以用於取消本次Job)

submitJob()內部創建了一個JobWaiter對象,這個對象會立即返回。它是用於管理Job狀態用的,例如Finish、cancel,相當於JobWaiter是一個回調對象:

JobWaiter對象的定義如下:

注意,submitTask()方法中,任務是通過eventProcessLoop.post(JobSubmitted(...))提交的,這是向消息隊列中放入作業,然後執行對應的邏輯。eventProcessLoop這個對象是在new DAGSchedule時創建的,本質是一個單獨的線程。最終會調用到dagScheduler中的handleJobSubmitted()方法。

那不還是要讓dagScheduler執行麼?爲啥要單獨再開一個線程 – 按照網上找到的說法,這裏是爲了處理方式的統一,不管是別人的消息還是自己的消息統一放在一個地方處理利於擴展,並且代碼也會很乾淨。

 

4.eventProcessLoop線程會調用dagScheduler中的handleJobSubmitted()方法。使用createResultStage()劃分stage,該方法返回List[Stage]

createRsultStage()方法創建所有的stage創建stage的方法是從最後一個RDD生成的ResultStage開始,使用getOrCreateParentStages()找出其祖先RDD所有的shuffle操作如果沒有Shuffle,當前job只有一個ResultStage。如果有shuffle,那麼當前job至少還有一個ShuffleMapStage有ShuffleMapStage代表該ResultStage存在父調度階段

創建好所有parentStages(即ShuffleMapStage之後),放到一個List中返回,越靠近當前RDD的ShuffleMapStage越在前面,最後才創建action操作對應的ResultStage。

我們來看下getOrCreateParentStages()方法內部是怎麼劃分出當前RDD的所有ShuffleDependency的:

該方法中首先調用了getShuffleDependencies()方法,它目的在於獲取該rdd的第一個寬依賴。舉個例子:如果 A => B => C,那麼返回的就是B => C的寬依賴,而不會返回A => B的寬依賴。

 

確實存在shuffle,則判斷對應的ShuffleMapStage的信息是否已存在(創建),如果已存在就直接返回,否則需要接着執行getMissingAncestorShuffleDependencies()方法,計算從shuffleDep所在RDD開始往前回溯的所有父RDD的ShuffleDependency信息

getMissingAncestorShuffleDependencies()用於查找沒有在shuffleToMapStage中註冊的所有祖先shuffle依賴。其實就是遞歸的調用上面的getShuffleDependencies()方法,將寬依賴一個個找出來,所以你會發現它們的代碼很像,就只有下面標紅的那一點不一樣:

最後調用createShuffleMapStage()方法爲每一個ShuffleDependency創建一個ShuffleMapStage,放入到List[Stage]中。當前RDD越遠的寬依賴越在棧頂,所以計算stage是從後往前計算的,即最開始的RDD最先被計算。

創建ShuffleMapStage的代碼如下,從中可以看出,RDD有多少個Partition就會對應有多少個Tasks。一個ShuffleMapStage會記錄所有的父Stage以及當前RDD的shuffleDep信息。最後將該stage註冊到mapOutputTracker中,這樣做是避免stage重試的時候全部重新計算 

通俗點說就是一個ShuffleMapStage會記錄它自己和它的祖先們是怎麼來的。至此就已經完成了Stage的劃分註冊

 

5.Stage劃分完成之後,接着調用handleJobSubmitted()方法中的submitStage()方法提交finalStage(即ResultStage)。方法內部其實依次遞歸的解析和提交每個stage所依賴的父stage,所以最終最先提交的是沒有任何依賴的stage(開始的那一個stage)

getMissingParentStages()方法和之前創建stage的方法類似,這裏就不再對它進行詳細分析了。它的作用就是看下該stage的父stage如果沒有提交的話,先提交父stage。我們直接看submitMissingTasks(stage, jobId.get)方法看下提交一個stage到底要。

 

6.按Stage的順序提交Stage,根據Partition的數據本地性每一個Stage構建對應的TaskLocation,將構建好之後的Stage序列化發送到Spark的各節點。(注意如果Stage是重新提交的話,已經Success的partition是不需要重新計算的)

首先判斷多少個Partition需要計算,側面說明了stage中某個partition完成之後是會做一個標記的,這樣做是爲了避免stage重提時的重複計算。輸出的是0-job.numPartitions這樣的partiton編號。

將stage的信息(id+partitionNum)記錄到outputCommitCoordinator,該變量是在SparkEnv中,具體幹啥用的還不太清楚……

然後爲每個Partition找到最佳執行位置,即考慮到數據本地性。該方法會遞歸調用父RDD的getPreferredLocations(split:Partition)找到最佳執行位置。(數據本地性怎麼確定,後續“補充”那裏有講到)

然後將stage的信息序列化,broadcast到各個Executor上。

最後根據stage需要compute的Partition的數量對應創建多少個Task這些Task集中放到Seq裏面。Task會記錄locality等信息。對於ResultStage生成ResultTask,對於ShuffleMapStage生成ShuffleMapTask。

最後的最後,將這些Seq[Task[_]]交給TaskScheduler執行;或者當前stage完成,提交下一個stage。

 

7.TaskSet交給TaskScheduler的調度池再調用***SchedulerBackendreviveOffers()給這些Task分配資源執行Task

Task是由TaskManager來管理的,每批次TaskSet都會新建一個TaskManager,然後加入到調度器統一調配。調度器的種類有兩種,在初始化SparkContext的時候創建的,默認的是FIFO,可以看下之前的初始化SparkContex那篇文章。

這裏的rootPool是個啥????它是在SparkContex初始化TaskSchedulerImpl時創建的,好像是個隊列…

最後調用***SchedulerBackend中的reviveOffers(),該方法估計又是爲了統一,反正最終又執行到了***SchedulerBackend 中的makeOffers()給Task分配資源然後執行:

makeOffers()會先獲取集羣中可用的Executor,然後發送到TaskSchedulerImpl中對任務集的任務分配資源,最後提交到LaunchTask方法中:

資源分配的過程是這樣的:(補充裏面其實講到了)

給每個Task創建Description的代碼如下,創建Description相當於是說這個Task要在哪個Executor上運行,並且數本地性怎樣…不知道這個屬性用不用的上:

然後就調用launchTasks,給對應Executor發送消息,讓Executor執行任務了。

 

8.Executor反序列化Task信息(TaskDescription),構造一個TaskRunner對象(Runnable)然後扔到線程池中執行Task

 

看下TaskRunner中的run()方法,看下任務內部到底是怎麼執行的:

首先是序列化任務依賴的jar包以及文件,因爲我是把所有的依賴都打到一個包裏面的,所以這裏看到只有一個with-dependencies.jar的整包。接着是執行Task,Task是一個抽象類,真正執行的是ShuffleMapTask或者ResultTask中的方法。執行完畢之後,將結果發送回Driver。

 

來細看下ShuffleMapTaskResultTask分別是怎麼執行的 

先看ResultTask:

它在計算時會調用func(context,rdd.iterator(partition,context)),其中rdd.iterator會遞歸調用調用RDD的compute(),最終會從第一個RDD的元素開始計算。從代碼調試的結果也可以看出,確實是遞歸執行的。

 

再看下ShuffleMapTask:

它返回給Driver的是MapStatus,它是將中間結果寫到文件,然後將這些文件的位置返回給Driver。

這個涉及到Shuffle內部的細節後續分析到Shuffle的時候再細講

 

根據之前的描述,梳理下Job執行的流程圖:

 

補充:Task的數據本地性介紹

Spark在處理任務時會考慮數據的本地性,畢竟移動計算比移動數據代價要小,利於提升程序的執行效率。Spark的數據本地性共劃分爲五種情況:

  1. PROCESS_LOCAL    同一個Executor中,速度最快
  2. NODE_LOCAL   本地,數據在其他Executor中/HDFS恰好有個Block
  3. NO_PREF 沒有偏好,哪裏訪問都也一樣快
  4. RACK_LOCAL 本地機架,需要通過網絡傳輸
  5. ANY 任何,速度最慢

之前劃分stage的時候說過,在爲每個Partition構建對應的Task信息(即TaskLocation)時會執行getPreferredLocations(split:Partition)方法,確定Partition優先位置,代碼邏輯如下:

該方法的返回是一個TaskLocation變量,這是一個trait,它有三個實現類,分別代表數據存儲在不同的位置,從上到下它們的含義分別是:

  1. 數據存儲在Executor內存中,即Partition被cache到了內存中(返回executorId+host)
  2. 數據存儲在HDFS上(返回host)
  3. 數據存儲在host這個節點的磁盤上(返回“hdfs_”+host)

知道了partition的本地性之後,接着就是將任務加入到隊列計算整個TaskSet本地性了,計算是在submitTask()方法構建TaskSetManager時進行的:

 

接着會執行上面說過的步驟7中的第6步再細講下到低是怎麼爲每一個TaskSet分配資源的: resourceOfferSingleTaskSet(),爲每一個TaskSet分配資源:

(怎麼爲每個Task分的?這裏後續補充下吧)

從編寫的代碼運行邏輯中也可以看出,因爲我們的RDD是第一次計算並且沒有真正cache過(真正cache是指第一次action操作觸發之後,會記錄信息到cacheLocs裏面,第二次纔會從這個裏面找信息),所以不會走第一個方法。而且我們的RDD也沒有checkpoint也不是最開始數據源最近拉數據的那個RDD,所以會走第三個方法,遞歸的去找數據:

任務執行時,本地性可以在Spark的UI界面直接看到:

回答之前留下的幾個疑問:

問題1:收到RegisterExecutor消息的時候,也會調用makeOffer()。兩者哪個在前哪個在後,優先看下sparkContext是怎麼啓動的吧,不然又是一頭霧水:…確實是SparkContext啓動在前,但是那個時候是沒有任務的,發送的空的,所以它是在等待執行具體的task的時候用的。

 

問題2:注意下,這個***ScheduleBackend是在driver端初始化用於進行一些資源管理的,CoarseGrainedExecutorBackend是在Executor端進行初始化,它是Executor運行的容器。兩者名字很像,但是功能是不一樣的。(看過SparkContext啓動流程之後,這兩個幹嘛用的就特別清晰了,輕者是管理任務怎麼分配什麼的,收集Worker的信息,計算Executor和Driver啓動在哪些Worker上,Task扔給哪些Executor執行)

 

問題3:最後就是之前提到過的……Stage完成之後,DAGSchedule會調用handleTaskCompletion方法,根據Stage返回的結果判定是否是 Success/Resubmitted/FetchFailed…然後進行相應的處理。

 

 

遺留的問題:

  1. ShuffleMapTask調用write的時候是如何進行Shuffle的?
  2. RDD的checkpoint是怎麼操作的?
  3. Job執行的過程中,內存是怎麼管理的?
  4. map之類的dep比較好構建,groupByKey的依賴是怎麼構建的?
  5. Executor中途掛了怎麼辦?

後續博客分析這些問題

 

參考:

http://www.mamicode.com/info-detail-1066067.html(withScope的作用)

https://www.jianshu.com/p/51f5a34e2785(sc.clean(f)的作用)

https://www.cnblogs.com/jcchoiling/p/6438435.html(DAGSchedule中爲什麼要單獨再開一個線程處理消息)

https://www.jianshu.com/p/8e7cd025d0ba(getProferLocation()方法解析)

http://www.cnblogs.com/chushiyaoyue/p/7468952.html(SparkContex初始化的時候調用makeOffers())

https://blog.csdn.net/qq_41774522/article/details/81707613(Spark執行流程圖,很詳細)

https://www.jianshu.com/p/05034a9c8cae/(Task數據本地性介紹)

 

 

 

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