Spark Executor模块详解

52 篇文章 1 订阅
46 篇文章 6 订阅

Executor模块负责运行Task计算任务,并将计算结果回传到Driver。Spark支持多种资源调度框架,这些资源框架在为计算任务分配资源后,最后都会使用Executor模块完成最终的计算。
每个Spark的Application都是从Spark-Context开始的,它通过Cluster Manager和Worker上的Executor建立联系,由每个Executor完成Application的部分计算任务。不同的Cluster Master,即资源调度框架的实现模式会有区别,但是任务的划分和调度都是由运行SparkContext端的Driver完成的,资源调度框架在为Application分配资源后,将Task分配到计算的物理单元Executor去处理。
在这里插入图片描述

一、Standalone模式的Executor分配详解

在这里插入图片描述
在Standalone模式下集群启动时,Worker会向Master注册,使得Master可以感知进而管理整个集群;Master通过借助Zookeeper,可以简单实现高可用性;而应用方通过SparkContext这个与集群的交互接口,在创建SparkContext时就完成了Application的注册,由Master为其分配Executor;在应用方创建了RDD并且在这个RDD上进行了很多的转换后,触发Action,通过DAGScheduler将DAG划分为不同的Stage,并将Stage转换为TaskSet交给TaskSchedulerImpl;再由TaskSchedulerImpl通过SparkDeploySchedulerBackend的reviveOffers,最终向ExecutorBackend发送LaunchTask的消息;ExecutorBackend接收到消息后,启动Task,开始在集群中启动计算。
SparkContext是用户应用和Spark集群的交换的主要接口,用户应用一般首先要创建它。如果你使用SparkShell,你不必显式地创建它,系统会自动创建一个名为sc的SparkContext的实例。创建SparkContext的实例,主要工作是设置一些参数,比如Executor使用到的内存的大小。如果系统的配置文件已经设置,那么就直接读取该配置;否则读取环境变量。如果都没有设置,那么取默认值为512M。当然,这个数值还是很保守的,特别是在内存已经不是那么昂贵的今天。除了加载这些集群的参数,它完成了TaskScheduler和DAGScheduler的创建。

1,SchedulerBackend创建AppClient

SparkDeploySchedulerBackend是Standalone模式的SchedulerBackend。在org.apache.spark.scheduler.cluster.SparkDeploySchedulerBackend#start中会创建AppClient,而AppClient可以向Standalone的Master注册Application,然后Master会通过Application的信息为它分配Worker,包括每个Worker上使用CPU core的数目等。
AppClient向Master注册Application时需要发送以下消息:RegisterApplication(appDescription: ApplicationDescription)

ApplicationDescription包含一个Application的所有信息,包括:

private[spark] class ApplicationDescription(
val name: String, //Application的名字,可以通过spark.app.name设置
val maxCores: Option[Int],// 最多可以使用CPU core的数量,可以通过spark.cores.max设置。每个Executor可以占用的内存数,可以通过spark.executor.memory、SPARK_EXECUTOR_MEMORY或者SPARK_MEM设置,默认值是512MB
val memoryPerSlave: Int,
// Worker Node拉起的ExecutorBackend进程的Command。在Worker接到 Master LaunchExecutor后会通过ExecutorRunner启动这个Command。Command包含了启动一个Java进程所需要的信息,包括启动的ClassName、所需参数、环境信息等
val command: Command,
var appUiUrl: String,
// Application 的web ui的hostname:port如果spark.eventLog.enabled(默认为false)指定为true的话,eventLogFile就设置为spark.eventLog.dir定义的目录
val eventLogFile: Option[String] = None)

其中,Command中比较重要的是Class Name和启动参数,在Standalone模式中,Class Name就是org.apache.spark.executor.CoarseGrainedExecutorBackend;

2,AppClient向Master注册Application

在这里插入图片描述
AppClient是Application和Master交互的接口。它包含一个实现为org.apache.spark.deploy.client.AppClient.ClientActor的成员变量actor。它负责了所有与Master的交互。Master与actor主要的消息如下:
(1)RegisteredApplication(appId_,masterUrl)=>//注:来自Master的成功注册Application的消息。
(2)ApplicationRemoved(message)=>//注:来自Master的删除Application的消息。Application执行成功或者失败最终都会被删除。
(3)ExecutorAdded(id:Int,workerId:String,hostPort:String,cores:Int,memory:Int)=>//注:来自Master的消息。
(4)ExecutorUpdated(id,state,message,exitStatus)=>?//注:来自Master的Executor状态更新的消息,如果Executor是完成的状态,那么回调SchedulerBackend的executorRemoved的函数。
(5)MasterChanged(masterUrl,masterWebUiUrl)=>??//注:来自新竞选成功的Master。Master可以选择ZK实现HA,并且使用ZK来持久化集群的元数据信息。因此在Master变成leader后,会恢复持久化的Application、Driver和Worker的信息。
(6)StopAppClient=>//注:来自AppClient::stop()。

Master端在接到注册的请求后,首先会将Application放到自己维护的数据结构中:
(1)apps+=app//HashSet[ApplicationInfo],保存了Master上所有的Application。
(2)idToApp(app.id)=app//HashMap[String,ApplicationInfo],app.id是在Master端分配的,格式是“app-currentdate-nextAppNumber”,其中nextAppNumber是Master启动以来注册的Application的总数-1,取四位数。
(3)actorToApp(app.driver)=app//HashMap[ActorRef,ApplicationInfo],app.driver就是org.apache.spark.deploy.client.AppClient.ClientActor。
(4)addressToApp(appAddress)=app//HashMap[Address,ApplicationInfo],appAddress=app.driver.path.address。
(5)waitingApps+=app//ArrayBuffer[ApplicationInfo],等待被调度的Application。

3,Master根据AppClient的提交选择Worker

org.apache.spark.deploy.master.Master#schedule为处于待分配资源的Application分配资源。在每次有新的Application加入或者新的资源加入时都会调用schedule进行调度。为Application分配资源选择Worker(Executor),现在有两种策略:
(1)尽量打散,即将一个Application尽可能多地分配到不同的节点。可以通过设置spark.deploy.spreadOut来实现。默认值为true。
(2)尽量集中,即一个Application尽量分配到尽可能少的节点。CPU密集型而内存占用比较少的Application适合使用这种策略。
在选择了Worker和确定了Worker上Executor需要的CPU Core数后,Master会调用?launchExecutor(worker:WorkerInfo,exec:ExecutorInfo)向Worker发送请求,向AppClient的actor发送Executor已经添加的消息。同时会更新Master保存的Worker的信息,包括增加Executor、减少可用的CPU Core数和memory数(注意,Worker上的资源信息并不是Worker主动上报到Master的,而是Master主动维护的,因为所有的资源分配都是由Master来完成的)。Master不会等到真正在Worker上成功启动Executor后再更新Worker的信息。如果Worker启动Executor失败,那么它会发送FAILED的消息给Master,Master收到该消息时再次更新Worker的信息即可

4,Worker根据Master的资源分配结果创建Executor

Worker接收到来自Master的LaunchExecutor的消息后,会创建org.apache.spark.deploy.worker.ExecutorRunner。
ExecutorRunner会将在org.apache.spark.scheduler.cluster.SparkDeployScheduler-Backend中准备好的org.apache.spark.deploy.ApplicationDescription以进程的形式启动。
Driver是一个Actor的Reference,实现是org.apache.spark.scheduler.cluster.CoarseGrainedSchedulerBackend::DriverActor的Reference。Driver在接到RegisterExecutor消息后,会将Executor的信息保存在本地,并且调用CoarseGrainedSchedulerBackend.DriverActor#makeOffers实现在Executor上启动Task。具体的实现细节请参阅4.3节。
回到CoarseGrainedExecutorBackend,它在接到Driver的回应RegisteredExecutor后,会创建一个org.apache.spark.executor.Executor。至此,Executor创建完毕。Executor在Mesos、YARN和Standalone模式中都是相同的,不同的只是资源的分配管理方式。

二、Task的执行

org.apache.spark.scheduler.cluster.CoarseGrainedSchedulerBackend.DriverActor#launchTasks会将分配到Executor的Task进行分配:

val executorData = executorDataMap(task.executorId)
executorData.freeCores -= scheduler.CPUS_PER_TASK
executorData.executorActor ! LaunchTask(new SerializableBuffer(serializedTask))

org.apache.spark.executor.CoarseGrainedExecutorBackend收到LaunchTask的消息后,会调用Executor的launchTask启动Task:

case LaunchTask(data) =>
    if (executor == null) {
		logError("Received LaunchTask command but executor was null")
        System.exit(1)
	} else {
		val ser = env.closureSerializer.newInstance()
        val taskDesc = ser.deserialize[TaskDescription](data.value)
        logInfo("Got assigned task " + taskDesc.taskId)
        executor.launchTask(this, taskDesc.taskId, taskDesc.name, taskDesc.            	
			serializedTask)
}

Executor会为这个Task生成一个TaskRunner(继承自Runnable);TaskRunner的run中会执行Task。TaskRunner最终会被放到一个ThreadPool中去执行。

val tr = new TaskRunner(context, taskId, taskName, serializedTask)
runningTasks.put(taskId, tr)
threadPool.execute(tr)

1,依赖环境的创建和分发

在Driver端封装Task时,会将Task依赖的文件封装到Task中:

val out = new ByteArrayOutputStream(4096)
val dataOut = new DataOutputStream(out)
// Write currentFiles
dataOut.writeInt(currentFiles.size)
for ((name, timestamp) <- currentFiles) {
    dataOut.writeUTF(name)
    dataOut.writeLong(timestamp)
}
// Write currentJars
dataOut.writeInt(currentJars.size)
for ((name, timestamp) <- currentJars) {
    dataOut.writeUTF(name)
    dataOut.writeLong(timestamp)
}
// Write the task itself and finish
dataOut.flush()
val taskBytes = serializer.serialize(task).array()
out.write(taskBytes)
ByteBuffer.wrap(out.toByteArray)

在Executor端,恢复Task的依赖和Task本身:

val in = new ByteBufferInputStream(serializedTask)
val dataIn = new DataInputStream(in)
// Read task's files
val taskFiles = new HashMap[String, Long]()
val numFiles = dataIn.readInt()
for (i <- 0 until numFiles) {
    taskFiles(dataIn.readUTF()) = dataIn.readLong()
}
// Read task's JARs
val taskJars = new HashMap[String, Long]()
val numJars = dataIn.readInt()
for (i <- 0 until numJars) {
    taskJars(dataIn.readUTF()) = dataIn.readLong()
}
// Create a sub-buffer for the rest of the data, which is the serialized Task object
val subBuffer = serializedTask.slice()  // ByteBufferInputStream will have
    read just up to task
(taskFiles, taskJars, subBuffer)

从序列化的Task中获取依赖的文件和依赖的位置信息后,org.apache.spark.executor.Executor会调用updateDependencies下载这些依赖:

Utils.fetchFile(name, new File(SparkFiles.getRootDirectory), conf,env.
    securityManager, hadoopConf, timestamp, useCache = !isLocal)

依赖下载完成后,Executor就具备执行Task的能力了。

2,任务执行

在准备好Task的运行时环境后,Task就会执行:

// 开始执行任务,并且记录任务的耗时情况
taskStart = System.currentTimeMillis()
val value = task.run(taskId.toInt) //执行Task,计算结果存入value
val taskFinish = System.currentTimeMillis()
// 如果任务已经被杀掉,那么直接失败返回
if (task.killed) {
    throw new TaskKilledException
}

task.run(taskId.toInt)就是执行最终的计算,它首先为本次计算创建一个Task-Context。
通过setTaskContext可以设置这个Context为特定Thread的上下文:

static void setTaskContext(TaskContext tc) {
    taskContext.set(tc);
}

TaskContext还有一个重要的设置是用户可以设置Task结束时的回调函数,Task会在结束时调用这个回调函数来完成最终的处理。目前推荐使用下面两种方式来完成这个回调函数的注册:

public abstract TaskContext addTaskCompletionListener(TaskCompletionListener listener);
public abstract TaskContext addTaskCompletionListener(final Function1 <TaskContext, Unit> f);

在Task执行结束时,会通过调用org.apache.spark.TaskContextImpl#markTaskCompleted来调用回调函数:

completed = true //标记Task完成为true
val errorMsgs = new ArrayBuffer[String](2) //记录错误信息
// Process complete callbacks in the reverse order of registration
onCompleteCallbacks.reverse.foreach { listener =>
    try {
		listener.onTaskCompletion(this) //执行回调函数
    } catch {
		case e: Throwable =>
			errorMsgs += e.getMessage //发生异常,记录错误信息
            logError("Error in TaskCompletionListener", e)
		}
	}
    if (errorMsgs.nonEmpty) {//如果错误信息不为空,那么抛出异常
        throw new TaskCompletionListenerException(errorMsgs)
}

核心实现如下:

// 为本次的任务生成Task
Contextcontext = new TaskContextImpl(stageId, partitionId, attemptId, runningLocally = false)
//设置上下文信息,实际上会调用
org.apache.spark.TaskContext#setTaskContext
		TaskContextHelper.setTaskContext(context)
context.taskMetrics.hostname = Utils.localHostName() //更新metrics信息
//当前线程,在被打断的时候可以通过它来停止该线程
    taskThread = Thread.currentThread()
    if (_killed) {//如果当前Task被杀死,那么需要退出Task的执行
        kill(interruptThread = false)
	}
    try {
		runTask(context) //执行本次Task
} finally {
    //任务结束,执行任务结束时的回调函数
    context.markTaskCompleted()
	TaskContextHelper.unset()
}

3,

三、参数设置

1,spark.executor.memory

配置了Executor可以最多使用的内存大小,是通过设置Executor的JVM的Heap尺寸来实现的。由于一个集群的内存资源总归是有限的,而且这些内存会被很多的应用共享,因此,设置一个合理的内存值是非常必要的。如果设置的过大,那么可能会导致部分任务由于分配不到资源而等待,延长整个应用的执行时间;如果设置过小,那么就会产生频繁的垃圾回收和读写外部磁盘,同样影响性能。
Executor的内存是被其内部所有的任务共享,而每个Executor上可以支持的任务的数量取决于Executor所持有的CPU Core的数量。因此为了评估一个Executor占用多少内存是合适的,需要了解每个任务的数据规模的大小和计算过程中所需要的临时内存空间的大小。实际上,要比较精确地计算出一个任务所需要的内存空间还是非常困难的,首先,因为数据本身加载到内存中,由于有管理这些内存的额外内存开销,可能需要的真实内存是数据大小的数倍;其次,任务计算过程中所需要的临时内存空间的大小会因为算法的不同而不同,这是比较难评估的。如果需要比较准确的评估数据集的大小的话,可以将RDD cache在内存中,从BlockManager的日志中可以看到每个Cache分区的大小(实际上,这个大小也是一个估计值)。
如果内存比较紧张,就需要合理规划每个分区任务的数据规模,例如采用更多的分区,用增加任务数量(进而需要更多的批次来运算所有的任务)的方式来减小每个任务所需处理的数据大小。

2,日志相关

如果spark.eventLog.enabled设置为true(默认为false),那么需要设置日志写入的目录spark.eventLog.dir,这样,日志就可以保存到本地,方便调试和问题追踪。但是随着时间推移,节点的日志必须需要一个清除机制,否则日志很容易写满磁盘。通过下图设置,可以设置日志清理的策略。
在这里插入图片描述

3,spark.executor.heartbeatInterval

Executor和Driver之间心跳的间隔,单位是毫秒。心跳主要是Executor向Driver汇报运行状态和Executor上报Task的统计信息(metric)。这个数值一般无须专门设置。

文章来源:《Spark技术内幕:深入解析Spark内核架构设计与实现原理》 作者:张安站

文章内容仅供学习交流,如有侵犯,联系删除哦!

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

晓之以理的喵~~

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值