目录
FIFOSchedulingAlgorithm.comparator
FairSchedulingAlgorithm.comparator
Hadoop提出了任务的延迟调度算法,详情可见https://blog.csdn.net/asd491310/article/details/90445156。整篇论文都讨论公平性和数据本地化对调度、任务执行效率的影响,从中权衡出一个最理想的算法。Spark的任务调度参考延迟调度算法并对其做出了实现。Spark在任务排序中考虑公平性、调度器上考虑数据本地化。本文基于Spark2.11。
调度模式
FIFO、FAIR
object SchedulingMode extends Enumeration {
type SchedulingMode = Value
val FAIR, FIFO, NONE = Value
}
调度模式只有FIFO、FAIR,但排序算法有FIFO、FAIR、FIFO+FAIR
调度时机
1. 注册新的Executor,RegisterExecutor
2. CoarseGrainedSchedulerBackend的ReviveThread线程默认每秒调度一次,发出ReviveOffers消息,从这点可以看出Spark最快只支持秒级任务。
//DriverEndpoint类的onStart方法中初始化
override def onStart() {
// Periodically revive offers to allow delay scheduling to work
val reviveIntervalMs = conf.getTimeAsMs("spark.scheduler.revive.interval", "1s")
reviveThread.scheduleAtFixedRate(new Runnable {
override def run(): Unit = Utils.tryLogNonFatalError {
//定期发送ReviveOffers事件消息(延迟调度中叫hearbeat),Spark的消息框架见
//https://blog.csdn.net/asd491310/article/details/89210932
Option(self).foreach(_.send(ReviveOffers))
}
}, 0, reviveIntervalMs, TimeUnit.MILLISECONDS)
}
3. Executor发送StatusUpdate消息给Driver,且TaskState为结束
可调度任务
可调度任务模块由三大块组成
Schedulable:构建可调度的任务Pool、TaskSetManager
SchedulableBuilder:构建可调度的任务池,FIFOSchedulableBuilder只会构建一个Pool,FairSchedulableBuilder构建可依据fairschedulableBuilder.xml构建多个Pool,用于Job之间的调度。
SchedulableAlogrithm: FIFO、Fair两种排序算法的实现
任务级别
TaskLocality定义了五个级别,PROCESS_LOCAL,NODE_LOCAL,NO_PREF,RACK_LOCAL,ANY
PROCESS_LOCAL:数据与任务在同一个Executor中,同一个JVM进程中。
NODE_LOCAL:数据与任务在同一个Node上,但不在同一个Executor中,需要跨进程传输,或者读取本地磁盘
NO_PREF:不考虑数据与任务的位置,一般Shuffle操作时会这个级别
RACK_LOCAL:数据与任务在同一个机架不同的节点上。数据读取时需要跨路由器进行网络传输,比NODE_LOCAL慢
ANY:跨机架,速度最慢。
Schedulable
可调度实体的接口,实现者有Pools和TaskSetManagers。每个Schedulable管理一个Stage的Task集合。
属性
//若实例为Pool,队列中都是TaskSetManager
def schedulableQueue: ConcurrentLinkedQueue[Schedulable]
//FIFO、Fair两种调度模式
def schedulingMode: SchedulingMode
//权重,只有在Fair调度模式下才有效,用于打破公平共享模式
def weight: Int
//最小配额,默认是0,调度器优先保证最小配额分配,其次为权重
def minShare: Int
//Stage当前正在运行的Task
def runningTasks: Int
//优先级,为Jobid
def priority: Int
def stageId: Int
//名称
def name: String
方法
def addSchedulable(schedulable: Schedulable): Unit
添加的Schedulable保存在线程安全的链表队列schedulQueue中。这里需要注意,只有Pool实现了addScheduable方法。TaskScheduler调度器持有一个rootpool属性,用来管理所有的可调度的Schedulable,此属性的实例类型只能是Pool。
def getSortedTaskSetQueue: ArrayBuffer[TaskSetManager]
依据调度算法getSortedTaskSetQueue对TaskSetManager做排序,任务每次调度的时候都会调用此方法,返回一个有序的TaskSetManager
SchedulableBuilder
SchedulableBuilder类对Schedulable构建,有两个实现类FIFOSchedulableBuilder和FairSchedulableBuilder。定义了两个抽象方法,buildPools构建Pool的树结构,addTaskSetManager构建节点的叶子节点。
Spark使用FIFO算法对Task调度时,Schedulable的构建器使用FIFOSchedulableBuilder,只有一个pool,即为rootpool。FIFOSchedulableBuilder对buildpool做了空实现。所以使用FIFO算法可调度的内存结构如下:
Spark使用Fair算法对Task调度时,Schedulable的构建器使用FairSchedulableBuilder,。FariScheulableBuilder对buildpoo做了实现,并支持配置化,默认配置文件为fairscheduler.xml,也支持多个配置文件。
文件格式如下:
<!--调度算法使用Fair,可分成多个pool,每个pool都可指定相应的调度算法-->
<allocations>
<pool name="production">
<schedulingMode>FAIR</schedulingMode>
<weight>1</weight>
<minShare>2</minShare>
</pool>
<pool name="test">
<schedulingMode>FIFO</schedulingMode>
<weight>2</weight>
<minShare>3</minShare>
</pool>
</allocations>
Fair算法支持多个Pool,每个Pool都可以选择相应的调度算法,因此当为Fair算法,schedulable的内存结构为:
我们关联下延迟调度中的Hadoop的pool结构,两者的实现结构基本一致。叶子结点上有些区别,Hadoop是job,Spark是一个Stage对应的TaskSet
排序算法
Pools支持FIFO和FS两种排序算法,FIFO算法只能用于TaskSetManager之间。FS算法可用于Pools之间或者单个Pool中。任务调度前会对rootpool中所有schedulable排序,对应关系schedulable-->taskset--->stage,均为1:1的关系,本质上是对stage的排序。FIFO、Fair两种调度算法各自的排序实现类是FIFOSchedulingAlgorithm、FairSchedulingAlgorithm,两个类均实现了comparator方法
FIFOSchedulingAlgorithm.comparator
优先保证job的FIIFO,然后保证stage的FIFO
override def comparator(s1: Schedulable, s2: Schedulable): Boolean = {
//为jobid
val priority1 = s1.priority
val priority2 = s2.priority
//优先确保jobid小的先执行
var res = math.signum(priority1 - priority2)
if (res == 0) {
val stageId1 = s1.stageId
val stageId2 = s2.stageId
//stageId1和stageId2不可能相等,Stage与Schedulable为1:1的关系
//确保stageId小的先执行
res = math.signum(stageId1 - stageId2)
}
res < 0
}
先比较taskset的priority,taskset的priority值为 jobid,jobid由Driver侧的DAGScheduler中的nextJobId属性原子增长维护,如果相等再比较两者的stageid。
FairSchedulingAlgorithm.comparator
公平分配算中有三个因子影响着Schedulable排序结果。我们回想下FairSchedulableBuilder中构建Schedulable时的树形结构,根节点是rootpool,子节点是Pool,叶子节点是TaskSetMananger,这里需要注意的是Pools之间或者TaskSetManager之间排序逻辑一样。
minShare:最低配额,优先保证先进队列的Schedulable的最低配额
runningTasks:正在运行Task,与最低配额的比率低的优先分配到计算资源
weight:权重,打破公平算法的因子,权重值越大,会优先分配更多的计算资源
三个优先级,,如果两个Schedulable比较结果相等,会再次比较Pool的名称的字典排序
override def comparator(s1: Schedulable, s2: Schedulable): Boolean = {
val minShare1 = s1.minShare
val minShare2 = s2.minShare
val runningTasks1 = s1.runningTasks
val runningTasks2 = s2.runningTasks
val s1Needy = runningTasks1 < minShare1
val s2Needy = runningTasks2 < minShare2
//最低配额与正在运行的Task的比例
val minShareRatio1 = runningTasks1.toDouble / math.max(minShare1, 1.0)
val minShareRatio2 = runningTasks2.toDouble / math.max(minShare2, 1.0)
//权重与正在运行的Task的比例
val taskToWeightRatio1 = runningTasks1.toDouble / s1.weight.toDouble
val taskToWeightRatio2 = runningTasks2.toDouble / s2.weight.toDouble
var compare = 0
if (s1Needy && !s2Needy) {
return true
} else if (!s1Needy && s2Needy) {
return false
} else if (s1Needy && s2Needy) {
compare = minShareRatio1.compareTo(minShareRatio2)
} else {
compare = taskToWeightRatio1.compareTo(taskToWeightRatio2)
}
if (compare < 0) {
true
} else if (compare > 0) {
false
} else {
//比较名称的字典排序
s1.name < s2.name
}
}
任务调度器
Spark任务调度器不仅仅考虑任务的数据本地化,还考虑了黑名单(后面有单节会介绍)、Speculated Task等。这里只分享任务调度的数据本地化。每个调度器作用域只能在一个SparkContext中,不能跨SparkConetxt调度任务,调度器可以接一个Stage的任务集。TaskSchedulerImpl类的resouceOffers方法实现了对任务的调度。
PreferredLocation
回顾下Spark的Partition知识,RDD依据Partition对数据进行逻辑分区,Partition还可以提升Spark程序的并行度,并且在Stage中的每个Partition生成一个相应的Task,因此Partition桥接了Data、Executor、Task。任务调度器本质是找到最优的Executor执行任务,离Task数据最近的Executor,我们可以通过Partition可以计算出数据所在的位置。另外,从源码发现Task的PreferredLocation源自Partition的PreferredLocation,而Partition的PreferredLocation源自于RDD的接口getPreferredLocations,不同的RDD实现方式不同。注意:TaskSet的PreferredLocation的值在Driver调度的时候才明确。
为了方便区分,我们记RDD1为M侧且M={M1,M2,M3,M4},RDD2为R且R={R1,R2,R3,R4}(注意:实际计算过程中当为map侧时,partition则为mapid,当为reduce侧时,partition为reduceid)。RDD1和RDD2之间产生了Shuffle,由ShuffleStatus关联两个Stage之间的关系,每个mapid都有一个唯 一的BlockManagerId与之对应,BlockManagerId存储数据所在的Host。
DAG提交任务时,依据RDD的PreferredLocation计算最优位置。
DAGScheduler.submitMissingTasks
val taskIdToLocations: Map[Int, Seq[TaskLocation]] = try {
stage match {
case s: ShuffleMapStage =>
//通过Partition计算location
partitionsToCompute.map { id => (id, getPreferredLocs(stage.rdd, id))}.toMap
case s: ResultStage =>
partitionsToCompute.map { id =>
val p = s.partitions(id)
(id, getPreferredLocs(stage.rdd, p))
}.toMap
}
以KafkaRDD为例,寻找kafka topic的partition所在的executor,接收 InputData的executor就为PreferredLocation。
KafkaRDDPartition定义
class KafkaRDDPartition(
// RDD的Partition
val index: Int,
val topic: String,
// Kafka topic的partition
val partition: Int,
val fromOffset: Long,
val untilOffset: Long
) extends Partition {}
KafkaPartition中记录了RDD的Partition与Topic的Partition的对应关系。
getPreferredLocations定义
override def getPreferredLocations(thePart: Partition): Seq[String] = {
val part = thePart.asInstanceOf[KafkaRDDPartition]
//所有可用的executor
val allExecs = executors()
val tp = part.topicPartition
//KafkaRDDPartition对应的topicPartition,再寻找相应的host(topic数据由executor消费)
val prefHost = preferredHosts.get(tp)
//再与所有可用executor的host匹配
val prefExecs = if (null == prefHost) allExecs else allExecs.filter(_.host == prefHost)
val execs = if (prefExecs.isEmpty) allExecs else prefExecs
if (execs.isEmpty) {
Seq.empty
} else {
val index = Math.floorMod(tp.hashCode, execs.length)
val chosen = execs(index)
Seq(chosen.toString)
}
}
Pending Task
TaskSetManage根据Locations定义四个pending集合存储Task,以供调度器调度。根据Task的PreferredLocations加入到相应pending collections。同一个Task会被加入到多个pending collections中,这样做方便按Locations调度。Task调度成功后以延迟的方式从pending collections中删除。
//key为executor id,value为task id,
//存储可调度为PROCESS_LOCAL类型的Task
private val pendingTasksForExecutor = new HashMap[String, ArrayBuffer[Int]]
//存储可调度为NODE_LOCAL类型的Task
private val pendingTasksForHost = new HashMap[String, ArrayBuffer[Int]]
//存储可调度为RACK_LOCAL类型的Task
private val pendingTasksForRack = new HashMap[String, ArrayBuffer[Int]]
private[scheduler] var pendingTasksWithNoPrefs = new ArrayBuffer[Int]
TaskSetManager的addPendingTask方法添加Task到相应的Pending集合中
TaskSetManager的dequeueTask方法操作Task从相应的Pending集合中弹出
Task调度器
调度逻辑可分三步分析:
1. 确认可用的CPU资源,如新增Executor、过渡黑名单、过滤非Active等
2. 通过rootpool获取有序Task集,支持FIFO、FAIR两类排序算法
3. 遍历有序Task列表和可用计算资源,结合延迟调度算法与locations匹配最优的executor
可用资源
这里说的可用资源都是指可用CPU核数,不考虑内存、网络、磁盘等物理资源。处理一个Task需要CPU的数量由spark.task.cpus配置项控制,默认是为1。
val CPUS_PER_TASK = conf.getInt("spark.task.cpus", 1)
在Cluster模式下,Spark集群中每个Executor的CPU使用情况由类CoarseGrainedSchedulerBackend管理,executorDataMap存储所有Executor。
executorDataMap定义:
//Key为ExecutorID,只能由Drvier侧修改,必须同步访问
private val executorDataMap = new HashMap[String, ExecutorData]
每个ExecutorData实例存储一个Executor可用CPU数量和CPU总数。
调度源码
//入参为空闲的计算资源,每个空闲的Slot代表一个WorkerOffer
//出参为这次调度被选中的任务与Slot,TaskDescription会被发送到相应的executor
def resourceOffers(offers: IndexedSeq[WorkerOffer]): Seq[Seq[TaskDescription]]
def resourceOffers(offers: IndexedSeq[WorkerOffer]): Seq[Seq[TaskDescription]] = synchronized {
var newExecAvail = false
//每次调度都检查下否有新的计算资源加入
//因为调度事件来源于注册Executor、任务结束、定时调度三方面
//TaskScheduler会维护一份host与Executor映射表
for (o <- offers) {
//判断可用计算资源Host是否已存在
if (!hostToExecutors.contains(o.host)) {
hostToExecutors(o.host) = new HashSet[String]()
}
//判断executor是否正在运行Task
if (!executorIdToRunningTaskIds.contains(o.executorId)) {
hostToExecutors(o.host) += o.executorId
executorAdded(o.executorId, o.host)
executorIdToHost(o.executorId) = o.host
executorIdToRunningTaskIds(o.executorId) = HashSet[Long]()
newExecAvail = true
}
//维护Host于Rack的映射表
for (rack <- getRackForHost(o.host)) {
hostsByRack.getOrElseUpdate(rack, new HashSet[String]()) += o.host
}
}
// 删除黑名单中已经超时,可恢复使用的executor、host
blacklistTrackerOpt.foreach(_.applyBlacklistTimeout())
//过滤掉黑名单中的host、executor
val filteredOffers = blacklistTrackerOpt.map { blacklistTracker =>
offers.filter { offer =>
!blacklistTracker.isNodeBlacklisted(offer.host) &&
!blacklistTracker.isExecutorBlacklisted(offer.executorId)
}
}.getOrElse(offers)
//打乱WorkOffer顺序,避免Task总是分配到相同的Executor
val shuffledOffers = shuffleOffers(filteredOffers)
//构建一个任务列表,分配给Executor。
//注意,这里实际上是指可分配的Task数量(根据可用worker计算本次调度可处理的Task数量)
val tasks = shuffledOffers.map(o => new ArrayBuffer[TaskDescription](o.cores / CPUS_PER_TASK))
val availableCpus = shuffledOffers.map(o => o.cores).toArray
//获取有序的TaskSetMananger,在前面的内容中已经分析过
val sortedTaskSets = rootPool.getSortedTaskSetQueue
for (taskSet <- sortedTaskSets) {
taskSet.parent.name, taskSet.name, taskSet.runningTasks))
if (newExecAvail) {
//有新的executor注册,需要重新计算taskSet的TaskLocality,会影响到Task的PreferredLocations
taskSet.executorAdded()
}
}
// NOTE: the preferredLocality order:
// PROCESS_LOCAL, NODE_LOCAL, NO_PREF, RACK_LOCAL, ANY
// 遍历有序任务列表根据locations调度最优的executor中
for (taskSet <- sortedTaskSets) {
var launchedAnyTask = false
var launchedTaskAtCurrentMaxLocality = false
// locations的顺序{PROCESS_LOCAL,NODE_LOCA,NO_PREF,RACK_LOCAL,ANY}
for (currentMaxLocality <- taskSet.myLocalityLevels) {
do {
// 根据locations循环匹配Executor与TaskSet,并启动Task
launchedTaskAtCurrentMaxLocality = resourceOfferSingleTaskSet(
taskSet, currentMaxLocality, shuffledOffers, availableCpus, tasks)
launchedAnyTask |= launchedTaskAtCurrentMaxLocality
} while (launchedTaskAtCurrentMaxLocality)
}
if (!launchedAnyTask) {
taskSet.abortIfCompletelyBlacklisted(hostToExecutors)
}
}
if (tasks.size > 0) {
hasLaunchedTask = true
}
return tasks
}
延迟调度
TaskSetManager中对延迟调度做了实现,调度逻辑为:
基于当前时间和延迟调度算法计算TaskSet可启动的任务级别,任务级别顺序为{PROCESS_LOCAL,NODE_LOCA,NO_PREF,RACK_LOCAL,ANY}索引为{0,1,2,3,4}。通过任务级别寻找可调度的任务,如果寻找到可调度任务就返回此任务级别。同时存在两种跳到下个任务级别的情况:
1. 一个任务级别调度时间超过3秒,自动跳到下任务级别
2. 没有寻找到可调度的任务,自动跳到下个任务别
//基于当前时间和延迟调度算法计算TaskSet可启动的任务级别
private def getAllowedLocalityLevel(curTime: Long): TaskLocality.TaskLocality = {
//延迟删除已被调度过或者结束的Task
...
//计算可启动的任务级别,这里需要注意currentLocalityIndex是一个全局变量
while (currentLocalityIndex < myLocalityLevels.length - 1) {
val moreTasks = myLocalityLevels(currentLocalityIndex) match {
case TaskLocality.PROCESS_LOCAL => moreTasksToRunIn(pendingTasksForExecutor)
case TaskLocality.NODE_LOCAL => moreTasksToRunIn(pendingTasksForHost)
case TaskLocality.NO_PREF => pendingTasksWithNoPrefs.nonEmpty
case TaskLocality.RACK_LOCAL => moreTasksToRunIn(pendingTasksForRack)
}
//currentLocalityIndex级别不存在可调度的任务,跳档到下个任务级别
if (!moreTasks) {
lastLaunchTime = curTime
currentLocalityIndex += 1
} else if (curTime - lastLaunchTime >= localityWaits(currentLocalityIndex)) {
lastLaunchTime += localityWaits(currentLocalityIndex)
调度超过3(默认)秒,跳档到下个任务级别
currentLocalityIndex += 1
} else {
return myLocalityLevels(currentLocalityIndex)
}
}
myLocalityLevels(currentLocalityIndex)
}
注意:currentLocalityIndex是一个全局变量,表明延迟调度策略不考虑可用Executor的变化。但会在任务出队时弥补Executor的变化对任务级别的影响。但对NODE_LOCAL还是存在影响,不能做到最优匹配。但这样做是有意义的,如果每次任务级别都考上变化的Executor,那跳档就没有意义了。跳档的意义在于调度效率和任务级别最优之间做权衡。
//任务出队
private def dequeueTask(execId: String, host: String, maxLocality: TaskLocality.Value)
: Option[(Int, TaskLocality.Value, Boolean)] =
{
//PROCESS_LOCAL不考虑maxLocality值
for (index <- dequeueTaskFromList(execId, host, getPendingTasksForExecutor(execId))) {
return Some((index, TaskLocality.PROCESS_LOCAL, false))
}
if (TaskLocality.isAllowed(maxLocality, TaskLocality.NODE_LOCAL)) {
for (index <- dequeueTaskFromList(execId, host, getPendingTasksForHost(host))) {
return Some((index, TaskLocality.NODE_LOCAL, false))
}
}
...
//其它任务级别处理类似上面if
}
举例解释上面的问题:
假设:T={T1,T2,T3,T4,T5},L={PROCESS_LOCAL,NODE_LOCAL,NO_PREF,RACK_LOCAL},e={e1,e2,e3}。
当,
time1:
e1-->T1,e2-->T2,e3-->T3
time2:
e1被释放,由于e1只满足T4,T5的RACK_LOCAL,所以currentLocalityIndex会被跳档到RACK_LOCAL。因此T4按RACK_LOCAL分配
time3:
e2,e3被释放,e2满足T5的NODE_LOCAL,但currentLocalityIndex已经是RACK_LOCAL,因此T5可能也按RACK_LOACL调度