資源調度
請參考鏈接
https://blog.csdn.net/pre_tender/article/details/99637004
如圖
任務調度
背景知識
DAG
DAG(Directed Acyclic Graph) 中文名是有向無環圖. DAG是有向無環圖(Directed Acyclic Graph)的簡稱. 在大數據處理領域,
DAG計算模型是指將計算任務在內部分解爲若干個子任務, 這些子任務之間由邏輯關係或運行先後順序等因素被構建成有向無環圖. Spark是實現了DAG計算模型的計算框架.
Spark運行時架構
首先, 先來熟悉一下Spark的運行時架構.
驅動器
Spark驅動器就是執行程序main()函數的進程. 驅動器有一下職責:
-
負責把用戶程序轉化爲多個物理執行單元(task). Task是Spark中最小的工作單元. 具體的步驟是首先它會把用戶程序轉化成DAG, 然後在把DAG轉化成task.
-
爲執行器(Executor)調度任務. 驅動器啓動成功後會向Driver程序註冊自己, Driver程序保存了所有可用的Executor. 當物理執行計劃生成之後它要負責協調哪些任務在哪些Executor執行. 這個過程Driver根據任務基於的數據所在的位置給其分配執行器.
執行器
執行器是最"基層", "幹活"的進程. Spark應用啓動的時候, Executor進程就被同時啓動, 知道整個Spark應用關閉, Executor被關閉. 它的具體職責是:
-
執行Driver交給的任務, 並返回結果.
-
通過自身的Block Manager爲用戶程序中要求緩存的RDD提供基於內存的緩存.
Job執行流程
在Spark中一個Job抽象的執行流程大概就是這樣的:Job提交 -> Driver把RDD轉化爲DAG -> 根據DAG轉化爲Task -> Task提交給Executor -> Result.
+-------+ +----------------+ +-------------+| RDD | --DAG--> | DAGScheduler | --Tasks--> | Executors |+-------+ +----------------+ +-------------+
簡單解釋一下涉及到的名詞都是什麼意思:
Job: 在用戶程序中, 每次調用Action函數都會產生一個新的job, 也就是說一個Action都會生成一個job.
Task: Task是Spark中最小的工作單元, Spark中的程序最終都要分解成一個個Task提交到Scheduler.
Stage: Stage對應DAG中的任務單元.
RDD依賴和DAG的構建
在第一篇文章中, 我提到過執行transformation函數的一個作用是構建RDD之間的依賴關係. 具體來說依賴有寬窄之分, 如果子RDD中的每個分區依賴常數個父RDD中的分區, 我們把這種依賴叫做窄依賴; 如果子RDD中的每個數據分片依賴父RDD的所有分片, 我們把這種依賴叫做寬依賴.
在這兒我們在引入一個新的詞彙lineage
, 在spark中每個RDD都攜帶自己的lineage. 而lineage就是通過RDD之間的依賴來表示的.
wide-narrow-dependency
我們通過這幅圖可以大概看一下寬窄依賴到底是這麼回事. 圖中矩形框圍住的部分是RDD, 實心小矩形是Partition.
接下來我們看一下Spark是如何構建DAG的. 當用戶調用Action函數時, 調度器會逆向的遍歷該RDD的lineage, 每個stage會嘗試儘可能多包含那些連續的窄依賴. 如果當前的Stage向上回溯的過程中遇到了寬依賴, 則當前Stage結束, 一個新的Stage被構建. 第二個Stage是第一個Stage的parent. 還有一種情況也會結束當前Stage, 那就是那個partition已經被計算出來, 換存在內存中, 這種情況下我們就不必作多餘的計算了.
內部實現
我們依據上邊抽象的Job執行流程爲依據, 從代碼入手看一下Spark內部代碼實現.
第一個階段: Job -> Stage.
這個階段主戰場在DAGScheduler, 假設我們調用了reduce函數, reduce內部會調用SparkContext.runJob函數. 在SparkContext內部, 經過一系列函數的調用, 最終通過調用DAGScheduler.runJob函數把Job提交給DAGScheduler.
我們看一下DAGScheduler runjob函數:
val waiter = submitJob(rdd, func, partitions, callSite, resultHandler, properties)
waiter.awaitResult() match { //...}
首先它先將job提交, 然後創建JobWaiter以阻塞的方式等待job執行結果.
我們接下來看一下DAGScheduler submitJob的過程?
它向DAGSchedulerEventProcessLoop post了一個JobSubmitted事件. DAGSchedulerEventProcessLoop接到JobSubmitted事件之後會調用DAGScheduler的handleJobSubmitted函數. 正是這個函數觸發了RDD到DAG的轉化. 我們重點來看一下這個函數的實現(刪掉了一些我們不關心的代碼):
private[scheduler] def handleJobSubmitted(...) { var finalStage: ResultStage = null
try {
finalStage = newResultStage(finalRDD, func, partitions, jobId, callSite)
} catch { case e: Exception =>
logWarning("Creating new stage failed due to exception - job: " + jobId, e)
listener.jobFailed(e) return
}
submitStage(finalStage)
submitWaitingStages()
}
通過newResultStage我們拿到了最DAG的最後一個Stage(finalStage), 最有一個Stage都是ResultStage. 如果我們反向的遍歷就能夠知道整個DAG, 這個稍後我們會具體分析newResultStage的實現. 接着看handleJobSubmitted函數, 在拿到DAG的最後一個Stage後, 通過submitStage把它提交, 不出意外submitStage肯定實在向Executor提交Task. 我們按順序先看newResultStage是如何生成DAG的.
private def getParentStages(rdd: RDD[_], firstJobId: Int): List[Stage] = {
val parents = new HashSet[Stage]
val visited = new HashSet[RDD[_]]
val waitingForVisit = new Stack[RDD[_]]
def visit(r: RDD[_]) { if (!visited(r)) {
visited += r for (dep <- r.dependencies) {
dep match { case shufDep: ShuffleDependency[_, _, _] =>
parents += getShuffleMapStage(shufDep, firstJobId) case _ =>
waitingForVisit.push(dep.rdd)
}
}
}
}
waitingForVisit.push(rdd) while (waitingForVisit.nonEmpty) {
visit(waitingForVisit.pop())
}
parents.toList
}
上邊背景知識部分我們已經大概知道了Spark是如何劃分Stage的. 簡單的說就是遇到寬依賴, 就生成新的Stage. 寬依賴會觸發shuffle. 我們來看上邊代碼的visit函數: 拿到RDD的所有的dependency, 如果是窄依賴那麼繼續查找依賴的RDD的parent; 如果是寬依賴, 則調用getShuffleMapStage把生成的Stage加到當前stage的parents中. 該函數執行完畢, 則整個DAG就構建完成.
看完DAG的構建過程, 我們繼續沿着submitStage那條線看下去(以下源碼做了部分刪減).
private def submitStage(stage: Stage) {
... if (!waitingStages(stage) && !runningStages(stage) && !failedStages(stage)) {
val missing = getMissingParentStages(stage).sortBy(_.id)
logDebug("missing: " + missing) if (missing.isEmpty) {
logInfo("Submitting " + stage + " (" + stage.rdd + "), which has no missing parents")
submitMissingTasks(stage, jobId.get)
} else { for (parent <- missing) {
submitStage(parent)
}
waitingStages += stage
}
}
}
這個函數非常簡單, 先把當前stage的parents提交, 完事兒後在提交自己. 我們重點關注一下: submitMissingTasks.
/** Called when stage's parents are available and we can now do its task. */private def submitMissingTasks(stage: Stage, jobId: Int) {
...
val partitionsToCompute: Seq[Int] = stage.findMissingPartitions()
val tasks: Seq[Task[_]] = try {
stage match { case stage: ShuffleMapStage =>
partitionsToCompute.map { id =>
val locs = taskIdToLocations(id)
val part = stage.rdd.partitions(id) new ShuffleMapTask(stage.id, stage.latestInfo.attemptId,
taskBinary, part, locs, stage.internalAccumulators)
} case stage: ResultStage =>
val job = stage.activeJob.get
partitionsToCompute.map { id =>
val p: Int = stage.partitions(id)
val part = stage.rdd.partitions(p)
val locs = taskIdToLocations(id) /**
* !! 一個ResultTask包含了task的定義(這個task要幹什麼), 以及在那個partition(part)執行該task
*/
new ResultTask(stage.id, stage.latestInfo.attemptId,
taskBinary, part, locs, id, stage.internalAccumulators)
}
}
} catch { case NonFatal(e) =>
abortStage(stage, s"Task creation failed: $e\n${e.getStackTraceString}", Some(e))
runningStages -= stage return
} if (tasks.size > 0) {
taskScheduler.submitTasks(new TaskSet(
tasks.toArray, stage.id, stage.latestInfo.attemptId, jobId, properties))
stage.latestInfo.submissionTime = Some(clock.getTimeMillis())
} else {
...
}
}
首先先找出該stage中所有未執行過的partition, 然後把序列化後的task(taskBinary), partition(part)等信息封裝成task. ShuffleMapStage轉化成ShuffleMapTask, ResultStage轉化成ResultStage. ShuffleMapTask的處理過程會比較複雜一下, 因爲會涉及到shuffle的過程. 這個我們後續分析Executor如何執行是再詳說. 函數最後我們看到新生成的tasks封裝成TaskSet提交給TaskScheduler.
到此我們分析了RDD如何轉化成DAG, DAG是如何生成Task並提交的. 接下來分析Executor如何處理Task.
Executor如何處理Tasks
Task提交成功之後, 我們來看一下都有哪些類參與到了Task執行的過程中? 第一個類就是TaskScheduler, 它主要負責Task的調度工作. 第二個類是SchedulerBackend, SchedulerBackend的作用是向TaskScheduler申請任務, 並分配給Executor去執行. SchedulerBackend可以有不同的實現. 支持本地單機運行的是LocalBackend, 支持Mesos集羣運行的是MesosSchedulerBackend, etc. 下面我們分析以本地單機運行爲例解釋任務執行的過程.
文章上一部分分析到DAGScheduler調用TaskScheduler的submitTasks方法提交Task, 那我們接着來看submitTasks的實現:
override def submitTasks(taskSet: TaskSet) {
...
backend.reviveOffers()
}
submitTasks最後一步調用了LocalBackend的reviveOffers函數, 這個函數是提醒LocalBackend可以開始執行任務了. 我們繼續看reviveOffers的實現:
def reviveOffers() {
val offers = Seq(new WorkerOffer(localExecutorId, localExecutorHostname, freeCores)) /** 向TaskSchedulerImpl申請Task */
for (task <- scheduler.resourceOffers(offers).flatten) {
freeCores -= scheduler.CPUS_PER_TASK
executor.launchTask(executorBackend, taskId = task.taskId, attemptNumber = task.attemptNumber,
task.name, task.serializedTask)
}
}
首先, 它先向scheduler申請task. 然後把tasks提交給Executor. 在申請Task的時候, 把自己空閒的cpu個數發送給Scheduler, 以便讓Scheduler按資源分配任務. 我們來看Scheduler是如何分配Task的.
def resourceOffers(offers: Seq[WorkerOffer]): Seq[Seq[TaskDescription]] = synchronized {
val shuffledOffers = Random.shuffle(offers)
val tasks = shuffledOffers.map(o => new ArrayBuffer[TaskDescription](o.cores))
val availableCpus = shuffledOffers.map(o => o.cores).toArray
val sortedTaskSets = rootPool.getSortedTaskSetQueue var launchedTask = false
for (taskSet <- sortedTaskSets; maxLocality <- taskSet.myLocalityLevels) { do {
launchedTask = resourceOfferSingleTaskSet(
taskSet, maxLocality, shuffledOffers, availableCpus, tasks)
} while (launchedTask)
} if (tasks.size > 0) {
hasLaunchedTask = true
} return tasks
}
private def resourceOfferSingleTaskSet(
taskSet: TaskSetManager, maxLocality: TaskLocality, shuffledOffers: Seq[WorkerOffer], availableCpus: Array[Int], tasks: Seq[ArrayBuffer[TaskDescription]]) : Boolean = { var launchedTask = false
for (i <- 0 until shuffledOffers.size) {
val execId = shuffledOffers(i).executorId
val host = shuffledOffers(i).host if (availableCpus(i) >= CPUS_PER_TASK) { try { for (task <- taskSet.resourceOffer(execId, host, maxLocality)) {
tasks(i) += task
val tid = task.taskId
taskIdToTaskSetManager(tid) = taskSet
taskIdToExecutorId(tid) = execId
executorIdToTaskCount(execId) += 1
executorsByHost(host) += execId
availableCpus(i) -= CPUS_PER_TASK
assert(availableCpus(i) >= 0)
launchedTask = true
}
} catch { case e: TaskNotSerializableException =>
logError(s"Resource offer failed, task set ${taskSet.name} was not serializable") // Do not offer resources for this task, but don't throw an error to allow other
// task sets to be submitted.
return launchedTask
}
}
} return launchedTask
}
TaskScheduler每次把所有的TaskSet都取出來, 這些TaskSet都按照一定算法進行了了排序, 排在前邊的TaskSet會被優先分配Executor.
在resourceOfferSingleTaskSet函數中, 我們可以知道只有workoffer中Executor的空閒cpu個數大於設定的每個Task需要的cpu數量時, 才把當前的Task添加到tasks列表裏.
總得來說任務調度的過程是: Backend向Scheduler發出work offer, worker offer中記錄着自己的基本信息, 自己的空閒資源, Scheduler根據worker offer中executor的空閒資源爲其分配合適的任務.
當Backend拿到Task之後, 依次把Task提交給Executor. TaskRunner繼承了Runnable, 所以每個Task都是單獨的線程去執行.
def launchTask(
context: ExecutorBackend,
taskId: Long,
attemptNumber: Int,
taskName: String,
serializedTask: ByteBuffer): Unit = {
val tr = new TaskRunner(context, taskId = taskId, attemptNumber = attemptNumber, taskName,
serializedTask)
runningTasks.put(taskId, tr)
threadPool.execute(tr)
}
代碼執行到這裏, Task開始真正執行.