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,回到createResultStage的getOrCreateParentStages方法获得了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会尽可能的将计算任务分配到要计算的数据所处的存储位置。