Spark Stage划分和Task提交源码清晰总结版(看不懂请举报作者)

本文深入探讨了Apache Spark中Job的执行流程,包括Action算子触发、DAGScheduler的角色、TaskScheduler的工作机制以及数据本地化策略。详细分析了SubmitJob方法、handleJobSubmitted方法和Task的最佳计算位置选择,揭示了Spark如何实现高效数据处理。

Spark的Action算子会触发job的执行,job执行流程中的数据依赖关系是以Stage为单位的,同一Job里的Stage可以并行,但是一般如果有依赖则是串行。所有Action算子都会执行SparkContext的RunJob-》DagScheduler的Runjob->DagScheduler的submitJob()接下来进行源码分析

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] = {
    // Check to make sure we are not launching a task on a partition that does not exist.
    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()
    if (partitions.size == 0) {
      val time = clock.getTimeMillis()
      listenerBus.post(
        SparkListenerJobStart(jobId, time, Seq[StageInfo](), properties))
      listenerBus.post(
        SparkListenerJobEnd(jobId, time, JobSucceeded))
      // Return immediately if the job is running 0 tasks
      return new JobWaiter[U](this, jobId, 0, resultHandler)
    }

    assert(partitions.size > 0)
    val func2 = func.asInstanceOf[(TaskContext, Iterator[_]) => _]
    // JobWaiter是一个阻塞线程,它等待job完成后j将结果交给resultHandler
    val waiter = new JobWaiter(this, jobId, partitions.size, resultHandler)
    /* eventProcessLoop是DAGScheduler的事件队列
    因为可能集群同时运行着多个Job,而DAGSchduler默认是FIFO先进先出的资源调度
    这里传入的事件类型为JobSubmitted,post方法会将该JobSubmitted事件put到一个LinkedBlockingQueue当中
   队列的size设置为Integer.MAX_VALUE,如果处理线程处理不及时那么肯定会OOM。
    通过这个eventLoop的设计将job提交由同步改成异步。进入了双端阻塞队列以后会由一个监听器线程不停监听这个队列
    监听到了后就调用onReceive(event)它的底层实现是doOnReceive。
    doOnReceive方法监听到了case为JobSubmitted方法后就调用handleJobSubmitted方法
    */
    eventProcessLoop.post(JobSubmitted(
      jobId, rdd, func2, partitions.toArray, callSite, waiter,
      SerializationUtils.clone(properties)))
    waiter
  }

讲到这里理解到一下RDD的惰性计算特性,在这里所有的方法传递的RDD 都是Final RDD.也就是说我们触发Action算子是通过传递FinalRDD来进行一系列的操作的.

handleJobSubmitted方法做了两件事

1.finalRDD来创建final Stage, 具体是先获得这个final stage的所有父stage, 

获得父stage的过程比较复杂

在这里建议先阅读一下Dependency的源码,了解到ShuffleDependency和NarrowDependenCy,然后每个dependency会对应一个RDD, RDD又会对应一个Seq[DependenCy]序列

先讲getShuffleDependencies根据finalRDD获取他的dependencies, 但是这个方法的源码说过 只会获取最近的一个shuffle dependency, 所以还能返回一个HashSet[ShuffleDependenCy]的原因是CoGroupedRDD这种会有多个,这样父依赖对应的RDD 都会入栈, 再通过他们去获得ShuffleDependenCy这样便获得了一个HashSet, 获取了HashSet后再回到上图中的代码,针对HashSet当中的每个ShuffleDependency去执行getOrCreateShuffleMapStage方法,这个时候会从start到final的宽依赖顺序进行创建stage,此时ResultStage之前的所有stage都已经划分好并添加进shuffleIdToMapStage,回到createResultStagegetOrCreateParentStages方法获得了ResultStage的父ShuffleMapStage从而创建ResultStage.

2.定位到handleJobSubmitted方法的submit(finalStage)方法里。

这个方法会递归先提交它的父stage(为啥?惰性是惰性,但是顺序没变啊,得先找根源的呀),提交父stage的意思是提交task,也就是要定位到submitMissingTasks方法中来.

submitMissingTasks做了4件事情

1.获取Task的最佳计算位置

2.序列化Task的Binary,并进行广播。Executor端在执行task时会向反序列化Task。

3. 根据stage的不同类型创建,为stage的每个分区创建创建task,并封装成TaskSet。(ShuffleMapTask、ResultTask)

4.调用TaskScheduler的submitTasks,提交TaskSet

task最佳位置即数据本地化级别分为以下几种.

  • PROCESS_LOCAL: 数据在同一个 JVM 中,即同一个 executor 上。这是最佳数据 locality。
  • NODE_LOCAL: 数据在同一个节点(worker)上。比如数据在同一个节点的另一个 executor上;或在 HDFS 上,恰好有 block 在同一个节点上。速度比 PROCESS_LOCAL 稍慢,因为数据需要在不同进程之间传递或从文件中读取
  • NO_PREF: 数据从哪里访问都一样快,不需要位置优先
  • RACK_LOCAL: 数据在同一机架的不同节点上。需要通过网络传输数据及文件 IO,比 NODE_LOCAL 慢
  • ANY: 数据在非同一机架的网络上,速度最慢.

task最佳位置的计算是通过DAGScheduler.getPreferredLocsInternal方法来操作的 源码已经做了清晰的解释如下:

private def getPreferredLocsInternal(
      rdd: RDD[_],
      partition: Int,
      visited: HashSet[(RDD[_], Int)]): Seq[TaskLocation] = {
    // If the partition has already been visited, no need to re-visit.
    // This avoids exponential path exploration.  SPARK-695
    if (!visited.add((rdd, partition))) {
      // Nil has already been returned for previously visited partitions.
      return Nil
    }
    // If the partition is cached, return the cache locations
    val cached = getCacheLocs(rdd)(partition)
    if (cached.nonEmpty) {
      return cached
    }
    // If the RDD has some placement preferences (as is the case for input RDDs), get those
    val rddPrefs = rdd.preferredLocations(rdd.partitions(partition)).toList
    if (rddPrefs.nonEmpty) {
      return rddPrefs.map(TaskLocation(_))
    }

    // If the RDD has narrow dependencies, pick the first partition of the first narrow dependency
    // that has any placement preferences. Ideally we would choose based on transfer sizes,
    // but this will do for now.
    rdd.dependencies.foreach {
      case n: NarrowDependency[_] =>
        for (inPart <- n.getParents(partition)) {
          val locs = getPreferredLocsInternal(n.rdd, inPart, visited)
          if (locs != Nil) {
            return locs
          }
        }

      case _ =>
    }

    Nil
  }

通过上述代码获取了任务计算最佳位置。 然后回到上面说的submissingtask方法来 看到第四步调用TaskScheduler的submitTasks,提交TaskSet。

这个submitTasks做了几件事

为每一个taskset创建一个tasksetManager用于管理TaskSet,包括任务推断,Task本地性,并对Task的资源进行分配,如果task提交失败它也会帮忙重新提交,这里重点了解task资源分配, 具体在submitTasks方法里调用backend.reviveOffers()。然后通过模式匹配调用makeoffers方法.

case ReviveOffers =>
        makeOffers()

makeoffer方法里做了几件事。

1、从executorDataMap当中过滤掉已死的executor,然后将executor封装成workoffers对象。

2、在这个方法的最后有行代码是这样的 launchTasks(scheduler.resourceOffers(workOffers)),所以我们只需要最后把resourceOffers看懂任务就完成了。

resourceOffers做了几件事

1、遍历workOffers来更新host和executor关系、host和机架的关系。

2.可用的executor进行shuffle分散,避免将task放在同一个worker上,进行负载均衡(这里还没看懂,源码就这么注释了一下 很懵逼)

3、在一个循环 里做了任务分配 也就是resourceOfferSingleTaskSet方法(也就是在executor当中启动task了).

[for (currentMaxLocality <- taskSet.myLocalityLevels)  ,   myLocalityLevels是一个ArrayBuffer依次顺序存储了本地化级别参数]

这里面有个cpu的相关配置cpus_per_task,也就是我们一个task占几个核, 默认是1核。 可以修改。

那么叽叽歪歪了半天对实践的应用有没有好处呢? 有, 了解了数据本地化后就可以查看自己的任务的数据本地化级别, 如果process_local很少的话就可以调节那个降级的等待时间来增加Process_local的task,调节完后对比总运行时间来进行调优。

task分配的算法理念叫做移动数据不如移动计算,就是Spark会尽可能的将计算任务分配到要计算的数据所处的存储位置。

参考文章:http://www.louisvv.com/archives/1836.html

### Apache Spark 3.0 Stage 划分源码实现 在 Apache Spark 3.0 中,Stage划分主要由调度器(Scheduler)负责完成。具体来说,DAGScheduler 是负责将逻辑执行计划转换为物理执行计划的核心组件之一。 当提交一个作业到 Spark 集群时,DAGScheduler 将解析该作业并构建有向无环图 (Directed Acyclic Graph, DAG),其中节点代表 RDD 或者 DataFrame/Dataset 操作,边则表示这些操作之间的依赖关系[^2]。 对于 Stage 的实际划分: - **ShuffleDependency**:如果存在 Shuffle 操作,则会在此处切断当前阶段,并形成一个新的 shuffle map stage reduce stage。 - **Narrow Dependency**:如果没有宽依赖(即只涉及窄依赖),那么所有的转换都会被放在同一个 result stage 内处理。 这种机制确保了数据可以在不同的计算节点之间高效传输,同时也使得故障恢复更加容易,因为每个 stage 可以独立重试而不影响其他部分的工作。 #### 关键类与方法 以下是几个重要的类函数,在理解如何进行 stage 划分时非常有用: 1. `org.apache.spark.scheduler.DAGScheduler` 类中的 `submitMissingTasks()` 方法用于提交缺失的任务集给 Task Scheduler 执行;此过程中包含了对 stages 进行规划的过程。 2. `computeJobBreakpoints()` 函数用来识别 job 中所有可能成为新 stage 边界的点,特别是那些具有 shuffle dependencies 的地方。 ```scala // 提交任务前准备stages private[scheduler] def submitMissingTasks( finalStage: ResultStage, jobId: Int): Unit = { ... } ``` 3. 当遇到 shuffle dependency 时,`getShuffleMapStageFor()` 被调用以创建相应的 shuffle map stage 实例。 通过以上描述可以看出,stage划分紧密围绕着依赖关系展开,尤其是区分宽依赖还是窄依赖这一点至关重要。这不仅决定了最终生成多少个 stage,也直接影响到了整个应用程序性能表现的好坏。
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值