每一個Spark應用都會創建一個sparksession,用來跟Spark集羣交互,如果提交任務的模式爲cluster
模式,則Driver
進程會被隨機在某個worker結點上啓動,然後真正執行用戶提供的入口類,或是使用Spark內置的入口類,同時會在Driver進程創建SparkContext
對象,提供Spark應用生命週期內所涉及到的各種系統角色。
SparkContext
使用Spark功能的主入口,用戶可以通過此實例在集羣中創建RDD、求累加各以及廣播變量。
由於Driver
進程會真正執行Spark應用的入口程序,因此它只會在driver端被創建。
默認情況下,一個JVM環境只會創建一個SparkContext實例,但用戶可以修改spark.driver.allowMultipleContexts
的默認值,來啓用多個實例。SparkContext內部會啓動多個線程來完成不同的工作,比如分發事件到監聽者、動態分配和回收executors、接收executor的心跳等,因此需要用戶主動調用stop()
接口來關閉此實例。
當用戶通過Action函數觸發RDD
上的計算時,(通常指count
、sum
、take
等的返回結果爲非RDD的函數),便會生成一個Job
,被提交到Spark集羣運行。而提交Job的入口便是此實例對象,比如RDD中的count()
方法的定義如下:
/**
* Return the number of elements in the RDD.
* sc 是SparkContext實例的變量名
*/
def count(): Long = sc.runJob(this, Utils.getIteratorSize _).sum
可以看到當一個Action方法被調用時,會通過調用SparkContext
的runJob方法,並將當前RDD作爲函數參數,觸發計算過程,最終通過一系列地包裝,將Job
任務的生成交由DAGScheduler
對象。
DAGScheduler
面向Stage的調度器,它會爲每一個Job計算相應的stage,並跟蹤哪個RDD和Stage的輸出結果被物化了,並找到一個最小的調度方案來跑Job。DAGScheduler會把所有的stage封裝成一系列的TaskSet
,提交給底層的一個TaskScheduler
的實現類,由它來負責任務的執行。TaskSet
包含了完整的依賴任務,這些任務都可以基於已經計算出現的數據結果,比如上游Stage的map任務的輸出,完成自己的工作,即使這些任務會由於依賴數據不可用而失敗。
Spark Stage的生成是通過在RDD圖中劃分shuffle邊界而得到的。RDD的窄依賴,比如map()和filter()方法,會被以流水線的方式劃分到一個stage的任務集中(TaskSet),但有shuffle行爲的操作需要依賴多個其它的stage的完成(比如一個stage輸出一組map數據文件,而另外一個stage會在被阻塞之後讀取這些文件)。最終每一個stage都只會與其它stage有shuffle依賴,而且其可能會執行多個操作。
只有當各種類型的RDD之上的RDD.compute()
方法被觸發時,纔會真正地開始執行整個流水線。
另外對於一個DAG圖中的所有stage,DAGScheduler也會根據當前集羣的狀態來決定每一個任務應該在哪個地方執行,最終纔會把這些任務發送到底層的TashScheduler
。同時DAGScheduler會保證當一個stage的所需要的shuffle數據文件丟失時,同跑上游的stage,而TaskScheduler會保證當一個stage內部產生錯誤時(非數據文件丟失)會嘗試重跑每個任務,在一定次數的嘗試之後,取消整個stage的執行。
在嘗試查看這部分的實現時,有如下一些概念需要明確:
- Jobs (內部以
ActiveJob
表示,兩類),上層提交給調度器的工作單元。比如調用count()
方法時,一個新的Job就會生成,而這個Job會在執行了多個stage之後生成中間的數據。 - Stages(內部以
Stage
表示),包含了一組合任務集(TaskSet
),每一個stage會生成當前Job所需的中間結果,而每一個任務都會在同一個RDD數據集的所有Partition(分區)數據塊上執行同一個方法。而由shuffle邊界(Shuffle Boundries,或稱之爲Barrier屏障)所分隔的stage,會所有barrier之前的stage執行完成之後纔開始執行。因此這裏有兩類的Stage,一個是ResultStage
,表示最後一個stage,輸出action方法的結果;另一個是ShuffleMapStage
,爲每一個shuffle操作輸出數據文件。通常這些Stage產生的結果都是可以跨Job共享的,由於這不同的Job使用了同一份RDD數據。
每一個Stage都有一個firstJobId
域,用於標識第一次提交當前Stage的Job的ID,如果使用的是FIFO的調度方式,通過此域可以提供首先執行前置Job中的stage或是有失敗時快速恢復的能力。
最終一個Stage能夠在失敗時嘗試恢復執行多次,因此Stage對象必須跟蹤多個StageInfo
對象,然後轉發給listeners(監聽器)或是WEB UI。 - TaskSet,包含一組有依賴關係的任務,同時也表示這些任務都有共同的shuffle依賴,通常構成一組流水線操作,比如map().filter(),或表示一個特定stage丟失的分區。
- Tasks,最小的執行單元,會被分發到每個機器上執行。
- Cache tracking,記錄了有哪個RDD已經被緩存了,以避免重複計算;同時也記錄了哪些ShuffleMapStage已經完成並生成了數據文件,以避免重複執行shuffle階段的map任務。
- Preferred locations: DAGScheduler會根據底層RDD,或是cache或shuffle的數據的位置來計算一個Stage中每個任務最優地執行節點。
- Cleanup:爲了避免一個長週期運行的應用可能導致內存泄漏的問題,正在運行的Job完成時所依賴的所有數據結構都會被清理。
爲了失敗時能夠故障恢復,一個stage可能被提交多次,稱爲attempts
。如是TaskScheduler
報告某個任務由於FetchFailed
或是ExeuctorLost
事件而失敗時,DAGScheduler會重新提交失敗的stage。
DAGScheduler
接收到來自SparkContext
對象提交任務的請求後,會根據傳遞過來的RDD的paritions數量、用戶提供的方法函數、JobId等信息,通過逆拓撲序的方法,遞歸地構建Stage
、TaskSet
。
提交Stage的過程描述如下:
- 首先從ResultStage開始,提交Stage執行,首先嚐試創建其父依賴的ShuffleMapStage
- 遍歷當前Stage的父依賴,如果是NarrowDependency類型的依賴,則將其綁定的RDD添加到待遍歷隊列,以便繼續回溯查找其父依賴,直到找到一個最近的ShuffleMapStage,否則若爲ShuffleDependency,則跳轉到步驟3
- 調用getOrCreateShuffleMapStage()方法,先嚐試從緩存的ShuffleMapStage中,按傳遞進來的ShuffleDependency的shuffleId字段,查找是否已經被創建過,有則返回,沒有跳轉到步驟4
- 找到當前父ShuffleDependency所包含的rdd的所有未創建過對應ShuffleMapStage的祖先ShuffleDenpendency,爲這些祖先依賴創建ShuffleMapStage,遞歸重複步驟2,否則如果所有的祖先依賴都已經被創建,由執行步驟5
- 創建當前ShuffleDependency對應的ShuffleMapStage,並綁定所有的直接父Stage
- ResultStage對應的所有父ShuffleMapStage創建成功後,並將這些父依賴Stage作爲參數創建最後的ResultStage
至此,所有可能的祖先ShuffleMapStage被依次創建完畢,每一個ShuffleMapStage只包含以下其直接父依賴所對應的ShuffleMapStage。
## rdd方法調用圖
rdd1 -- map() --> rdd2 -- filter() -> rdd3 -- groupbykey() --> rdd3
\ \
\ \
filter() --> rdd4 -- reducebykey() --> rdd5 -- join() --> rdd6 -- count() --> result
## rdd方法調用圖轉換成依賴圖的結果如下
## ndep == narrow dependency
## sdep == shuffle dependency
rdd1 -- ndep --> rdd2 -- ndep -> rdd3 -- [sdep] --> rdd3
\ \
\ \
ndep --> rdd4 -- [sdep] --> rdd5 -- ndep --> rdd6 -- action --> result
## 如果result stage所對應的RDD的直接父依賴,不是ShuffleDependency,則繼續回溯父依賴(NarrowDependency)的父依賴,
## 直到找到了每個直接父依賴可能存在的、離result stage最近的依賴,如stage1, stage2,則算完成第一輪創建。
## 然後再遞歸創建stage1、stage2所對應的父ShuffleDependencty。
## 依賴圖換成stage圖的結果如下
stage1 --> stage1 -> stage1 --> stage1
\
--> stage0 --> stage0
/
stage2 --> stage2 --> stage2 --> stage2
SchedulerBackend
Driver進程在初始化SparkContext的實例時,會創建任務調度相關的組件,它包括集羣管理器SchedulerBackend
、任務調度器TaskScheduler
的實例,分別用來管理Spark應用關聯的集羣和任務分發和執行。根據用戶的執行環境不同,一共有三種類型的SchedulerBackend(Cluster Manager)可能被創建:
- LocalSchedulerBackend
當用戶提交的任務以本地方式執行時,創建此類的實例,它會在當前的JVM環境中創建executor線程,執行任務。 - StandaloneSchedulerBackend
當用戶提交的任務以本地集羣的方式執行或是提交到standalone集羣執行時,會創建此類的實例,它繼承自CoarseGrainedSchedulerBackend
類,除了基本的分配和回收Executor功能外,它提供了用於和standalone集羣交互的接口,不論是用戶使用的是Spark開發模式(用戶本地會啓動一個常駐的子進程來交互式地提交任務)還是提交模式(spark-submit),都需要通過此實例與集羣間接交互。 - ExternalClusterManager
外部集羣管理器,
核心的源碼摘取如下:
private def createTaskScheduler(
sc: SparkContext,
master: String,
deployMode: String): (SchedulerBackend, TaskScheduler) = {
import SparkMasterRegex._
master match {
case "local" =>
val scheduler = new TaskSchedulerImpl(sc, MAX_LOCAL_TASK_FAILURES, isLocal = true)
val backend = new LocalSchedulerBackend(sc.getConf, scheduler, 1)
scheduler.initialize(backend)
(backend, scheduler)
case LOCAL_N_REGEX(threads) =>
val scheduler = new TaskSchedulerImpl(sc, MAX_LOCAL_TASK_FAILURES, isLocal = true)
val backend = new LocalSchedulerBackend(sc.getConf, scheduler, threadCount)
scheduler.initialize(backend)
(backend, scheduler)
case LOCAL_N_FAILURES_REGEX(threads, maxFailures) =>
val scheduler = new TaskSchedulerImpl(sc, maxFailures.toInt, isLocal = true)
val backend = new LocalSchedulerBackend(sc.getConf, scheduler, threadCount)
scheduler.initialize(backend)
(backend, scheduler)
case SPARK_REGEX(sparkUrl) =>
val scheduler = new TaskSchedulerImpl(sc)
val masterUrls = sparkUrl.split(",").map("spark://" + _)
val backend = new StandaloneSchedulerBackend(scheduler, sc, masterUrls)
scheduler.initialize(backend)
(backend, scheduler)
case LOCAL_CLUSTER_REGEX(numSlaves, coresPerSlave, memoryPerSlave) =>
val scheduler = new TaskSchedulerImpl(sc)
val localCluster = new LocalSparkCluster(
numSlaves.toInt, coresPerSlave.toInt, memoryPerSlaveInt, sc.conf)
val masterUrls = localCluster.start()
val backend = new StandaloneSchedulerBackend(scheduler, sc, masterUrls)
scheduler.initialize(backend)
backend.shutdownCallback = (backend: StandaloneSchedulerBackend) => {
localCluster.stop()
}
(backend, scheduler)
case masterUrl =>
val cm = getClusterManager(masterUrl) match {
case Some(clusterMgr) => clusterMgr
case None => throw new SparkException("Could not parse Master URL: '" + master + "'")
}
try {
val scheduler = cm.createTaskScheduler(sc, masterUrl)
val backend = cm.createSchedulerBackend(sc, masterUrl, scheduler)
cm.initialize(scheduler, backend)
(backend, scheduler)
} catch {
case se: SparkException => throw se
case NonFatal(e) =>
throw new SparkException("External scheduler cannot be instantiated", e)
}
}
}
CoarseGrainedSchedulerBackend
(通常所說的driver,StandaloneSchedulerBackend
但是它的一個子類)爲該類的一個實現類,用於管理所有的executors(這裏的executor指的是一個在worker節點啓動的後端進程CoarseGrainedExecutorBackend
,管理真正的Executor實例),這些executor的生命週期是與Spark Job的綁定的,而非每個Task
,這樣就避免了過多的資源的申請、創建、回收等的過程,提高整個集羣的性能。
當一個Spark應用被提交到集羣時,會先傳遞給DAGScheduler
進行Stage
的劃分及TaskSet
的生成,而後續任務的調度和執行由DAGScheduler
通過RPCJobSubmitted
消息轉發給SchedulerBackend
對象。
TaskScheduler
其實現類爲TaskSchedulerImpl
,它會接收來自DAGScheduler
生成的一系列TaskSet
,然後創建對應的TaskSetManager
(實際負責調度TaskSet中的每一個任務),並將TaskSetManager
添加到自己的等待資源池裏,等待後續的調度(調度方式目前有兩種,一是先進選出調度,一種是公平調度)。
def initialize(backend: SchedulerBackend) {
this.backend = backend
schedulableBuilder = {
schedulingMode match {
case SchedulingMode.FIFO =>
new FIFOSchedulableBuilder(rootPool)
case SchedulingMode.FAIR =>
new FairSchedulableBuilder(rootPool, conf)
case _ =>
throw new IllegalArgumentException(s"Unsupported $SCHEDULER_MODE_PROPERTY: " +
s"$schedulingMode")
}
}
schedulableBuilder.buildPools()
}
當TaskSchedulerImpl接收到可調度的任務集後,就通知driver進程,嘗試調度Task
執行。
override def submitTasks(taskSet: TaskSet) {
val tasks = taskSet.tasks
this.synchronized {
val manager = createTaskSetManager(taskSet, maxTaskFailures)
val stage = taskSet.stageId
val stageTaskSets =
taskSetsByStageIdAndAttempt.getOrElseUpdate(stage, new HashMap[Int, TaskSetManager])
stageTaskSets.foreach { case (_, ts) =>
ts.isZombie = true
}
stageTaskSets(taskSet.stageAttemptId) = manager
schedulableBuilder.addTaskSetManager(manager, manager.taskSet.properties)
if (!isLocal && !hasReceivedTask) {
starvationTimer.scheduleAtFixedRate(new TimerTask() {
override def run() {
if (!hasLaunchedTask) {
} else {
this.cancel()
}
}
}, STARVATION_TIMEOUT_MS, STARVATION_TIMEOUT_MS)
}
hasReceivedTask = true
}
backend.reviveOffers()
}
TaskScheduler
從CoarseGrainedSchedulerBackend
接收已經註冊的、可用的空閒executors信息,從自己的等待池中遍歷所有的TaskSet
,嘗試調度每一個TaskSet執行,但很多情況下,並不是每一個TaskSet
中的所有Task
被一次分配資源並執行,因此TaskScheduler
完成爲某一個等待中的TaskSet找到一組可用的executors,至於如何在這些executors上分配自己管理的Task,則將由TaskSetManager
完成。TaskSetManager
嘗試分配任務到時某個executor上的核心代碼如下:
@throws[TaskNotSerializableException]
def resourceOffer(
execId: String,
host: String,
maxLocality: TaskLocality.TaskLocality)
: Option[TaskDescription] =
{
val offerBlacklisted = taskSetBlacklistHelperOpt.exists { blacklist =>
blacklist.isNodeBlacklistedForTaskSet(host) ||
blacklist.isExecutorBlacklistedForTaskSet(execId)
}
if (!isZombie && !offerBlacklisted) {
var allowedLocality = maxLocality
if (maxLocality != TaskLocality.NO_PREF) {
allowedLocality = getAllowedLocalityLevel(curTime)
if (allowedLocality > maxLocality) {
// We're not allowed to search for farther-away tasks
allowedLocality = maxLocality
}
}
dequeueTask(execId, host, allowedLocality).map { case ((index, taskLocality, speculative)) =>
// Found a task; do some bookkeeping and return a task description
val task = tasks(index)
val taskId = sched.newTaskId()
// Do various bookkeeping
copiesRunning(index) += 1
val attemptNum = taskAttempts(index).size
val info = new TaskInfo(taskId, index, attemptNum, curTime,
execId, host, taskLocality, speculative)
taskInfos(taskId) = info
taskAttempts(index) = info :: taskAttempts(index)
// Update our locality level for delay scheduling
// NO_PREF will not affect the variables related to delay scheduling
if (maxLocality != TaskLocality.NO_PREF) {
currentLocalityIndex = getLocalityIndex(taskLocality)
lastLaunchTime = curTime
}
// Serialize and return the task
val serializedTask: ByteBuffer = try {
ser.serialize(task)
} catch {
case NonFatal(e) =>
throw new TaskNotSerializableException(e)
}
if (serializedTask.limit() > TaskSetManager.TASK_SIZE_TO_WARN_KB * 1024 &&
!emittedTaskSizeWarning) {
emittedTaskSizeWarning = true
addRunningTask(taskId)
sched.dagScheduler.taskStarted(task, info)
new TaskDescription(
taskId,
attemptNum,
execId,
taskName,
index,
task.partitionId,
addedFiles,
addedJars,
task.localProperties,
serializedTask)
}
} else {
None
}
}
TaskSetManager
每一個該類的實例,都對應於一個TaskSet
,它負責調度TaskSet中的任務,並跟蹤、記錄每一個任務的狀態和行爲。
TaskSetManager
內部使用本地化可感知的延遲算法,爲自己管理的TaskSet
中的每一個任務Task
(主要是ShuffleMapTask
或ResultTask
類型的任務)分配executor,同時創建相應的TaskDescription
集體,返回給上層角色。
ExecutorAllocationManager
Executor
執行器分配管理器,(當用戶開啓了動態資源分配策略時spark.dynamicAllocation.enabled
時會在SparkContext
內部創建此對象),基於工作負載動態分配和回收executors,它內部維護了一個可變的目標數據變量,表示當前應用需要多少個活動的executor才能解決任務的積壓問題,它的最小值爲配置的初始化值,並跟隨堆積和正在運行的任務的數量而變化。manager會週期性地與cluster manager
(SchedulerBackend後端)同步executor的目標數量的值。
如果當前executor的數量大於目前的負載,會被減少至能夠容納當前正在運行和堆積的任務的數量。而需要增加executor數量的情況發生在有任務堆積待執行的時候,並且在N秒內不能夠處理完等待隊列中的任務;或者之前增加executor數量的操作不能夠在M秒內消費完隊列中的任務時候,繼續增加executor數量的操作,然後繼續下一輪判定。在每一輪的判定中,executor數量的增加是指數級的,直到達到一個上界值,而這個上界是基於配置的spark屬性及當前正在運行和等待的任務數量共同決定的。
至於爲何以指數的遞增方式增加executor,這裏有兩個方面的合理性:
- 添加executor的行爲在起始階段應當是緩慢的,以防當前應用只需求很小的數量便能夠完成工作,否則才需要增加更多的executor;
- 在一定時間以後,增加executor的動作應該是快速的,以花費更多的時間才能達到目標數量的executor。
移除executor的邏輯很簡單,如果某個executor在K秒內沒有運行過任務,那麼就應該移除它。ExecutorAllocationManager
沒有重試的邏輯,以增加或是減少executor,因此如何才能保證達到請求的executor數量是由ClusterManager保證的。
與此管理器相關的、可配置的屬性有以下幾個:
* spark.dynamicAllocation.enabled - 是否開啓動態分配功能
* spark.dynamicAllocation.minExecutors - 活動executor的最小數量
* spark.dynamicAllocation.maxExecutors - 活動executor的最大數量
* spark.dynamicAllocation.initialExecutors - 應用啓動時請求的executor數量
*
* spark.dynamicAllocation.executorAllocationRatio - 控制executor數量的因子,實際的最大executor數量=(任務運行數+等待任務數)* ration / executor.cores
*
* spark.dynamicAllocation.schedulerBacklogTimeout (M) - 如果在timeout時間後依然有堆積的任務,則嘗試增加executor的數量
*
* spark.dynamicAllocation.sustainedSchedulerBacklogTimeout (N) - 如果
*
* spark.dynamicAllocation.executorIdleTimeout (K) - timeout時間後移除executor
Job / Stage / Task間的關係
- 當前用戶調用一個action方法觸發執行時,就會創建一個Job,(比如
rdd.take(10)
方法),就開始在Spark集羣中計算數據。 - 一個Job開始以後,會先經由DAGScheduler進行依賴分析,從最後一個方法調用開始,回溯RDD的依賴圖(當調用一個RDD的方法時,就會新建一個新的RDD實例,如
MapPartitionsRDD
或是ShuffledRDD
等,並封裝調用的RDD爲其父依賴,MapPartitionsRDD
對應於OneToOneDependency
,ShuffledRDD
對應於ShuffleDependency
),創建Stage
圖,而後嘗試提交最後一個Stage(即ResultStage
)執行。 - 具體到如何計算Partition數據,需要創建具體的Task實例在集羣中執行。因此DAGScheduler創建好Stage圖之後,會採用逆拓撲排序的方法,依次創建爲每一個Stage的每個Partition創建任務實例
Task
。當回溯到某個Stage沒有父依賴時,意味着當前Stage應該被首先執行,且可以提交執行,因此將當前Stage添加到running隊列中,併爲這個Stage的每一個待生成的Partition創建對應的Task,而後提交執行。 - 當
TaskSchedulerImpl
接收到任務後,且有worker資源啓動任務時,就會將任務分配到可用的worker執行,例如ShuffleMapTask
會執行Reduce/Map操作,即先reduce之前依賴的Partition的數據,再Map輸出reduce之後的Partition數據。 - 當前任務執行完成後,不論成功還是失敗,都會返回一個
MapStatus
的實例,描述當前Task的狀態信息,並序列化後發送給driver端。 - driver端收到任務的更新消息後,就會更新任務相關的所有對象,最終如果當前Stage包含的所有任務都已經正常完成,則會嘗試啓動所有的孩子Stage執行。如下圖中表示的依賴關係,
Stage_0
和Stage_1
可以同時執行,但只有先完成Stage_0
纔會執行Stage_1
。
Spark App提交過程
Spark應用提交到Standalone集羣過程的時序圖如下所示,一個App提交給master後,最終會在集羣內啓動一個driver進程,通過各種RPC交互與集羣交互,完成應用的運行。
Spark App執行過程
Spark App Cluster構建時序圖
最終一個Spark應用從Driver進程到啓動Executor執行的過程的時序圖如下: