在《Spark任务调度概述》一节中介绍了Job的执行框架,也简单介绍了Job的概念。本文先介绍Job的基本概念,然后分析Job的创建和提交的实现原理,最后分析Job的执行状态的。

Job的基本概念

Job是Spark任务调度的顶层抽象,在执行Spark应用过程中,RDD的每个Action操作都会触发一个Job的创建和提交。

有两种类型的Job:

1.Result Job(结果Job)

此类Job是最常见的一种。每次执行Action操作时,就是创建的这一类的Job。该Job用来完成Action及依赖的相关数据的计算。

2.Map-Stage Job(映射阶段Job)

这一类Job主要用于SparkSQL的自适应查询计划中(adaptive query planning)。在提交后续Stage之前查看map操作的输出统计信息。它会在任何下游的Stage提交之前,为ShuffleMapStage计算map操作的输出。

另外要注意,Spark中Job都是通过ActiveJob类来实现的,而Job的类型通过ActiveJob类的finalStage字段的值来进行区分。

Job的创建

Job是提交给DAGScheduler来计算Action操作结果的顶层工作单元,它是在执行RDD的Action操作时产生的。RDD的每个Action操作都会调用SparkContext的runJob函数,而该函数会调用DAGScheduler的runJob函数来进行Job的创建和提交。

在实现层面,Job对应的是ActiveJob类。Job创建的整个过程的大体流程如图1所示:

Job的基本概念和实现原理_java

     图1 Job创建的基本流程

Job的实现类是ActiveJob,在创建ActiveJob对象时jobId和finalStage都已经生成。其中的jobId是一个自增的整数,每次创建新的job时,就会自动加1。jobId会作为FIFO资源调度算法的重要依据。若Job的类型是结果Job时,finalStage的类型是ResultStage,但它只包含最后一个Action动作而创建的Stage,但通过该Stage可以找到依赖的父Stage列表。

Job的创建代码如下:

 val job = new ActiveJob(jobId, finalStage, callSite, listener,properties)

在后续的处理中,会把Job按RDD的shuffle依赖为边界,划分成多个Stage,Job和Stage的关系如图2所示:

Job的基本概念和实现原理_java_02

                图2 Job和Stage的关系

Job执行完成状态的获取

Job执行完成时如何得知其完成状态,如何处理Job的返回信息?其实,在提交Job时,会创建一个JobWaiter对象,该对象等待DAGScheduler提交的任务执行完成。当Job完成时,会把最终的状态信息保存到JobWaiter中。这个过程如图3所示:

Job的基本概念和实现原理_java_03

图3 通过JobWaiter保存Job的完成状态信息

从图3可知,当任务终止或完成时,Worker端的执行后台服务会把任务的状态数据封装到消息StatusUpate中,并发送给Driver端的调度后台服务,调度后台服务通过TaskScheduler解析该状态信息,并发送对应的事件给DAGScheduler的事件处理框架来进行处理。

JobId和Task调度


前面提到过,每个Job都会使用一个唯一的JobId来进行标识,这是一个自整的整数,在同一个Spark应用中(相同SparkContext),每次创建新的Job时,就会在以前的JobId的基础上加1。通过JobId可以用来区分不同的Job。

另外,JobId也会作为FIFO调度的重要依据。

当使用FIFO调度算法时(默认Task调度算法),若同时有多个Task提交,会先比较Task的JobId的大小,JobId越小的Task会先执行。若Task的JobId相同,则会再比较StageId的值,根据StageId的值来决定Task执行的先后顺序。

Job的实现类:ActiveJob

该类代表一个在DAGScheduler运行的Job对象。该类的实现代码很简单,如下:

 private[sparkclass ActiveJob(
     val jobIdInt,
     val finalStageStage,  // job对象中只保留最后一个Stage的引用
     val callSiteCallSite,
     val listenerJobListener,  // Job执行完成后,由该对象来进行获取执行结果
     val propertiesProperties) {
 
   // 分区数,不同类型的Job,获取分区数的方式不同
   val numPartitions = finalStage match {
     case rResultStage => r.partitions.length
     case mShuffleMapStage => m.rdd.partitions.length
  }
 
   // 标识stage的那个分区已经计算完成了
   val finished = Array.fill[Boolean](numPartitions)(false)
   
   var numFinished = 0
 }

下面介绍一个该类的各个成员的意义:

成员名说明
jobId每个Job都有一个唯一的id号来进行标识。这是一个自增的整数。
finalStage该Job计算的Stage,可以是:执行action的ResultStage;或submitMapStage的ShuffleMapStage。根据该字段把Job分为两类。
callSiteCallSite类型的实体,用户应用程序在这里进行Job的初始化。
listener监听Job结束或Job失败的事件,返回Job的执行状态信息。
properties与Job调度相关的属性
numPartitions需要为该Job计算的分区数量
finished是一个Boolean的数组,用来表示在该Job中计算完成的分区
numFinished在该Job中已经计算完成的分区数量

Job的实现类需要注意以下几点:

1.jobId在创建ActiveJob类对象之前就已经创建好了,该jobId是一个自增的整数。

2.finalStage是Job的最后一个Stage,若是通过Action操作触发Job的提交,则是一个ResultStage;若不是,则是一个ShuffleMapStage。

3.Job会记录finalStage已经计算完成的分区数,并通过计算完成的分区数来判断Job是否已经完成。那么为什么finalStage的分区数据计算完成了,job就完成了呢?这是因为在提交Stage(其实是提交该Stage对应的TaskSet)会先提交finalStage依赖的Stage,这样提交finalStage时,其实它依赖的Stage就已经全部执行完了。

另外,还需要注意,ActiveJob类中finalStage的分区数,其实是finalStage的Task数,每个Stage中有多少个分区,就会创建并提交多少个Task。

总结

本文分析Job的创建和提交的实现原理,并对其实现进行了分析。