Spark之任务调度

目录

 

调度模式

调度时机

可调度任务

任务级别

Schedulable

属性

方法

SchedulableBuilder

排序算法

FIFOSchedulingAlgorithm.comparator

FairSchedulingAlgorithm.comparator

任务调度器

PreferredLocation

Pending Task

Task调度器

可用资源

调度源码

延迟调度


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. CoarseGrainedSchedulerBackendReviveThread线程默认每秒调度一次,发出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:构建可调度的任务PoolTaskSetManager

SchedulableBuilder:构建可调度的任务池,FIFOSchedulableBuilder只会构建一个PoolFairSchedulableBuilder构建可依据fairschedulableBuilder.xml构建多个Pool,用于Job之间的调度。

SchedulableAlogrithmFIFO、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支持FIFOFS两种排序算法,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:权重,打破公平算法的因子,权重值越大,会优先分配更多的计算资源

三个优先级,minShare> runningTasks--->\frac{runningTasks}{minShare}--->\frac{runningTasks}{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调度

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值