1、前言
由前面的文章Spark源码解析04-Submit提交流程及SparkContext准备流程我们已经知道了SparkContext准备过程中会向Master发送RegisterApplication注册Spark应用
我们继续看下cluster提交流程
- 任务提交后,Master(资源管理器)会找到一个Worker(节点)启动Driver进程,Driver启动后向Master注册应用程序
- Master根据submit脚本的资源配置,启用相应的Worker
- 在Worker上启动所有的Executor,Executor反向到Driver注册
- Driver开始执行main函数,之后执行到Action算子时,开始划分stage,每个stage生成对应的taskSet,之后将task分发到各个Executor上执行
步骤1的源码已经全部跟踪完毕了,接下来我们继续步骤2中的Master分配
2、Master资源分配01
由资源层源码分析我们知道,Master收到消息后,会通过receiver()方法进行匹配处理逻辑,我们直奔主题,来到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()
}
下面我们再看下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()
}
由于Driver相关调度的我们已经分析过了,这次我们直奔主题到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()方法
从截图我们可知得到,在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进行分析的
从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
下面我们默认可以用的Worker的数量是3,且三个Worker的核数和内存数不同
val numUsable = 3
val assignedCores = new Array[Int](numUsable)
val assignedExecutors = new Array[Int](numUsable)
下面我们将属性图准备好,准备用图形将Master分配资源的流程走一遍
从三个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,分配情况如下图
从上图我们可知经过一二步骤就已经完成第一轮分配后,还有个第三步骤,我们仔细解释下,可以看到第三层循环里有两个判断条件,其中的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
}
}
接下来下标为1的Worker(12C 32G)进行分配后coresToAssign = 4,结果如下
由于canLaunchExecutor()中的判断,仍成立
coresToAssign >= minCoresPerExecutor
继续下标为2的Worker(12C 20G)进行分配,结果如下
与此同时分配后的coresToAssign = 0,已经不能满足canLaunchExecutor()中的判断条件,也就是Master资源分配划分到此结束
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
该方法的代码逻辑比价简单,我们就不做过多解释
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))
至此,Master资源分配已经全部跟完,接下来总结一下Master分配的一些小细节
小结:
- Master资源划分的主要方法是startExecutorsOnWorkers()
- Master在进行资源划分时默认是按Worker水平划分
5、Executor启动流程
上文我们知道了Master会向Executor发送LaunchExecutor消息,通知其启动Executor,我们直奔主题来到,Worker的receive()方法,查看LaunchExecutor的处理逻辑,由于我们是在Windows环境,java启动进程的实现在Window和Linux平台的实现是不一样的,所以这边我们不必关注太多关于Executor启动细节,我们关心的是Executor启动后的操作
关于Executor的启动,我们要回忆上一篇文章Spark源码解析04-Submit提交流程及SparkContext准备流程的一个属性org.apache.spark.executor.CoarseGrainedExecutorBackend,这个就是我们真正启动的Executor的全类名
下面我们来看下CoarseGrainedExecutorBackend类,由于这个类有伴生对象,我们直接从main()方法入手,可以看到这个main()方法实际就是在对参数进行封装,然后调用run()方法
接下来我们看下run()方法
由于代码篇幅我们忽略了部分代码,直接来第231行代码,熟悉的rpcEnv.setupEndpoint()方法,将端点CoarseGrainedExecutorBackend注册到RpcEnv环境,且后台线程会调用其onStart()方法
env.rpcEnv.setupEndpoint("Executor", new CoarseGrainedExecutorBackend(
env.rpcEnv, driverUrl, executorId, hostname, cores, userClassPath, env))
下面我们看下onStart()方法,看到第63行代码,向driver发送RegisterExecutor消息,这不正好符合了提交流程中的:Executor启动成功后反向去Driver注册
ref.ask[Boolean](RegisterExecutor(executorId, self, hostname, cores, extractLogUrls))
下面我们需要回到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)
}
从上面源码我们知道Driver在Executor注册成功后,给Executor发送了RegisteredExecutor注册成功的消息,下面我们回到CoarseGrainedExecutorBackend.receive()方法,查看RegisteredExecutor消息的处理逻辑,这里可以看到只有一行简单的代码
executor = new Executor(executorId, hostname, env, userClassPath, isLocal = false)
这里的executor其实是CoarseGrainedExecutorBackend的一个属性,这是一个重要的属性
下面我们来看下Executor,首要关注第一行注释:“Spark executor, backed by a threadpool to run tasks”,通过线程池执行tasks,这是不是很重要的一个提示,Executor中有一个最重要的属性就是threadpool
我们在Executor源码第89行代码找到了threadPool属性,既真正执行task的是跑在Executor中的线程池threadPool里,而CoarseGrainedExecutorBackend又持有Executor属性
既然有线程池,肯定有执行方法,我们来看下Executor第174行代码,launchTask(),可以看到执行task就是把task包装一下,然后放进threadPool执行
下面我们用图将我们已知的Master资源分配以及Executor启动流程画一下
总结:
- CoarseGrainedExecutorBackend就是我们所说的Executor角色,且其在启动时会反向去Driver进行注册
- CoarseGrainedExecutorBackend中有个executor属性,executor中的线程池才是真正干活的线程
至此我们关于SparkSubmit提交的全部流程已经跟踪完毕了