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任务慢如何排查 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终端点。