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