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調度

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章