Spark-Job执行流程分析

在“Application启动流程分析”文章的第4步提到了,driver接收Executor发送RegisterExecutor消息之后,通过makeOffers()任务随机分发给Executor。Executor(即CoarseGrainedExecutorBackend)收到后会将Task封装成TaskRunner对象,然后提交到Executor的线程池中去执行。Executor的线程池是:newCachedThreadPool

 

Spark执行作业的大致流程为:

Spark中的作业会按照RDD间的依赖关系划分成多个stage,stage的划分是由DAGScheduler来完成的,划分的依据是宽依赖(是不是宽依赖根据对RDD操作使用哪个算子决定的,比如map就不是宽依赖)。然后DAGSchedule会按照调度依据数据本地性将任务交给TaskScheduler,由它将任务发送到Work节点,交给Executor执行。

如果某阶段失败,由DAGScheduler调度重新执行,如果某个Task失败,由TaskScheduler调度重新执行。DAGSchedule还记录RDD被存到磁盘,数据本地性,监控任务阶段等操作。TaskSchedule发现某一任务没有运行完,可能启动另外一个相同的任务,哪个先完成哪个结果(这个后续再分析下吧...目前没看到相关的代码)。

Spark中的Job分成两类: 一类是action操作触发的,另外一个是shuffle操作触发的,前者返回的是ResultStage,后者返回的是ShuffleMapStage。看代码的时候可以看到只有Shuffle操作的中间结果会写磁盘,其余窄依赖的中间结果不会保存的!

 

作业执行源码分析:

接下来以这个简单的代码为例,对执行作业的源码进行分析,作业代码如下:

发现parallelize和map,filter等函数基本上都会调用到withScope()和sc.clean(f)这两个函数,先来了解下这两个函数是干嘛用的。

 

WithScope{}:

这个方法是做DAG可视化用的,看下它的注释,发现它是想让所有created RDD的操作都在这个方法中进行。方法内部对输入的方法没有进行过任何的改动,所以不会影响方法的执行逻辑,所以这个函数我们可以不管它。

从代码中可以看出,map函数的返回其实是一个MapPartitionRDD,所以map会被封装在WithScope里面

 

sc.clean(f):

它是为了在分布式环境上正确的闭包。闭包会把它对外的引用保存到自己内部,这样闭包就可以单独使用,而不用担心它脱离了当前作用域。但是在分布式环境中,如果外部引用是无法serializable的,就不能正确被发送到Worker节点上去了。这个函数就是清理外围类中无用域,降低序列化的开销,防止不必要的不可序列化异常。(了解下就可以了,毕竟不是我们要关注的重点)

 

1.构建各个RDD之间的依赖关系,调用算子时完成。

parallelize()构造的RDD,返回一个ParallelCollectionRDD,这个没啥好说了,new了一个对象而已。

没有指定partition数量的情况下,默认生成多少个partition是由***SchedulerBackend类中根据如下方法计算的:

当代码跑到count()操作的时候,我们发现在最后那个RDD(代码中filter()方法返回的那个RDD)中已经完整的记录了与所有父类RDD的依赖关系,并且记录父类RDD是调用哪些方法生成的,对应的代码在哪儿:

后来调细了之后才发现,rdd.map(***)的代码最终会跳转到RDD.scala里面,然后构建RDD的依赖关系。也就是说创建RDD对象的时候,就已经记录了该RDD和RDD的依赖关系,并且根据Dependency类型可以知道是宽依赖还是依赖

可以看到,最终会使用当前RDD的引用构建一个OneToOneDependency对象,Dependency意思是RDD依赖关系,OneToOneDependency代表窄依赖,RDD对象之间的依赖关系官方术语叫Lineage。

 

2.action操作触发Job计算,将rddcleandFunc、所有partitions对应 编号整理partition结果集的函数等信息传递给dagScheduler这一步是Job执行前的准备

在提交任务时已经构造好了存放返回结果集的数据结构,每个partition对应一个结果,所有的结果汇总到一个Array[U]中。如下图所示:输入是Partition编号,输出是res,res是任意类型,然后将res赋值给results(index)

任务交给dagScheduler进行调度(全部完成之后做清理和检查是否要checkPoint()的操作):

 

3.dagScheduler执行submitJob()方法将DAGScheduler自身引用jobIdpartition size以及处理partition结果集函数这四个信息封装到一个JobWaiter对象,并将这个对象交eventProcessLoop内部类进行提交然后提交任务的线程会阻塞等待Job完成,然后根据Job的返回状态打印对应日志。(ps:JobWaiter对象还可以用于取消本次Job)

submitJob()内部创建了一个JobWaiter对象,这个对象会立即返回。它是用于管理Job状态用的,例如Finish、cancel,相当于JobWaiter是一个回调对象:

JobWaiter对象的定义如下:

注意,submitTask()方法中,任务是通过eventProcessLoop.post(JobSubmitted(...))提交的,这是向消息队列中放入作业,然后执行对应的逻辑。eventProcessLoop这个对象是在new DAGSchedule时创建的,本质是一个单独的线程。最终会调用到dagScheduler中的handleJobSubmitted()方法。

那不还是要让dagScheduler执行么?为啥要单独再开一个线程 – 按照网上找到的说法,这里是为了处理方式的统一,不管是别人的消息还是自己的消息统一放在一个地方处理利于扩展,并且代码也会很干净。

 

4.eventProcessLoop线程会调用dagScheduler中的handleJobSubmitted()方法。使用createResultStage()划分stage,该方法返回List[Stage]

createRsultStage()方法创建所有的stage创建stage的方法是从最后一个RDD生成的ResultStage开始,使用getOrCreateParentStages()找出其祖先RDD所有的shuffle操作如果没有Shuffle,当前job只有一个ResultStage。如果有shuffle,那么当前job至少还有一个ShuffleMapStage有ShuffleMapStage代表该ResultStage存在父调度阶段

创建好所有parentStages(即ShuffleMapStage之后),放到一个List中返回,越靠近当前RDD的ShuffleMapStage越在前面,最后才创建action操作对应的ResultStage。

我们来看下getOrCreateParentStages()方法内部是怎么划分出当前RDD的所有ShuffleDependency的:

该方法中首先调用了getShuffleDependencies()方法,它目的在于获取该rdd的第一个宽依赖。举个例子:如果 A => B => C,那么返回的就是B => C的宽依赖,而不会返回A => B的宽依赖。

 

确实存在shuffle,则判断对应的ShuffleMapStage的信息是否已存在(创建),如果已存在就直接返回,否则需要接着执行getMissingAncestorShuffleDependencies()方法,计算从shuffleDep所在RDD开始往前回溯的所有父RDD的ShuffleDependency信息

getMissingAncestorShuffleDependencies()用于查找没有在shuffleToMapStage中注册的所有祖先shuffle依赖。其实就是递归的调用上面的getShuffleDependencies()方法,将宽依赖一个个找出来,所以你会发现它们的代码很像,就只有下面标红的那一点不一样:

最后调用createShuffleMapStage()方法为每一个ShuffleDependency创建一个ShuffleMapStage,放入到List[Stage]中。当前RDD越远的宽依赖越在栈顶,所以计算stage是从后往前计算的,即最开始的RDD最先被计算。

创建ShuffleMapStage的代码如下,从中可以看出,RDD有多少个Partition就会对应有多少个Tasks。一个ShuffleMapStage会记录所有的父Stage以及当前RDD的shuffleDep信息。最后将该stage注册到mapOutputTracker中,这样做是避免stage重试的时候全部重新计算 

通俗点说就是一个ShuffleMapStage会记录它自己和它的祖先们是怎么来的。至此就已经完成了Stage的划分注册

 

5.Stage划分完成之后,接着调用handleJobSubmitted()方法中的submitStage()方法提交finalStage(即ResultStage)。方法内部其实依次递归的解析和提交每个stage所依赖的父stage,所以最终最先提交的是没有任何依赖的stage(开始的那一个stage)

getMissingParentStages()方法和之前创建stage的方法类似,这里就不再对它进行详细分析了。它的作用就是看下该stage的父stage如果没有提交的话,先提交父stage。我们直接看submitMissingTasks(stage, jobId.get)方法看下提交一个stage到底要。

 

6.按Stage的顺序提交Stage,根据Partition的数据本地性每一个Stage构建对应的TaskLocation,将构建好之后的Stage序列化发送到Spark的各节点。(注意如果Stage是重新提交的话,已经Success的partition是不需要重新计算的)

首先判断多少个Partition需要计算,侧面说明了stage中某个partition完成之后是会做一个标记的,这样做是为了避免stage重提时的重复计算。输出的是0-job.numPartitions这样的partiton编号。

将stage的信息(id+partitionNum)记录到outputCommitCoordinator,该变量是在SparkEnv中,具体干啥用的还不太清楚……

然后为每个Partition找到最佳执行位置,即考虑到数据本地性。该方法会递归调用父RDD的getPreferredLocations(split:Partition)找到最佳执行位置。(数据本地性怎么确定,后续“补充”那里有讲到)

然后将stage的信息序列化,broadcast到各个Executor上。

最后根据stage需要compute的Partition的数量对应创建多少个Task这些Task集中放到Seq里面。Task会记录locality等信息。对于ResultStage生成ResultTask,对于ShuffleMapStage生成ShuffleMapTask。

最后的最后,将这些Seq[Task[_]]交给TaskScheduler执行;或者当前stage完成,提交下一个stage。

 

7.TaskSet交给TaskScheduler的调度池再调用***SchedulerBackendreviveOffers()给这些Task分配资源执行Task

Task是由TaskManager来管理的,每批次TaskSet都会新建一个TaskManager,然后加入到调度器统一调配。调度器的种类有两种,在初始化SparkContext的时候创建的,默认的是FIFO,可以看下之前的初始化SparkContex那篇文章。

这里的rootPool是个啥????它是在SparkContex初始化TaskSchedulerImpl时创建的,好像是个队列…

最后调用***SchedulerBackend中的reviveOffers(),该方法估计又是为了统一,反正最终又执行到了***SchedulerBackend 中的makeOffers()给Task分配资源然后执行:

makeOffers()会先获取集群中可用的Executor,然后发送到TaskSchedulerImpl中对任务集的任务分配资源,最后提交到LaunchTask方法中:

资源分配的过程是这样的:(补充里面其实讲到了)

给每个Task创建Description的代码如下,创建Description相当于是说这个Task要在哪个Executor上运行,并且数本地性怎样…不知道这个属性用不用的上:

然后就调用launchTasks,给对应Executor发送消息,让Executor执行任务了。

 

8.Executor反序列化Task信息(TaskDescription),构造一个TaskRunner对象(Runnable)然后扔到线程池中执行Task

 

看下TaskRunner中的run()方法,看下任务内部到底是怎么执行的:

首先是序列化任务依赖的jar包以及文件,因为我是把所有的依赖都打到一个包里面的,所以这里看到只有一个with-dependencies.jar的整包。接着是执行Task,Task是一个抽象类,真正执行的是ShuffleMapTask或者ResultTask中的方法。执行完毕之后,将结果发送回Driver。

 

来细看下ShuffleMapTaskResultTask分别是怎么执行的 

先看ResultTask:

它在计算时会调用func(context,rdd.iterator(partition,context)),其中rdd.iterator会递归调用调用RDD的compute(),最终会从第一个RDD的元素开始计算。从代码调试的结果也可以看出,确实是递归执行的。

 

再看下ShuffleMapTask:

它返回给Driver的是MapStatus,它是将中间结果写到文件,然后将这些文件的位置返回给Driver。

这个涉及到Shuffle内部的细节后续分析到Shuffle的时候再细讲

 

根据之前的描述,梳理下Job执行的流程图:

 

补充:Task的数据本地性介绍

Spark在处理任务时会考虑数据的本地性,毕竟移动计算比移动数据代价要小,利于提升程序的执行效率。Spark的数据本地性共划分为五种情况:

  1. PROCESS_LOCAL    同一个Executor中,速度最快
  2. NODE_LOCAL   本地,数据在其他Executor中/HDFS恰好有个Block
  3. NO_PREF 没有偏好,哪里访问都也一样快
  4. RACK_LOCAL 本地机架,需要通过网络传输
  5. ANY 任何,速度最慢

之前划分stage的时候说过,在为每个Partition构建对应的Task信息(即TaskLocation)时会执行getPreferredLocations(split:Partition)方法,确定Partition优先位置,代码逻辑如下:

该方法的返回是一个TaskLocation变量,这是一个trait,它有三个实现类,分别代表数据存储在不同的位置,从上到下它们的含义分别是:

  1. 数据存储在Executor内存中,即Partition被cache到了内存中(返回executorId+host)
  2. 数据存储在HDFS上(返回host)
  3. 数据存储在host这个节点的磁盘上(返回“hdfs_”+host)

知道了partition的本地性之后,接着就是将任务加入到队列计算整个TaskSet本地性了,计算是在submitTask()方法构建TaskSetManager时进行的:

 

接着会执行上面说过的步骤7中的第6步再细讲下到低是怎么为每一个TaskSet分配资源的: resourceOfferSingleTaskSet(),为每一个TaskSet分配资源:

(怎么为每个Task分的?这里后续补充下吧)

从编写的代码运行逻辑中也可以看出,因为我们的RDD是第一次计算并且没有真正cache过(真正cache是指第一次action操作触发之后,会记录信息到cacheLocs里面,第二次才会从这个里面找信息),所以不会走第一个方法。而且我们的RDD也没有checkpoint也不是最开始数据源最近拉数据的那个RDD,所以会走第三个方法,递归的去找数据:

任务执行时,本地性可以在Spark的UI界面直接看到:

回答之前留下的几个疑问:

问题1:收到RegisterExecutor消息的时候,也会调用makeOffer()。两者哪个在前哪个在后,优先看下sparkContext是怎么启动的吧,不然又是一头雾水:…确实是SparkContext启动在前,但是那个时候是没有任务的,发送的空的,所以它是在等待执行具体的task的时候用的。

 

问题2:注意下,这个***ScheduleBackend是在driver端初始化用于进行一些资源管理的,CoarseGrainedExecutorBackend是在Executor端进行初始化,它是Executor运行的容器。两者名字很像,但是功能是不一样的。(看过SparkContext启动流程之后,这两个干嘛用的就特别清晰了,轻者是管理任务怎么分配什么的,收集Worker的信息,计算Executor和Driver启动在哪些Worker上,Task扔给哪些Executor执行)

 

问题3:最后就是之前提到过的……Stage完成之后,DAGSchedule会调用handleTaskCompletion方法,根据Stage返回的结果判定是否是 Success/Resubmitted/FetchFailed…然后进行相应的处理。

 

 

遗留的问题:

  1. ShuffleMapTask调用write的时候是如何进行Shuffle的?
  2. RDD的checkpoint是怎么操作的?
  3. Job执行的过程中,内存是怎么管理的?
  4. map之类的dep比较好构建,groupByKey的依赖是怎么构建的?
  5. Executor中途挂了怎么办?

后续博客分析这些问题

 

参考:

http://www.mamicode.com/info-detail-1066067.html(withScope的作用)

https://www.jianshu.com/p/51f5a34e2785(sc.clean(f)的作用)

https://www.cnblogs.com/jcchoiling/p/6438435.html(DAGSchedule中为什么要单独再开一个线程处理消息)

https://www.jianshu.com/p/8e7cd025d0ba(getProferLocation()方法解析)

http://www.cnblogs.com/chushiyaoyue/p/7468952.html(SparkContex初始化的时候调用makeOffers())

https://blog.csdn.net/qq_41774522/article/details/81707613(Spark执行流程图,很详细)

https://www.jianshu.com/p/05034a9c8cae/(Task数据本地性介绍)

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值