Spark作业执行原理
Spark的作业和任务调度系统是其核心,它能够有效地进行调度的根本原因是对任务划分DAG和容错,使得它对底层到顶层的各个模块之间的调用和处理显得游刃有余。下面介绍一些相关术语。
- 作业(Job):RDD中由行动操作所生成的一个或多个调度阶段。
- 调度阶段(Stage):每个作业会因为RDD之间的依赖关系拆分成多组任务集合,成为调度阶段,也叫做任务集(TaskSet)。调度阶段的划分是由DAGScheduler来划分的,调度阶段有Shuffle Map Stage和Result Stage两种。
- 任务(Task):分发到Executor上的工作任务,是Spark实际执行应用的最小单元。
- DAGScheduler:DAGScheduler是面向调度阶段的任务调度器,负责接收Spark应用提交的作业,根据RDD的依赖关系划分调度阶段,并提交调度阶段给TaskScheduler。
- TaskScheduler:TaskScheduler是面向任务的调度器,它接受DAGScheduler提交过来的调度阶段,然后把任务分发到Work节点运行,由Worker节点的Executor来运行该任务。
概述
Spark的作业调度主要是指基于RDD的一系列操作构成一个作业,然后在Executor中执行。这些操作算子主要分为Transformation和Action操作,对于转换操作的计算是lazy级别的,只有出现了Action操作才触发作业的提交。在Spark调度中最终的是DAGScheduler和TaskScheduler两个调度器,其中,DAGScheduler负责任务的逻辑调度,将作业拆分成不同阶段的具有依赖关系的任务集,而TaskScheduler则负责具体任务的调度执行。
Spark的作业和任务调度系统示意图:
根据上图,从整体上对Spark的作业和任务调度系统做一下分析。
1、Spark应用程序进行各种转换操作,通过Action操作触发作业执行。提交之后根据RDD之间的依赖关系构建DAG图,DAG图提交给DAGScheduler进行解析。
2、DAGScheduler是面向调度阶段的高层次的调度器,DAGScheduler把DAG拆分成相互依赖的调度阶段,拆分调度阶段是以RDD的依赖是否为宽依赖,当遇到宽依赖就划分为新的调度阶段。每个调度阶段包含一个或多个任务,这些任务形成任务集,提交给底层调度器TaskScheduler进行调度运行。同时,DAGScheduler记录哪些RDD被存入磁盘等动作,同时要寻求任务的最优化调度,例如数据本地行等;DAGScheduler监控运行调度阶段过程,如果某个调度阶段运行失败,则需要重新提交该调度阶段。
3、TaskScheduler接收来自DAGScheduler发送过来的任务集,TaskScheduler收到任务集后负责把任务集以任务的形式一个个发送到集群Worker节点的Executor中运行。如果某个任务运行失败,TaskScheduler要负责重试。如果TaskScheduler发现某个任务一直未运行完,就可能启动(spark.speculation设为true时开启task execution预测机制,默认设为false)同样的任务运行同一个任务,哪个任务先运行完就用哪个任务的结果。
4、Worker中的Executor收到TaskScheduler发送过来的任务后,以多线程的方式运行,每一个线程负责一个任务。任务运行结束后要返回给TaskScheduler,不同类型的任务,返回的方式也不同。ShuffleMapTask返回的是一个MapStatus对象,而不是结果本身;ResultTask根据结果大小的不同,返回的方式也不同。
提交作业
我们以WordCount为例来分析提交作业的情况:
val lines = sc.textFile("data.txt")
val wordcount = lines.flatMap(_.split(" ")).map(s => (s, 1)).reduceByKey(_+_).count
这个作业的真正提交是从count这个Action操作开始的,其内部隐性调用了SparkContext的runJob方法,用户不需要显性地去提交作业。
def count(): Long = sc.runJob(this, Utils.getIteratorSize _).sum
SparkContext的runJob方法经过几次调用后,进入DAGScheduler的runJob方法,其中SparkContext调用了DAGScheduler类的runJob方法:
/**
* Run a function on a given set of partitions in an RDD and pass the results to the given
* handler function. This is the main entry point for all actions in Spark.
*/
def runJob[T, U: ClassTag](
rdd: RDD[T],
func: (TaskContext, Iterator[T]) => U,
partitions: Seq[Int],
resultHandler: (Int, U) => Unit): Unit = {
if (stopped.get()) {
throw new IllegalStateException("SparkContext has been shutdown")
}
val callSite = getCallSite
val cleanedFunc = clean(func)
logInfo("Starting job: " + callSite.shortForm)
if (conf.getBoolean("spark.logLineage", false)) {
logInfo("RDD's recursive dependencies:\n" + rdd.toDebugString)
}
dagScheduler.runJob(rdd, cleanedFunc, partitions, callSite, resultHandler, localProperties.get)
progressBar.foreach(_.finishAll())
rdd.doCheckpoint()
}
在DAGScheduler的runJob方法中,调用submitJob继续提交作业,这个会发生阻塞,直到作业返回结果。
def submitJob[T, U](
rdd: RDD[T],
func: (TaskContext, Iterator[T]) => U,
partitions: Seq[Int],
callSite: CallSite,
resultHandler: (Int, U) => Unit,
properties: Properties): JobWaiter[U] = {
// 判断任务处理的分区是否存在,如果不存在,则抛出异常
val maxPartitions = rdd.partitions.length
partitions.find(p => p >= maxPartitions || p < 0).foreach { p =>
throw new IllegalArgumentException(
"Attempting to access a non-existent partition: " + p + ". " +
"Total number of partitions: " + maxPartitions)
}
val jobId = nextJobId.getAndIncrement()
// 如果作业只包含0个任务,则创建0个任务的JobWaiter,并立即返回
if (partitions.size == 0) {
// Return immediately if the job is running 0 tasks
return new JobWaiter[U](this, jobId, 0, resultHandler)
}
assert(partitions.size > 0)
// 创建JobWaiter对象,等待作业运行完毕,使用内部类提交作业
val func2 = func.asInstanceOf[(TaskContext, Iterator[_]) => _]
val waiter = new JobWaiter(this, jobId, partitions.size, resultHandler)
// 利用消息通信把JobWaiter对象发送出去,后面由DAGSchedulerEventProcessLoop的onReceive方法中接收
eventProcessLoop.post(JobSubmitted(
jobId, rdd, func2, partitions.toArray, callSite, waiter,
SerializationUtils.clone(properties)))
waiter
}
划分调度阶段
Spark调度阶段的划分是由DAGScheduler实现的,DAGScheduler会从最后一个RDD出发,使用广度优先遍历整个依赖树,从而划分调度阶段,调度阶段划分依据是以操作是否为宽依赖(ShuffleDependency)进行的,即当某个RDD的操作是Shuffle时,以该Shuffle操作为界限划分成前后两个调度阶段。
具体实现是在DAGScheduler的handleJobSubmitted方法中根据最后一个RDD生成ResultStage开始的。先从finalRDD使用getParentStages找出其依赖的祖先RDD是否存在Shuffle操作。
private[scheduler] def handleJobSubmitted(jobId: Int,
finalRDD: RDD[_],
func: (TaskContext, Iterator[_]) => _,
partitions: Array[Int],
callSite: CallSite,
listener: JobListener,
properties: Properties) {
var finalStage: ResultStage = null
try {
// 根据最后一个RDD回溯,获取最后一个调度阶段finalStage
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
}
// 根据最后一个调度阶段finalStage生成作业
val job = new ActiveJob(jobId, finalStage, callSite, listener, properties)
clearCacheLocs()
logInfo("Got job %s (%s) with %d output partitions".format(
job.jobId, callSite.shortForm, partitions.length))
logInfo("Final stage: " + finalStage + " (" + finalStage.name + ")")
logInfo("Parents of final stage: " + finalStage.parents)
logInfo("Missing parents: " + getMissingParentStages(finalStage))
val jobSubmissionTime = clock.getTimeMillis()
jobIdToActiveJob(jobId) = job
activeJobs += job
finalStage.setActiveJob(job)
val stageIds = jobIdToStageIds(jobId).toArray
val stageInfos = stageIds.flatMap(id => stageIdToStage.get(id).map(_.latestInfo))
listenerBus.post(
SparkListenerJobStart(job.jobId, jobSubmissionTime, stageInfos, properties))
// 提交作业
submitStage(finalStage)
submitWaitingStages()
}
DAGScheduler.getParentStages代码:
private def getParentStages(rdd: RDD[_], firstJobId: Int): List[Stage] = {
val parents = new HashSet[Stage]
val visited = new HashSet[RDD[_]]
// 存放等待访问的堆栈,存放的是非ShuffleDependency的RDD
val waitingForVisit = new Stack[RDD[_]]
// 定义遍历处理方法,先对访问过的RDD标记,然后根据当前RDD所依赖RDD操作类型进行不同处理
def visit(r: RDD[_]) {
if (!visited(r)) {
visited += r
// Kind of ugly: need to register RDDs with the cache here since
// we can't do it in its constructor because # of partitions is unknown
for (dep <- r.dependencies) {
dep match {
// 所依赖RDD操作类型是ShuffleDependency,需要划分ShuffleMap调度阶段。
case shufDep: ShuffleDependency[_, _, _] =>
// 以调度getShuffleMapStage方法为入口,向前遍历划分调度阶段
parents += getShuffleMapStage(shufDep, firstJobId)
// 所依赖RDD操作类型是非ShuffleDependency,把该RDD压入等待访问的堆栈
case _ =>
waitingForVisit.push(dep.rdd)
}
}
}
}
// 以最后一个RDD开始向前遍历整个依赖树,如果该RDD依赖树存在ShuffleDependency的RDD,则父调度阶段存在。反之,则不存在。
waitingForVisit.push(rdd)
while (waitingForVisit.nonEmpty) {
visit(waitingForVisit.pop())
}
parents.toList
}
当finalRDD存在父调度阶段,需要从发生Shuffle操作的RDD往前遍历,找出所有的ShuffleMapStage。这是调度阶段划分的最关键的部分,该算法和getParentStages类似,由getAncestorShuffleDependencies方法中实现。
private def getAncestorShuffleDependencies(rdd: RDD[_]): Stack[ShuffleDependency[_, _, _]] = {
val parents = new Stack[ShuffleDependency[_, _, _]]
val visited = new HashSet[RDD[_]]
// We are manually maintaining a stack here to prevent StackOverflowError
// caused by recursively visiting
val waitingForVisit = new Stack[RDD[_]]
def visit(r: RDD[_]) {
if (!visited(r)) {
visited += r
for (dep <- r.dependencies) {
dep match {
// 所依赖RDD操作类型是ShuffleDependency,作为划分ShuffleMap调度阶段界限
case shufDep: ShuffleDependency[_, _, _] =>
if (!shuffleToMapStage.contains(shufDep.shuffleId)) {
parents.push(shufDep)
}
case _ =>
}
waitingForVisit.push(dep.rdd)
}
}
}
waitingForVisit.push(rdd)
while (waitingForVisit.nonEmpty) {
visit(waitingForVisit.pop())
}
parents
}
当所有调度阶段划分完毕时,这些调度阶段建立起依赖关系。该依赖关系是通过调度阶段其中属性parents:List[Stage]来定义的,通过该属性可以获取当前阶段所有祖先阶段,可以根据这些信息按顺序提交调度阶段进行运行。
提交调度阶段
在DAGScheduler的handleJobSubmitted方法中,生成finalStage的同时建立起所有调度阶段的依赖关系,然后通过finalStage生成一个作业实例,在该作业实例中按照顺序提交调度阶段进行执行,在执行过程中通过监听总线获取作业、阶段执行情况。
private def submitStage(stage: Stage) {
val jobId = activeJobForStage(stage)
if (jobId.isDefined) {
logDebug("submitStage(" + 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 {
// 如果存在父调度阶段,把该调度阶段加入到等待运行调度阶段列表中,同时递归调用submitStage方法,
// 直到找到没有父调度的阶段
for (parent <- missing) {
submitStage(parent)
}
waitingStages += stage
}
}
} else {
abortStage(stage, "No active job for stage " + stage.id, None)
}
}
在入口的调度阶段运行完成后相继提交后续调度阶段,在调度前先判断该调度阶段所依赖的父调度阶段的结果是否可用(是否运行成功)。如果结果都可用,则提交该调度阶段;如果结果不可用,则尝试提交结果不可用的父调度阶段。如果存在执行失败的任务,则重新提交该调度任务。如果所有任务完成,则扫描等待运行调度阶段列表,检查它们的父调度阶段是否存在未完成,如果不存在则表明该调度阶段准备就绪,生成实例并提交运行。
代码见DAGScheduler的handleTaskCompletion方法:
private[scheduler] def handleTaskCompletion(event: CompletionEvent) {
val task = event.task
val stageId = task.stageId
val taskType = Utils.getFormattedClassName(task)
outputCommitCoordinator.taskCompleted(
stageId,
task.partitionId,
event.taskInfo.attemptNumber, // this is a task attempt number
event.reason)
...
val stage = stageIdToStage(task.stageId)
event.reason match {
case Success =>
listenerBus.post(SparkListenerTaskEnd(stageId, stage.latestInfo.attemptId, taskType,
event.reason, event.taskInfo, event.taskMetrics))
stage.pendingPartitions -= task.partitionId
task match {
...
case smt: ShuffleMapTask =>
val shuffleStage = stage.asInstanceOf[ShuffleMapStage]
updateAccumulators(event)
val status = event.result.asInstanceOf[MapStatus]
val execId = status.location.executorId
logDebug("ShuffleMapTask finished on " + execId)
if (failedEpoch.contains(execId) && smt.epoch <= failedEpoch(execId)) {
logInfo(s"Ignoring possibly bogus $smt completion from executor $execId")
} else {
shuffleStage.addOutputLoc(smt.partitionId, status)
}
if (runningStages.contains(shuffleStage) && shuffleStage.pendingPartitions.isEmpty) {
markStageAsFinished(shuffleStage)
logInfo("looking for newly runnable stages")
logInfo("running: " + runningStages)
logInfo("waiting: " + waitingStages)
logInfo("failed: " + failedStages)
// We supply true to increment the epoch number here in case this is a
// recomputation of the map outputs. In that case, some nodes may have cached
// locations with holes (from when we detected the error) and will need the
// epoch incremented to refetch them.
// TODO: Only increment the epoch number if this is not the first time
// we registered these map outputs.
mapOutputTracker.registerMapOutputs(
shuffleStage.shuffleDep.shuffleId,
shuffleStage.outputLocInMapOutputTrackerFormat(),
changeEpoch = true)
clearCacheLocs()
if (!shuffleStage.isAvailable) {
// 当调度阶段中存在部分任务执行失败,则重新提交运行
// TODO: Lower-level scheduler should also deal with this
logInfo("Resubmitting " + shuffleStage + " (" + shuffleStage.name +
") because some of its tasks had failed: " +
shuffleStage.findMissingPartitions().mkString(", "))
submitStage(shuffleStage)
} else {
// 当该调度阶段没有等待运行的任务,则设置该调度阶段状态为完成
if (shuffleStage.mapStageJobs.nonEmpty) {
val stats = mapOutputTracker.getStatistics(shuffleStage.shuffleDep)
for (job <- shuffleStage.mapStageJobs) {
markMapStageJobAsFinished(job, stats)
}
}
}
// Note: newly runnable stages will be submitted below when we submit waiting stages
}
}
...
}
submitWaitingStages()
}
提交任务
当调度阶段提交运行后,在DAGScheduler的submitMissingTasks方法中,会根据调度阶段Partition个数拆分对应个数任务,这些任务组成一个任务集提交到TaskScheduler进行处理。对于ResultStage生成ResultTask,对于ShuffleMapStage生成ShuffleMapTask。对于每一个任务集包含了对应调度阶段的所有任务,这些任务处理逻辑完全一样,不同的是对应处理的数据,而这些数据是其对应的数据分区(Partition)。
执行任务
当Executor收到LaunchTask消息时,会调用Executor的launchTask方法进行处理。launchTask方法会初始化一个TaskRunner来封装任务,它用于管理任务运行时的细节,再把TaskRunner对象放入到ThreadPool(线程池)中去执行。
获取执行结果
对于Executor的计算结果,会根据结果的大小有不同的策略。
1、大于1G:结果直接丢弃,可以通过spark.driver.maxResultSize进行配置。
2、大小在[128M-200KB, 1G]之间:如果结果大于等于128M-200KB,会把该结果以taskId为编号存入到BlockManager中,然后把该编号通过Netty发送给Driver终端点,该阈值时Netty框架传输的最大值spark.akka.frameSize(默认为128M)和Netty的预留空间reservedSizeBytes(200KB)差值。
3、大小在(0, 128M-200KB):通过Netty直接发送到Driver终端点。