Spark源码解析05-Submit提交流程及Master资源分配和Executor启动

615 阅读6分钟

1、前言

由前面的文章Spark源码解析04-Submit提交流程及SparkContext准备流程我们已经知道了SparkContext准备过程中会向Master发送RegisterApplication注册Spark应用

我们继续看下cluster提交流程

  1. 任务提交后,Master(资源管理器)会找到一个Worker(节点)启动Driver进程,Driver启动后向Master注册应用程序
  2. Master根据submit脚本的资源配置,启用相应的Worker
  3. 在Worker上启动所有的Executor,Executor反向到Driver注册
  4. Driver开始执行main函数,之后执行到Action算子时,开始划分stage,每个stage生成对应的taskSet,之后将task分发到各个Executor上执行

步骤1的源码已经全部跟踪完毕了,接下来我们继续步骤2中的Master分配

2、Master资源分配01

由资源层源码分析我们知道,Master收到消息后,会通过receiver()方法进行匹配处理逻辑,我们直奔主题,来到Master.receive()方法,寻找RegisterApplication消息的处理逻辑

18-Master.receive()-RegisterApplication

下面我们简单解释下代码逻辑

if (state == RecoveryState.STANDBY) {
  // 当前Master是standby,不做处理
  // ignore, don't send response
} else {
  // 将app信息和driver信息封装到ApplicationInfo()
  val app = createApplication(description, driver)
  // 将封装后的app信息加入Master维护的等待启动的集合中
  registerApplication(app)
  persistenceEngine.addApplication(app)
  // 向Driver发送RegisteredApplication消息
  driver.send(RegisteredApplication(app.id, self))
  // 为等待的程序分配资源
  schedule()
}

06-Master-createApplication()

下面我们再看下schedule()代码,我们在前面的文章Spark源码解析03-Submit提交流程及Driver启动流程已经解释过部分schedule()源码了,下面我们简单回忆下

private def schedule(): Unit = {

    // 寻找还活着的Worker
    val shuffledAliveWorkers = Random.shuffle(workers.toSeq.filter(_.state == WorkerState.ALIVE))
    val numWorkersAlive = shuffledAliveWorkers.size
    var curPos = 0

    /*
     * 1、Driver相关调度
     */
    // 1.1、遍历等待集合中的Driver
    for (driver <- waitingDrivers.toList) {
      var launched = false
      var numWorkersVisited = 0
      // 1.2、寻找合适的Worker
      while (numWorkersVisited < numWorkersAlive && !launched) {
        val worker = shuffledAliveWorkers(curPos)
        numWorkersVisited += 1
        if (worker.memoryFree >= driver.desc.mem && worker.coresFree >= driver.desc.cores) {
          // 1.3、启动Driver
          launchDriver(worker, driver)
          waitingDrivers -= driver
          launched = true
        }
        curPos = (curPos + 1) % numWorkersAlive
      }
    }
    /*
     * Worker相关调度
     */
    startExecutorsOnWorkers()
  }

06-Master-schedule

由于Driver相关调度的我们已经分析过了,这次我们直奔主题到startExecutorsOnWorkers()方法,可以看到这个方法还是比较简单的,因为注释已经解释的很清楚代码具体是做什么的

06-Master-startExecutorsOnWorkers()

下面我们继续把代码逻辑解释下

private def startExecutorsOnWorkers(): Unit = {

  // 遍历Master中记录的待分配的app
  for (app <- waitingApps) {
    val coresPerExecutor = app.desc.coresPerExecutor.getOrElse(1)//需要分配的核数

    if (app.coresLeft >= coresPerExecutor) {
      // 过滤到不可用的worker
      // 剩余核数以及内存都满足要求,并按剩余核数倒序排序
      val usableWorkers = workers.toArray.filter(_.state == WorkerState.ALIVE)
        .filter(worker => worker.memoryFree >= app.desc.memoryPerExecutorMB &&
          worker.coresFree >= coresPerExecutor)
        .sortBy(_.coresFree).reverse
      // 确认需要分配的核数
      val assignedCores = scheduleExecutorsOnWorkers(app, usableWorkers, spreadOutApps)
	  // 通知worker为executor分配资源
      for (pos <- 0 until usableWorkers.length if assignedCores(pos) > 0) {
        allocateWorkerResourceToExecutors(
          app, assignedCores(pos), app.desc.coresPerExecutor, usableWorkers(pos))
      }
    }
  }
}

从上面代码逻辑我们需要将重点关注到一个方法scheduleExecutorsOnWorkers()用于为app分配资源的计算,下面我们来看下scheduleExecutorsOnWorkers()方法,由于方法长度过长,我们暂时先忽略了内部定义的canLaunchExecutor()方法

06-Master-scheduleExecutorsOnWorkers()-01

06-Master-scheduleExecutorsOnWorkers()-02

从截图我们可知得到,在scheduleExecutorsOnWorkers()方法中定义了以下属性外

val coresPerExecutor = app.desc.coresPerExecutor // 一个Executor需要分配的核数,由用户指定
val minCoresPerExecutor = coresPerExecutor.getOrElse(1) // 没有指定默认是1
val oneExecutorPerWorker = coresPerExecutor.isEmpty  // 表示一个Executor是否要消耗所有核数
val memoryPerExecutor = app.desc.memoryPerExecutorMB // 一个Executor需要分配的内存大小
val numUsable = usableWorkers.length
val assignedCores = new Array[Int](numUsable) 
val assignedExecutors = new Array[Int](numUsable) 
// 【所需要分配的总核数】 如果Worker有足够的核数,则取我们所需要的【总核数】,如果不够,则取剩余的核数
var coresToAssign = math.min(app.coresLeft, usableWorkers.map(_.coresFree).sum) 

还有一个的三层循环嵌套的代码

// 通过canLaunchExecutor()方法过滤可以使用的Worker
var freeWorkers = (0 until numUsable).filter(canLaunchExecutor)
while (freeWorkers.nonEmpty) { // 第一层
  freeWorkers.foreach { pos =>  // 第二层
    var keepScheduling = true
    // 针对每一个Worker进行分配
    while (keepScheduling && canLaunchExecutor(pos)) { // 第三层
      coresToAssign -= minCoresPerExecutor
      assignedCores(pos) += minCoresPerExecutor

      // 一个Executor消耗Worker的所有核数
      if (oneExecutorPerWorker) {
        assignedExecutors(pos) = 1
      } else {
        // 一个Executor消耗Worker的部分核数
        assignedExecutors(pos) += 1
      }
	  
      // 水平分配还是垂直分配
      if (spreadOutApps) {
        keepScheduling = false
      }
    }
  }
  freeWorkers = freeWorkers.filter(canLaunchExecutor)
}

下面我们再继续看下内部定义的canLaunchExecutor()方法,从注释我们也可以得知该方法的作用:“返回指定的Worker是否为该程序启动Executor”,同时也知道该方法是针对一个Worker进行分析的

06-Master-scheduleExecutorsOnWorkers()-canLaunchExecutor()

从canLaunchExecutor()可以看到,会判断Worker的内存以及核数是否符合要求,下面我们把代码逻辑解释下

def canLaunchExecutor(pos: Int): Boolean = {
  // 【需要分配的核数】 是否大于 【配置中的最小核数】
  val keepScheduling = coresToAssign >= minCoresPerExecutor
  // 【该Worker减去已分配的核数】 是否大于 【配置中的最小核数】
  val enoughCores = usableWorkers(pos).coresFree - assignedCores(pos) >= minCoresPerExecutor

  // 是否一个Worker只启动一个Executor或者第一次分配
  val launchingNewExecutor = !oneExecutorPerWorker || assignedExecutors(pos) == 0
  if (launchingNewExecutor) {
    // 已分配的内存
    val assignedMemory = assignedExecutors(pos) * memoryPerExecutor
    // 是否有足够内存分配
    val enoughMemory = usableWorkers(pos).memoryFree - assignedMemory >= memoryPerExecutor
    // 是否已经超过限额
    val underLimit = assignedExecutors.sum + app.executors.size < app.executorLimit
    
    keepScheduling && enoughCores && enoughMemory && underLimit
  } else {
    // We're adding cores to an existing executor, so no need
    // to check memory and executor limits
    keepScheduling && enoughCores
  }
}

3、Master资源分配02

看完上面的代码解释,相信很多读者会跟笔者一样处于一个很懵的状态,Master怎么将资源进行划分的,下面我们通过一个实际的例子来解释下Master的资源划分

spark-submit 
--master spark://127.0.0.1:7077 \
--deploy-mode cluster \
--driver-memory 1g \
--class org.apache.spark.examples.SparkPi \
${SPARK_HOME}/examples/jars/spark-examples.jar \
#本篇文章主要关注下面三个参数
--executor-cores  4 \  #指定一个executor占4核
--totle-executor-cores 12 \  #指定所有executor总共占12核,间接指定了executor有 12/4 = 3个
--executor-memory  4g \  #指定的每个executor占4g内存

上面的shell提交脚本我们主要关注与Executor相关的三个参数,下面我们用图将属性值与上面源码对应起来

val coresPerExecutor = 4 // 一个Executor需要分配的核数,由用户指定
val minCoresPerExecutor = 4 // 没有指定默认是1
val oneExecutorPerWorker = false  // 表示一个Executor是否要消耗所有核数
val memoryPerExecutor = 4g // 一个Executor需要分配的内存大小
val numUsable = usableWorkers.length
val assignedCores = new Array[Int](numUsable) 
val assignedExecutors = new Array[Int](numUsable) 
// 【所需要分配的总核数】 如果Worker有足够的核数,则取我们所需要的【总核数】,如果不够,则取剩余的核数
var coresToAssign = 12 

19-资源分配01

下面我们默认可以用的Worker的数量是3,且三个Worker的核数和内存数不同

val numUsable = 3
val assignedCores = new Array[Int](numUsable) 
val assignedExecutors = new Array[Int](numUsable) 

下面我们将属性图准备好,准备用图形将Master分配资源的流程走一遍

19-资源分配02

从三个Worker的资源我们可以知道其肯定可以通过canLaunchExecutor()方法的过滤

取出下标为0的第一个Worker(8C 12G),接下来我们回到上文的三层循环嵌套的代码

// 通过canLaunchExecutor()方法过滤可以使用的Worker
var freeWorkers = (0 until numUsable).filter(canLaunchExecutor)
while (freeWorkers.nonEmpty) { // 第一层
  freeWorkers.foreach { pos =>  // 第二层
    var keepScheduling = true
    // 针对每一个Worker进行分配  
    while (keepScheduling && canLaunchExecutor(pos)) { // 第三层
      // 步骤一
      coresToAssign -= minCoresPerExecutor
      assignedCores(pos) += minCoresPerExecutor

      // 步骤二:一个Executor消耗Worker的所有核数
      if (oneExecutorPerWorker) {
        assignedExecutors(pos) = 1
      } else {
        // 一个Executor消耗Worker的部分核数
        assignedExecutors(pos) += 1
      }
      // 步骤三:水平分配还是垂直分配
      if (spreadOutApps) {
        keepScheduling = false
      }
    }
  }
  freeWorkers = freeWorkers.filter(canLaunchExecutor)
}

这边下标为0的Worker经过了第一二步骤后,这时coresToAssign = 8,分配情况如下图

19-资源分配03

从上图我们可知经过一二步骤就已经完成第一轮分配后,还有个第三步骤,我们仔细解释下,可以看到第三层循环里有两个判断条件,其中的keepScheduling在步骤三可以修改

  • keepScheduling为false,第三层循环结束,Worker只分配一次
  • keepScheduling为true(默认),第三层循环继续,Worker持续分配直到资源不够分配为止

通过上面的分析,我们可以知道keepScheduling是控制资源分配中Worker一次性分配完,还是Worker之间水平分配,然而影响keepScheduling的赋值而是另外一个参数spreadOutApps,这个参数我们可以在Master中看到,默认值是true,既:默认会给keepScheduling赋值为false,Worker按水平分配

while (keepScheduling && canLaunchExecutor(pos)) { // 第三层

  // 步骤三:水平分配还是垂直分配
  if (spreadOutApps) {
    keepScheduling = false
  }
}

06-Master-spreadOutApps

接下来下标为1的Worker(12C 32G)进行分配后coresToAssign = 4,结果如下

19-资源分配04

由于canLaunchExecutor()中的判断,仍成立

coresToAssign >= minCoresPerExecutor

继续下标为2的Worker(12C 20G)进行分配,结果如下

与此同时分配后的coresToAssign = 0,已经不能满足canLaunchExecutor()中的判断条件,也就是Master资源分配划分到此结束

19-资源分配05

4、Master资源分配03

从Spark的提交流程我们知道,Master进行资源划分好后,会通知Worker启动相应的Executor,下面我们继续源码跟踪下是否如此,继续startExecutorsOnWorkers()方法,看下最后的for循环,拿着我们前面分配好的assignedCores,分别遍历并调用allocateWorkerResourceToExecutors()方法

private def startExecutorsOnWorkers(): Unit = {

  // 遍历Master中记录的待分配的app
  for (app <- waitingApps) {
    val coresPerExecutor = app.desc.coresPerExecutor.getOrElse(1)//需要分配的核数

    if (app.coresLeft >= coresPerExecutor) {
      // 过滤到不可用的worker
      // 剩余核数以及内存都满足要求,并按剩余核数倒序排序
      val usableWorkers = workers.toArray.filter(_.state == WorkerState.ALIVE)
        .filter(worker => worker.memoryFree >= app.desc.memoryPerExecutorMB &&
          worker.coresFree >= coresPerExecutor)
        .sortBy(_.coresFree).reverse
      // 确认需要分配的核数
      val assignedCores = scheduleExecutorsOnWorkers(app, usableWorkers, spreadOutApps)
        
	  // 通知worker为executor分配资源
      for (pos <- 0 until usableWorkers.length if assignedCores(pos) > 0) {
        allocateWorkerResourceToExecutors(
          app, assignedCores(pos), app.desc.coresPerExecutor, usableWorkers(pos))
      }
    }
  }
}

下面我们来看下allocateWorkerResourceToExecutors(),从方法名我们可以知道中Worker将资源分配给Executor

06-Master-allocateWorkerResourceToExecutors()

该方法的代码逻辑比价简单,我们就不做过多解释

private def allocateWorkerResourceToExecutors(
    app: ApplicationInfo,
    assignedCores: Int,
    coresPerExecutor: Option[Int],
    worker: WorkerInfo): Unit = {
  
  // 该Worker分配多少个Executor,就上面个例子, (分配的核数)4 / (配置文件里的最小核数)4  = 1
  val numExecutors = coresPerExecutor.map { assignedCores / _ }.getOrElse(1)
  val coresToAssign = coresPerExecutor.getOrElse(assignedCores)
  for (i <- 1 to numExecutors) {
    val exec = app.addExecutor(worker, coresToAssign)
    // 向Worker发送消息
    launchExecutor(worker, exec)
    app.state = ApplicationState.RUNNING
  }
}

接下来我们看下launchExecutor(),第755行代码我们可以看到,Master向Worker发送LaunchExecutor消息,来通知Worker启动Executor

worker.endpoint.send(LaunchExecutor(masterUrl,
      exec.application.id, exec.id, exec.application.desc, exec.cores, exec.memory))

06-Master-launchExecutor()

至此,Master资源分配已经全部跟完,接下来总结一下Master分配的一些小细节

小结

  • Master资源划分的主要方法是startExecutorsOnWorkers()
  • Master在进行资源划分时默认是按Worker水平划分

5、Executor启动流程

上文我们知道了Master会向Executor发送LaunchExecutor消息,通知其启动Executor,我们直奔主题来到,Worker的receive()方法,查看LaunchExecutor的处理逻辑,由于我们是在Windows环境,java启动进程的实现在Window和Linux平台的实现是不一样的,所以这边我们不必关注太多关于Executor启动细节,我们关心的是Executor启动后的操作

20-Worker-receive()-LaunchExecutor01

20-Worker-receive()-LaunchExecutor02

关于Executor的启动,我们要回忆上一篇文章Spark源码解析04-Submit提交流程及SparkContext准备流程的一个属性org.apache.spark.executor.CoarseGrainedExecutorBackend,这个就是我们真正启动的Executor的全类名

11-SparkContext-属性-04

下面我们来看下CoarseGrainedExecutorBackend类,由于这个类有伴生对象,我们直接从main()方法入手,可以看到这个main()方法实际就是在对参数进行封装,然后调用run()方法

21-CoarseGrainedExecutorBackend-main()01

21-CoarseGrainedExecutorBackend-main()02

接下来我们看下run()方法

21-CoarseGrainedExecutorBackend-run()01

由于代码篇幅我们忽略了部分代码,直接来第231行代码,熟悉的rpcEnv.setupEndpoint()方法,将端点CoarseGrainedExecutorBackend注册到RpcEnv环境,且后台线程会调用其onStart()方法

env.rpcEnv.setupEndpoint("Executor", new CoarseGrainedExecutorBackend(
        env.rpcEnv, driverUrl, executorId, hostname, cores, userClassPath, env))

21-CoarseGrainedExecutorBackend-run()02

下面我们看下onStart()方法,看到第63行代码,向driver发送RegisterExecutor消息,这不正好符合了提交流程中的:Executor启动成功后反向去Driver注册

ref.ask[Boolean](RegisterExecutor(executorId, self, hostname, cores, extractLogUrls))

21-CoarseGrainedExecutorBackend-onStart()

下面我们需要回到CoarseGrainedSchedulerBackend类,查看DriverEndpoint.receiveAndReply()方法,找到RegisterExecutor消息的处理逻辑可以看到有三个分支,由于代码篇幅我们就简单介绍下

if (executorDataMap.contains(executorId)) {
  // 分支一:Executor已注册
  executorRef.send(RegisterExecutorFailed("Duplicate executor ID: " + executorId))
  context.reply(true)
} else if (scheduler.nodeBlacklist != null &&
  scheduler.nodeBlacklist.contains(hostname)) {
  // 分支二:Executor存在黑名单
  executorRef.send(RegisterExecutorFailed(s"Executor is blacklisted: $executorId"))
  context.reply(true)
} else {
  // 分支三:Executor注册
  // Driver记录Executor信息
  .....
  executorRef.send(RegisteredExecutor)
  context.reply(true)
}

15-CoarseGrainedSchedulerBackend-receiveAndReply()-RegisterExecutor01

15-CoarseGrainedSchedulerBackend-receiveAndReply()-RegisterExecutor02

从上面源码我们知道Driver在Executor注册成功后,给Executor发送了RegisteredExecutor注册成功的消息,下面我们回到CoarseGrainedExecutorBackend.receive()方法,查看RegisteredExecutor消息的处理逻辑,这里可以看到只有一行简单的代码

executor = new Executor(executorId, hostname, env, userClassPath, isLocal = false)

21-CoarseGrainedExecutorBackend-receive()-RegisteredExecutor

这里的executor其实是CoarseGrainedExecutorBackend的一个属性,这是一个重要的属性

21-CoarseGrainedExecutorBackend

下面我们来看下Executor,首要关注第一行注释:“Spark executor, backed by a threadpool to run tasks”,通过线程池执行tasks,这是不是很重要的一个提示,Executor中有一个最重要的属性就是threadpool

22-Executor01

我们在Executor源码第89行代码找到了threadPool属性,既真正执行task的是跑在Executor中的线程池threadPool里,而CoarseGrainedExecutorBackend又持有Executor属性

22-Executor02

既然有线程池,肯定有执行方法,我们来看下Executor第174行代码,launchTask(),可以看到执行task就是把task包装一下,然后放进threadPool执行

22-Executor-launchTask()

下面我们用图将我们已知的Master资源分配以及Executor启动流程画一下

11-SparkContext-属性-05

总结

  • CoarseGrainedExecutorBackend就是我们所说的Executor角色,且其在启动时会反向去Driver进行注册
  • CoarseGrainedExecutorBackend中有个executor属性,executor中的线程池才是真正干活的线程

至此我们关于SparkSubmit提交的全部流程已经跟踪完毕了