從源碼剖析一個Spark WordCount Job執行的全過程

原文:http://mzorro.me/2015/08/11/spark-wordcount-analyse/

從源碼剖析一個Spark WordCount Job執行的全過程

WordCount可以說是分佈式數據處理框架的”Hello World”,我們可以以它爲例來剖析一個Spark Job的執行全過程。

我們要執行的代碼爲:

1
sc.textFile("hdfs://...").flatMap(_.split(" ")).map((_, 1)).reduceByKey(_+_).collect

只有一行,很簡單也很經典的代碼。這裏的collect作爲一個action,將觸發一個Job,現在我們從源碼開始剖析這個Job執行的全部過程。我這次讀的源碼是Spark 1.4.1的release版本。

爲了方便描述,我們把上面的代碼先進行一下拆分,這樣可以清晰的看到每一步生成的RDD及其依賴關係,並方便下面分析時進行引用:

1
2
3
4
5
val hadoopRDD0 = sc.textFile("hdfs://...") // HadoopRDD[0]
val mapPartitionsRDD1 = hadoopRDD0.flatMap(_.split(" ")) // MapPartitionsRDD[2]
val mapPartitionsRDD2 = mapPartitionsRDD1.map((_, 1)) // MapPartitionsRDD[2]
val shuffledRDD3 = mapPartitionsRDD2.reduceByKey(_+_) // ShuffledRDD[3]
shuffledRDD3.collect // action

collect觸發Job

首先,collect調用了SparkContext上的runJob方法。這個方法是一個阻塞方法,會在Job完成之前一直阻塞等待,直到Job執行完成之後返回所得的結果:

RDD.collect

1
2
3
4
def collect(): Array[T] = withScope {
val results = sc.runJob(this, (iter: Iterator[T]) => iter.toArray)
Array.concat(results: _*)
}

需要注意的是這裏傳入了一個函數,這個函數就是這個Job的要執行的任務。後面我們可以看到,它將會被包裝並序列化後發送到要執行它的executor上,並在要處理的RDD上的每個分區上被調用執行。

DAGScheduler提交Job

SparkContext的runJob被調用之後,這個Job的信息被遞傳給了SparkContext持有的一個DAGScheduler上。DAGScheduler本身維護着一個消息隊列,在收到這個Job之後,將給自己的消息隊列發送一個JobSubmitted消息。這個消息中包含了新生成的一個JobId, 觸發action的RDD,經過清理後的閉包函數,要處理的各個分區的在RDD中的索引,以及一些其他信息。

DAGScheduler的消息隊列在收到JobSubmitted消息後,將觸發調用handleJobSubmitted方法。在這個方法中,首先會根據這個觸發action的RDD的依賴信息計算出這個Job的所有Stage。在這個WordCount中,我們是在reduceByKey生成的shuffledRDD3(其生成的過程涉及到通用的combineByKey方法,具體可以參考這篇文章)上觸發的action,所以我們的ResultStage所對應的finalRDD就是shuffledRDD3,ResultStage所要執行的就是shuffledRDD3的所有分區。shuffledRDD3有一個ShuffleDependency,指向mapPartitionsRDD2,據此ShuffleDependency會生成一個ShuffleMapStage,它是ResultStage的父Stage。

根據繼承關係分析Stages

在分析出所有的Stage之後,DAGScheduler會根據ResultStage創建出一個ActiveJob對象,用來表示這個活躍的Job。然後提交ResultStage,但是在真正執行這個Stage之前,先遞歸的判斷它有沒有父Stage,若有的話先提交它的父Stage,並將當前Stage加入等待隊列;若沒有父Stage,纔會真正的開始執行這個Stage。等待隊列中的Stage,會在父Stage都執行完成之後再被執行。

由此可以看出,在一個Job中,Stage之間必須按序執行,後一個Stage的執行將依賴前一個Stage的結果。一個Job只會有一個ResultStage,並且這個ResultStage一定會是整個Job的最後一個Stage,所以ResultStage執行的結束也就標誌着整個Job的結束。

Task的創建和提交

按照之前的分析,我們的Job一共有兩個Stage,一個ShuffleMapStage,一個ResultStage,並將先執行ShuffleMapStage。在執行Stage的時候,會按此Stage對應的RDD的分區數量,對應每一個分區創建一個Task。如果是ShuffleMapStage則創建ShuffleMapTask,如果是ResultStage則創建ResultTask。這些Task在後面將會被序列化後發到其他的executor上面去運行。

在這裏分析一下每個Task包含哪些信息
兩種Task都會包含的信息有 (1)當前Stage對應的RDD對象(輕量級) (2)當前Stage的ID (3)要處理的那個分區信息(輕量級),以及該任務可能的最優執行位置(例如,對於hdfs上的文件,HadoopRDD中會記錄其每一個分區存儲在集羣的位置,並將這個位置通過依賴繼承到其子RDD)

除此之外,ShuffleMapTask還包含了對應的ShuffleDependency的對象(這其中實際上有分區的方法,數據合併的方法等計算時所需的信息);ResultTask還包含了當前這個Job最終要執行在每個數據上的函數(在此情況下就是collect傳給SparkContext的那個函數)。

在對每個要處理的分區創建出各個Task之後,DAGScheduler會將同一個Stage的各個Task合併成一個TaskSet,並將其提交給TaskScheduler。至此,調度這些Task的工作就交給了TaskScheduler來進行。

TaskScheduler在收到這個TaskSet之後,首先爲其創建一個TaskSetManager,這個TaskSetManager將輔助任務的調度。然後TaskScheduler將會調用SchedulerBackend上的reviveOffers方法去申請可用的資源。

SchedulerBackend分配資源(executors)和發送Task

SchedulerBackend是一個接口,它在不同的部署模式下會有不同的實現(實際上TaskScheduler也是這樣)。SchedulerBackend的作用是調度和控制整個集羣裏面的資源(我是這麼理解的,這裏的資源指的是可用的executors),當reviveOffers方法被調用後,它會將當前可用的所有資源信息,通過調用TaskScheduler的resourceOffers提供給TaskScheduler(實際上這個過程是通過另一個EndPoint類以消息隊列的方式實現的,這樣可以保證同時只會進行一個對資源的申請或釋放過程)。

TaskScheduler在收到當前所有可用的資源信息後,會將這些資源信息按序提供給當前正在執行的多個TaskSet,每個TaskSet再根據這些資源信息將當前可以執行的Task序列化後包裝到一個TaskDescription對象中返回(這個TaskDescription對象中也包含了這個任務將要運行在哪個executor上),最終通過TaskScheduler將所有當前的資源情況可以執行的Task對應的TaskDescription返回給SchedulerBackend。

SchedulerBackend這時才根據每個TaskDescription將executors資源真正的分配給這些Task,並記錄已分配掉的資源和剩餘的資源,然後將TaskDescription中序列化後的Task通過網絡(Spark使用akka框架)發送給它對應的executor。

executor執行Task

集羣中的executor在收到Task後,申請一個線程開始運行這個Task。這是整個Job中最核心的部分了,真正的計算都在這一步發生。首先將其反序列化,然後調用這個Task對象上的runTask方法。在這裏對於ShuffleMapTask和ResultTask,runTask方法有着不同的實現,並將返回不同的內容。我們分別來分別分析。

對於ShuffleMapTask,runTask首先獲取對應的RDD和ShuffleDependency。在這裏對應的RDD是mapPartitionsRDD2,ShuffleDependency中則有着合併的計算信息。然後調用RDD的iterator方法獲取一個對應分區數據的迭代器。如果當前RDD分區的數據已經在之前計算過了,則會直接去內存或磁盤中獲取,否則在此時就會調用mapPartitionsRDD2的compute方法,根據其依賴去計算它的分區數據。如果ShuffleDependency中的mapSideCombine標記爲true,就會將iterator方法返回的分區數據在這裏(也就是map端)進行合併(此時要求ShuffleDependency中的aggregator不爲空,aggregator中包含了如何將數據進行合併的信息)。然後根據ShuffleDependency中的partitioner(默認是一個HashPartitioner)計算出每條數據在其結果端(就是shuffleRDD3中)的分區,並將其寫入到本地磁盤中對應的文件中去(在這裏寫入方法有多種實現方式,1.4.1的版本默認是用了SortShuffleManager,還有的其他實現是HashShuffleManager和UnsafeShuffleManager,具體的實現方法在此處就不詳說了)。當分區的每條數據都處理完後,runTask會返回一個MapStatus,這其中包含了一個BlockManagerId(標記了這個任務被執行的位置,也就是Map後的數據存儲的位置)以及每個結果分區(每個reduceId)的數據的大小信息。最後這個MapStatus將通過網絡發回給driver,dirver將其記錄。

ShuffleMapTask.runTask

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
override def runTask(context: TaskContext): MapStatus = {
// Deserialize the RDD using the broadcast variable.
val deserializeStartTime = System.currentTimeMillis()
val ser = SparkEnv.get.closureSerializer.newInstance()
val (rdd, dep) = ser.deserialize[(RDD[_], ShuffleDependency[_, _, _])](
ByteBuffer.wrap(taskBinary.value), Thread.currentThread.getContextClassLoader)
_executorDeserializeTime = System.currentTimeMillis() - deserializeStartTime
metrics = Some(context.taskMetrics)
var writer: ShuffleWriter[Any, Any] = null
try {
val manager = SparkEnv.get.shuffleManager
writer = manager.getWriter[Any, Any](dep.shuffleHandle, partitionId, context)
writer.write(rdd.iterator(partition, context).asInstanceOf[Iterator[_ <: Product2[Any, Any]]])
return writer.stop(success = true).get
} catch {
case e: Exception =>
try {
if (writer != null) {
writer.stop(success = false)
}
} catch {
case e: Exception =>
log.debug("Could not stop writer", e)
}
throw e
}
}

對於ResultTask,runTask首先也是獲取對應的RDD和要在數據上執行的函數func。在這裏對應的RDD應該是shuffleRDD3,然後調用RDD上的iterator獲取這個分區的數據,並將其傳入func函數中,將func函數的返回值作爲runTask的返回值返回。過程看似簡單,實際上在shuffleRDD3上調用iterator時就對應了shuffle的reduce端的合併。從shuffleRDD3的compute方法的實現可以看出,它的每個分區的數據都要去執行了ShuffleMapTask的executor上面獲取,所以會產生大量的網絡流量和磁盤IO。這個過程就是MapReduce範式中的shuffle過程,這裏面還有很多的細節我並沒有詳述,但是這個過程十分關鍵,它的實現效率直接決定了分佈式大數據處理的效率。

ResultTask.runTask

1
2
3
4
5
6
7
8
9
10
11
override def runTask(context: TaskContext): U = {
// Deserialize the RDD and the func using the broadcast variables.
val deserializeStartTime = System.currentTimeMillis()
val ser = SparkEnv.get.closureSerializer.newInstance()
val (rdd, func) = ser.deserialize[(RDD[T], (TaskContext, Iterator[T]) => U)](
ByteBuffer.wrap(taskBinary.value), Thread.currentThread.getContextClassLoader)
_executorDeserializeTime = System.currentTimeMillis() - deserializeStartTime
metrics = Some(context.taskMetrics)
func(context, rdd.iterator(partition, context))
}

executor返回結果

在runTask計算結束返回數據後,executor將其返回的數據進行序列化,然後根據序列化後數據的大小進行判斷:如果數據大與某個值,就將其寫入本地的內存或磁盤(如果內存不夠),然後將數據的位置blockId和數據大小封裝到一個IndirectTaskResult中,並將其序列化;如果數據不是很大,則直接將其封裝入一個DirectTaskResult並進行序列化。最終將序列化後的DirectTaskResult或者IndirectTaskResult遞傳給executor上運行的一個ExecutorBackend上(通過statusUpdate方法)。

ExecutorBackend如上面的SchedulerBackend有着相似的功能(實際上,對於local模式,這兩個類都由一個LocalBackend實現),將結果封入一個StatusUpdate消息透傳給一個對應的EndPoint類,EndPoint類中收到這個消息後將該消息再通過網絡發送給driver。

driver接收executor返回的結果並釋放資源

在driver端的SchedulerBackend收到這個StatusUpdate消息之後,將結果續傳給TaskScheduler,並進行資源的釋放,在釋放資源後再調用一次reviveOffers,這樣又可以重複上面所描述的過程,將釋放出來的資源安排給其他的Task來執行。

TaskResultGetter解析並拉取結果

TaskScheduler在收到任務結果後,將這個任務標記爲結束,然後使用一個TaskResultGetter類來進行結果的解析。TaskResultGetter將結果反序列化,判斷如果其是一個DirectTaskResult則直接抽取出其中的結果;如果是一個IndirectTaskResult則需要根據其中的blockId信息去對應的機器上拉取結果。最終都是將結果拉取到driver的內存中(這就是我們最好不要在大數據集上執行類似collect的方法的原因,它會將所有的數據拉入driver的內存中,造成大量的內存開銷,甚至內存不足)。然後TaskResultGetter會將拉取到的結果遞交給TaskScheduler,TaskScheduler再將此結果遞交給DAGScheduler。

處理結果並在Job完成時返回

DAGScheduler在收到Task完成的消息後,先判斷這完成的是一個什麼任務。如果是一個ShuffleMapTask則需要將返回的結果(MapStatus)記錄到driver中,並判斷如果當前的ShuffleMapStage若是已經完成,則去提交下一個Stage。如果是一個ResultTask完成了, 則將其結果遞交給JobWaiter,並標記這個任務以完成。

JobWaiter是DAGScheduler在最開始submitJob的時候創建的一個對象,用於阻塞等待任務的完成,並進行結果的處理。JobWaiter在每收到一個ResultTask的結果時,都將結果在resultHandler上執行。這個resultHandler則是由SparkContext傳進來的一個函數,其作用是將數據放入一個數組中,這個數組最終將作爲SparkContext.runJob方法的返回值,被最開始的collect方法接收然後返回。若JobWaiter收到了每個ResultTask的結果,則表示整個Job已經完成,此時就停止阻塞等待,於是SparkContext.runJob返回一個結果的數組,並由collect接收後返回給用戶程序。

至此,一個Spark的WordCount執行結束。

總結

本文從源碼的角度詳細分析了一個Spark Job的整個執行、調度的過程,不過很多東西還只是淺嘗輒止,並未完全深入。儘管如此,經過連續好幾天的分析,我還是覺得收穫頗豐,對Spark的實現原理有了更加深入的理解,甚至對MapReduce的編程範式以及其shuffle過程也增加了不少理解。PS:其實從一開始我到分析結束都是沒有做任何記錄的,只因爲一直一知半解實在不知道如何來做記錄,所以只是去查閱一些資料和使勁兒的閱讀源碼。在我自認爲分析結束後,我纔開始寫這篇記錄,但是在寫的過程中我才發現我分析的過程有一些並不是很清晰,然後重新去看,才真正弄的比較清晰了。可見寫博文是很重要的過程,不僅是將學到的知識分享出來,而且對自身的知識也有很好的加固作用。


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