Spark源码阅读笔记:DAGScheduler

来源:互联网 发布:单片机需要c语言 编辑:程序博客网 时间:2024/04/28 11:35

前言

    有个前辈给我提了建议,他觉得我这种方式写源码分析文档有点记流水帐的意思,意义并不是很大。我觉得他说得很有道理,想了以后,也觉得每必要一点点说,针对几个重点着重讲一下自己的看法和感悟就行了。

DAGScheduler简介

    那么本篇文档要搞的是DAGScheduler,虽然前面几篇文章多多少少有所涉及DAGScheduler,这里还是简单地介绍一下。


    通过这张图,可以很清除地看到DAGScheduler的作用:将一个job拆成一个个stage,然后提交给taskScheduelr,同时也负责处理stage运行失败的情况。DAGScheduler在一个Spark Application中唯一,是运行过程中的重要组件。

怎样生成stage

    在描述DAGScheduler怎样把提交上来的job变成一组stage前,必须先要提一下RDD在一组transformation和action到底干了些啥。我们知道transformation是不会触发执行的,只有在调用action的时候才会产生job并开始真正在spark集群上开始运行。这里以map和count这两个最简单的方法来分别查看其中的过程。

     count方法就是直接开始runjob了,整个流程在以后会做一次整理,这里先跳过。
     map是一个transformation过程,通过RDD的map方法发现,map方法只是单纯地新建并返回了一个MappedRDD对象。在生成这个新的RDD对象时,把原先的RDD对象和map方法中的lambda表达式作为构造参数丢了进去。MappedRDD类重写了RDD中两个方法,一个是getPartitions方法,用来获取RDD的partitions,这里直接返回其上一级父RDD的partitions;另一个是compute方法,代码:

  override def compute(split: Partition, context: TaskContext) =    firstParent[T].iterator(split, context).map(f)
    这里不作深入,仅从这里来看就是真正对rdd中数据在进行map操作的方法了。

    以map为例,构建MappedRDD时除了自己笑纳了map里的function,还把它之前的RDD作为一个参数传给了其父类(RDD)构造器,这个构造器的代码如下:

     def this(@transient oneParent: RDD[_]) =         this(oneParent.context , List(new OneToOneDependency(oneParent)))
    可以看到,这里第一个参数是获取前一个RDD的上下文,第二个参数创建了一个Dependency序列,序列中有个oneToOne的dependency对象,然后MappedRDD对象的getDependencies方法直接返回这个序列。然而像ShuffledRDD中,这个方法就直接被重写了,返回的是一个包含了ShuffleDependency对象的序列。总而言之,RDD的getDependencies方法能容许访问者获得它的依赖。
    然后我们回归到DAGScheduler,要看怎样生成stage,入口是handleJobSubmitted,调用关系大家可以自己跟一下调用堆栈。这个方法的目标是生成一个finalStage,并把它提交出去。其中,在创建finalStage时,通过newStage方法来完成,而不是直接new 一个stage对象。而在其内部new产生一个新的stage对象时,用到了getParentStages这个方法,这个方法需要接收finalRDD和jobid两个参数,在内部会遍历这个rdd的dependencies,如果有ShuffleDependency,那么就在parent stage这个hashset中添加一个stage;如果是其他的dependency,那么暂时先把这个父RDD压入一个堆栈中,等遍历完这个RDD的其他父RDD后再行处理(这里这么做的原因是有些RDD可能由多个父RDD组合构造,其实本质上来说这段代码是一个遍历RDD依赖的过程)。需要重点关注的是遇到shuffleDependency时的过程,这里是调用了getShuffleMapStage这个方法来返回一个parent stage,在这个方法中,采用模式匹配先根据这个shuffleDependency的shuffled判断是否是已创建的shuffle stage(有可能同一个shuffle结果被用来创建多个子rdd,那么这样一来就有可能会同一个stage被多次依赖到),如果有则直接返回;如果没有就创建一个,创建时内部依然会用到newStage方法,还是会传入当前遍历到的rdd,继续迭代。


    那么这里已经很明显了,DAGScheduler划分stage的依据就是ShuffleDependency,在今后分析stage划分时,只需要查看所调用的transformation是否会产生一个ShuffleDependency即可。
在stage提交阶段,可以看以下方法:

/** Submits stage, but first recursively submits any missing parents. */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 == Nil) {                logInfo("Submitting " + stage + " (" + stage.rdd + "), which has no missing parents")                submitMissingTasks(stage, jobId.get)            } else {                for (parent <- missing) {                     submitStage(parent)                }                waitingStages += stage             }        }    } else {        abortStage(stage, "No active job for stage " + stage.id)    }}

    也是一个递归的过程,最外层输入的是最终的stage,然后向上先提交前面的stage。


附:Spark中的依赖(Dependency


看过RDD基础知识的知道Spark中RDD有宽依赖和窄依赖两种,那么表现在代码里,Spark为依赖关系定义了Dependency这个类(org.apache.spark.Dependency)。这个类有两个直接子类,一个是NarrowDependency,另一个是ShuffleDependency。NarrowDependency是一个抽象类,其也有两个子类OneByOneDependency和RangeDependency,分别代表父RDD与子RDD的所有分区呈一对一完全依赖和在一定范围内分区一对一依赖。所以对于stage的划分来说,可以说是遇到宽依赖就划一个stage。

到底是谁决定了task的运行位置

    这个问题其实在前面分析TaskScheduler时就有涉及,只是当时由于没有涉及到DAGScheduler,并且理解得还不够深入,所以无法真正说明白。
    要看是谁决定了task到底运行在哪个executor上,这里还是要继续跟一下流程,上面submitStage方法中真正提交stage的是submitMissingTasks,这个方法就是DAGScheduler中提交任务的末端了。我们可以看到,进入这个方法前,我们手上还是一个一个stage,Task对象并没有被创建,然而提交给TaskScheduler的是taskset,封装了一组task对象,那么显而易见这个方法里的一个重要步骤就是创建task了。跳过方法前面序列化任务信息广播等步骤,直接来到创建task的步骤。
val tasks: Seq[Task[_]] = if (stage.isShuffleMap) {    partitionsToCompute.map { id =>        val locs = getPreferredLocs(stage.rdd, id)        val part = stage.rdd.partitions(id)        new ShuffleMapTask(stage.id, taskBinary, part, locs)    }} else {    val job = stage.resultOfJob.get    partitionsToCompute.map { id =>        val p: Int = job.partitions(id)        val part = stage.rdd.partitions(p)        val locs = getPreferredLocs(stage.rdd, p)        new ResultTask(stage.id, taskBinary, part, locs, id)    }}

    这里构造一个新的Task时,一个必不可少的参数就是locs这个常量,这就是代表了DAGScheduler所决定的task运行位置,通过getPreferredLocs来获得。
    再来看看getPreferredLocs这个方法,直接转入getPreferredLocsInternal,在这个方法内部,决定位置信息的核心逻辑:
val cached = getCacheLocs(rdd)(partition)if (!cached.isEmpty) {    return cached}val rddPrefs = rdd.preferredLocations(rdd.partitions(partition)).toListif (!rddPrefs.isEmpty) {    return rddPrefs.map(host => TaskLocation(host))}rdd.dependencies.foreach {    case n: NarrowDependency[_] =>        for (inPart <- n.getParents(partition)) {        val locs = getPreferredLocsInternal(n.rdd, inPart, visited)        if (locs != Nil) {            return locs        }    }    case _ =>}

1.首先查看这个RDD是否被cache了,如果已经cache,那么就直接返回这个RDD关于这     个task对应的分区位置信息。
2.如果没有cache,那么就调用rdd的preferredLocations方法来获取地址信息。这个方法在内部通过调用getPreferredLocations方法来获取地址,而这个方法不同的RDD有不同的实现。对于那种直接从外部数据源(比如hdfs)形成的rdd,这里就直接返回数据所在的地址了。
3.如果以上两步检查都没有找到最佳运行地址,那么如果当前RDD与父RDD之间是窄依赖关系,那么就递归向上找父RDD分区最佳位置作为当前task运行的最佳位置。
4.其他情况就返回空。


    DAGScheduler在做完这个工作后就不去碰这个地址信息了,但不要以为就这么完了,要知道,真正提交任务的是在TaskScheduler,它才是最后一道坎。此外,DAGScheduler所计算处理的只是当前这个job,即使它指定了最佳运行的executor地址,那还得看人家是不是有空闲来运行等等其他情况,这个时候就要靠TaskScheduler来协调了。
在TaskScheduler部分有提到,DAGScheduler提交给TaskScheduler的是Taskset,TaskScheduler会根据收到的Taskset来生成TaskSetManager,在这里要关注一下TaskSetManager的构造过程了。在构造过程中,有一个步骤为:

for (i <- (0 until numTasks).reverse) {    addPendingTask(i)}

    在这里是将taskset中的task逐一添加到manager的数据结构中,即内部的三个hashmap:pendingTasksForExecutor、pendingTasksForHost、pendingTasksForRack,记录的是以执行位置(分别是进程级的executorid,节点级的host,机架级的rack)为键,以应该在该位置上执行的任务index组成的数组为值。
    那么这三个hashmap有啥用呢,继续跟流程,在taskScheduler的resourceOffer阶段就派上用场了。前面说过了,这个方法接收的是master告诉taskscheduler的worker状态(executor id,host,可用core数),输出的是经过调度的待执行任务序列。在其内部,有一个步骤是遍历可用executor列表,将executor id、executor host以及本地性配置作为参数,调用tasksetManager的resourceOffer方法。
    TaskSetManager里有个重要的方法是getAllowedLocalityLevel,在这部分会用到delay scheduling算法来获取当前允许的最大本地性级别(即最差的本地性情况,该值是一个枚举,越差的情况值越大)。具体的做法:
1.首先在TaskSetManager初始化阶段检查当前配置中默认每个级别的等待时间以及每个级别独立的wait时间,放到一个数组里(配置项分别是:spark.locality.wait,spark.locality.wait.process,spark.locality.wait.node,spark.locality.wait.rack)。具体判断是否支持某个本地性级别的代码(以PROCESS_LOCAL为例):
if (!pendingTasksForExecutor.isEmpty && getLocalityWait(PROCESS_LOCAL) != 0 &&
    pendingTasksForExecutor.keySet.exists(sched.isExecutorAlive(_))) {
    levels += PROCESS_LOCAL
}
即检查存储当前级别与task执行关系的hashmap不能为空,对应的配置等待时间不为0且存在alive的executor。
2.接着,在调度过程中getAllowedLocalityLevel方法内,代码如下:
while (curTime - lastLaunchTime >= localityWaits(currentLocalityIndex) &&    currentLocalityIndex < myLocalityLevels.length - 1){    lastLaunchTime += localityWaits(currentLocalityIndex)    currentLocalityIndex += 1}myLocalityLevels(currentLocalityIndex)

    如果taskset刚刚被提交,taskScheduler开始第一轮对taskset中的task开始提交,那么当时currentLocalityIndex为0,直接返回可用的最好的本地性;如果是在以后的提交过程中,那么如果当前的等待时间超过了一个级别,就向后跳一个级别。可能这么说有点晕,一句话概括一下,这个getAllowedLocalityLevel方法返回的是当前这次调度中,能够容忍的最差的本地性级别,在后续步骤的搜索中就只搜索本地性比这个级别好的情况。另外,这个值会被maxLocality配置覆盖。
    可以看到,随着时间的推移,撇开maxLocality配置不谈,对于本地性的容忍程度越来越大。
3.通过内置的findTask方法,结合前面得到的值,按本地性有由好到差(process_local、node_local、.....)搜索前面提到的几个hashmap。如果传入的executor id或host在hashmap中存在可用的task,那么就返回这个task在taskset中的index,并移除这个index。那么这个返回的task,将被确定了在当前遍历到的这个executor来执行。归纳一下,这个流程就是在当前Pending的task中找出最适合在这个executor上运行的任务。而随着时间的推移,虽然对于本地性的容忍程度越来越大,但搜索过程依然按照本地性由好到坏进行,如果有可用的最佳运行位置,还是会被直接丢上去跑的。
    综上可见,DAGScheduler因为接触的是从数据、对于数据的操作形成task的过程,所以由它能够根据数据位置决定任务运行的最佳位置;而TaskScheduler接触的是整个application的任务调度,所以会根据集群资源情况结合DAGScheduler设置的最佳位置进行调整,最终将任务的执行消息传输给指定的executor。
先到这里,其他部分以后有涉及到了再作补充。

0 0