Scheduler 需要考慮的議題相當複雜,如何 "公平" 的排程呢?顯然不是每個任務都享有相同的 CPU 時間那麼單純而已。如何把抽象的 "priority" 轉換成公平的時間分配?Interactive 的程式與 batch 的程式該如何排程才可以滿足使用的期待?諸多的問題讓 scheduler 的設計可說是沒有正確答案的。
interactive 指接受某些形式輸入並產生回應的任務類型。例如一個文字編輯器,會一直等待使用者輸入內容,且當輸入後儘量快速的回應(例如顯示在畫面上)
在 linux 上,scheduler 需要思考更多問題。即使 linux 最初是以桌面系統發展的,但如今它已經被廣泛運用在 server、嵌入式系統、mainframes、及超級電腦上。顯然,這些系統的 schedule domain 各自不同,硬體結構的差異、對任務優先的差異等,更是讓 linux 排程器的設計充滿困難。
O(1) scheduler 雖然比起其前面的版本具有更高的彈性,也考量了對於 I/O bound 及 CPU bound 的 "公平" 問題,但是因為其仰賴 heuristics 的計算來調整任務,導致了程式的架構非常難以維護,而 heuristics 也缺乏算術實質,沒有具體的邏輯建立排程的演算法。正因如此,CFS 在其後被提出以改善 O(1) scheduler 所存在的問題。
CFS 的核心想法是保持 task 對於使用 CPU time 的 "平衡",當有 task 被給予的可用的時間落後於其他時,scheduler 會對此做出補償,給予其更多的執行時間。
那麼 CFS 該怎麼定義 "平衡" 呢?先簡而言之: CFS 會維護每一個 task 的 virtual runtime。 Task 的 virtual runtime 愈小,表示其被允許使用的 CPU 時間愈短,也間接代表此 task 對 CPU 的需要更高,因此更需要被排程。CFS 同時也思考到了進入 sleep 狀態的任務(例如等待 I/O)之公平性,確保當他們醒來時可以得到足夠的 CPU 額度。
補充:
Virtual runtime 指 process 截至目前為止已經運行的虛擬時間,所以一個任務的 vruntime 越小,代表它比起其他任務還需要更多的 CPU 額度。
這麼說明可能有些不清楚,下面就讓我們更仔細的來認識 CFS 中的設計考量和核心架構吧!
在探討 CFS 本身之前,可以先認識一下在現今的 Linux 版本(5.16)中設計 scheduler 的框架。由於核心提供許多不同種類的 scheduler ,例如 RT scheduler、Deadline scheduler、CFS scheduler 及 Idle scheduler 等。近似於物件導向的 parent class(沒錯! Linux 中有許多透過 C 語言設計的物件導向思維),這些不同的 scheduler 透過 struct sched_class
結構作為一致的抽象(abstraction)定義,這樣的模組化使得設計 scheduler 時可以不需要修改大量的程式碼,只需要定義 scheduler 對應的 operation 即可。
scheduler 的定義透過 DEFINE_SCHED_CLASS
這個 macro 完成,後者將 scheduler 根據 linker script 擺放到編譯出的 linux image 的對應位置。設計上,每一個執行單元在建立之後,都要選擇一種調度策略,而每一種調度策略會對應一個 sched_class。因此,除了執行單元在 scheduler 中有優先級,不同的 scheduler 之間也存在先後之分。
有趣的是,這些 scheduler 的先後順序是透過很特別的方法決定的,前面我們有提到 DEFINE_SCHED_CLASS
會將 scheduler 按照 linker script 擺在記憶體中。實際上,這些 scheduler 會緊密的排列在相鄰的位置,而參見 /include/asm-generic/vmlinux.lds.h:
註解告訴我們 sched_class
在記憶體中的先後順序其實就對應優先程度!這使得核心實際上不需要透過額外的資料結構來紀錄優先序。在 _pick_next_task
的 for_each_class
也驗證了此種設計。
之所以用 section 來決定優先順序是為考慮對 cache 使用上的友善,詳見: (感謝 @Uduru0522 補充)
也因為所有的 scheduler 都需要透過 DEFINE_SCHED_CLASS
做宣告,我們可以在 DEFINE_SCHED_CLASS
的搜尋頁面 找到不同種類的 scheduler 所在。因此,我們將從此 macro 出發,並深入解析實作一個 scheduler 所需定義的介面。
所謂公平並不是使所有任務都具有相同的執行時間,而是應該根據每個任務的優先級給予合適的時間比例。為此,核心引入權重的概念,權重代表著進程的優先級。各個進程之間按照權重的比例分配 cpu 時間。
而權重的數值就透過 nice 值的概念進行對應,CFS 可以透過陣列表對應 nice value 與一個 weight 數值。nice 值的範圍在 [-20, 19] 之間,值越小,代表優先級越大,權重值越大。如下所示為在 linux kernel 4.4 以前在 kernel/sched/sched.h header 中的定義。
nice value 換算到 weight 的公式近似 。因此 nice value 每增加 1,weight 就乘上 1.25 倍。這使得 nice value 每增加 1 可以減少約 10% 的 CPU time 的比重。
我們可以簡單的計算出 10% 的由來,假設 process A 的 nice 為 n 而 process B 的 nice 為 n + 1。因此 而 ,則 A 佔比重 ,B 佔比重 ,兩者比重相減的值(計算略)得到的數值就約為 10%
不過這其實存在一個容易被忽略的缺陷: 因為該陣列是 static 宣告在 header 中的,這導致所有 include 都會建立一份獨立的 header 到記憶體中。由於該陣列理應只需在記憶體中存在一份且不必被修改,該結構被修改成 const int sched_prio_to_weight
。對照 sched/core: Move the sched_to_prio[] arrays out of line 的 patch 郵件,從這裡我們也可以發現一個也許可以著手的修改思考!
schedule lantency 所指是每個任務在被排程之後,下一次可以再被排程的時間,一般狀況下固定為 sysctl_sched_latency
(6ms)。但假設想固定這個延遲時間,可以想像如果 queue 中任務一多,每個任務能夠獲得的 time slice 將被瓜分而導致過於頻繁的 context switch。因此,CFS 中會根據 queue 中的任務數量動態調整 latency 的。當任務數量超過 sched_nr_latency
,會以 nr_running
為權重提高 latency,確保每個任務至少有足夠 sysctl_sched_min_granularity
的運行時間可以使用。
可以在 __sched_period
看到這一系列行為的呈現。
前面我們有提到 CFS 會根據不同任務的 nice value 分配不同比例的時間,但這就表示任務實際執行的時間長短不能做為判斷是否 "公平" 分配的依據。為了方便管理,CFS 引入了 virtual runtime(vruntime) 的觀念來定義任務之間的 "公平"。每一個任務的 virtual runtime 會根據 nice value 所對應的權重以及實際執行的時間做計算,如此一來,task 的 virtual runtime 越小,就可以直接表示其對 CPU 的需要更高。換句話說,CFS 只需要保證每個任務在系統中運行的 vruntime 是平衡的即可,在選擇下一個需要被進行的任務的時候,CFS 就只需選擇 vruntime 數值中最小的任務來達到公平。
那麼,將實際時間(wall time)轉換成 vruntime 的公式為何呢? 我們可以關注 calc_delta_fair
,先閱讀其中的註解部份:
根據註解,計算式為:
為了方便說明,我們先考慮 weight == NICE_0_LOAD
的一般情形下(CONFIG_64BIT
not defined),可以把計算式理解成:
不過在核心的計算中可能需要遇到沒有 FPU 的情況,為此我們需要針對會用到的除法運算進行調整。因為 顯然是 1 以下的小數(對照 sched_prio_to_weight
都是正整數),我們可以把式子調整成:
也就是說,我們可以先把 放大 倍和 wall_time_delta
進行計算,也就是 (lw->inv_weigth
),然後最後再把放大的部份補償回來即可。 這個數字在 sched_prio_to_wmult
中被記錄起來,例如我們可以在 reweight_task
中看到其被用來指派給一個 sched_entity
中的 load_weight
中儲存起來。
藉此 calc_delta_fair
可以拿到 lw->inv_weigth
並使用,可以看到核心的計算都被放在 __calc_delta
,而 calc_delta_fair
做了一點小優化因為 nice 值為 0 的任務 vruntime = wall time。
下面來看 vruntime 計算的核心函數 __calc_delta
是怎麼實現的:
首先,我們想計算 。然而,如果 超過 32 bits(在 CONFIG_64BIT
defined 時可能發生),而 (也就是 ) 是 32 bits 表示的,直接相乘可能會發生超過 64 bits 的 overflow 的問題。為此,我們可以先把最後要做的 ( ) 中的一部分 shift 先 apply 到 weight 上,也就是把 改寫成 。然後上面就是先計算 的部份
最後要再乘上 ,類似於前面,直接乘上 可能有 overflow,因此可以先 apply 一部分 shift 到前面運算得到的結果。
CONFIG_64BIT
的目的以及實作上的額外考量點sched_entity
, task_struct
, task_group
和 cfs_rq
是 CFS 最為核心的資料結構,釐清這些資料結構之間的架構可說是弄懂 CFS 的關鍵。我們可以先透過下面的圖簡單的了解四個資料結構彼此的大致關係(紅色的箭頭表示指標變數的 reference,灰色虛線則是對應變數與其 instance 內部的詳細內容)。接著,就讓我們更詳盡的用這張圖進行說明吧!
sched_entity
我們首先關注排程器管理資源分配的單元: sched_entity
。前面我們先探討了 CFS 如何實現其公平的目標,提及了每個任務會根據優先度對應的權重調配可以分到的 time slice。若從程式碼的實作而言,CFS 實際上調配權重以排程的單位就是 sched_entity
實體。
也因此,首先我們可以看到 struct sched_entity
中包含 struct cfs_rq
的指標 cfs_rq
,指向其所在的 runqueue 中。而另一個 struct cfs_rq
指標 my_q
則是當該 se 是屬於 group entity 是,會 reference 到自己底下的 run queue。
結構中都有自己的 struct load_weight
結構的成員,代表該排程單元在排程中的被分配權重。最後,sched_entity
中也包含用來選擇任務需要的 vruntime
。
如果 sched_entity
是隸屬於非 root_task_group
的某個 task_group
之下的話,成員的 parent
會指向該 task_group
的 sched_entity
。對應上方的圖可以看到,這是因為系統中的 runqueue 其實是階層式結構的(對於某個 sched_entity
所在的 runqueue
所屬的 task_group
,該 task_group
可能是在另一個 task_group
的 runqueue 之下),而某些操作會需要對每一層級的sched_entity
都進行更新,因此記錄 parent
資訊可以有效的進行此操作。
如果要仔細探討的話,CFS 的 runqueue 的資料結構其實是藉由一個紅黑樹(RB-tree) 來維護的。因此我們可以在其 sched_entity
下看到 rb_node
這個結構。後者就可以被插入在 runqueue 的紅黑樹下。
對於 scheduler 如何使用紅黑樹進行排程,我們會在後續的章節透過程式碼仔細探討。先簡單從概念上解釋 CFS 維護任務調度的思維: 那些急需 CPU(virtual runtime 小) 的 task 會被放在紅黑樹的左邊,而不迫切的 task 則在右邊。為保持公平,Scheduler 會去挑選最左邊(leftmost)的任務,任務被切換掉後,把 virtual runtime 加上使用的 CPU 時間重新插入 RB-tree。如此一來,左側的任務就被確保有時間,並且樹的內容會從右向左遷移以保持公平,每個 task 會不斷追逐其他使用 CPU 多於自己的任務以保持平衡。
task_struct
對於 Linux 有稍微了解的人應該知道,Linux 可以透過 task_struct
結構來描述在核心中被建立之任務。關注與 CFS 有關的部分的話,task_struct
中包含的 sched_entity
即可用來向 CFS scheduler 註冊排程,而 sched_task_group
則會指向所屬的 task_group
。
task_group
如果 CFS 的公平僅是以 task_struct
表示的 task 為單位的話,實際上會造成某種程度上的不公平。試想一個情境:在某個 server 上的 50 個任務,有 1 個 A 君所擁有,另外 49 個是由一個 B 君所擁有,如果只以 task 為公平的判斷單位,這就表示 B 君可以佔用 98 % 的 CPU 資源。
為此,CFS 中帶來了 group scheduling 的概念。Group scheduling 是另一種帶來公平性的方法。而由於排程的單位是基於 sched_entity
而非 task_struct
,這就使得 group scheduling 得以被實作。實作上是透過定義 task_group
結構描述由多個任務組成的群組。task_group
中也包含 sched_entity
實體,因此其也可以做為一個 CFS 所管理的對象,被加入到一個 runqueue 之中。並且,task_group
中對於自己群組中的任務也存在自己的 runqueue 中。換句話說,系統中的 runqueue 其實是階層式樹狀結構的,在前面的章節中我們已經介紹過這點。
task_group
中兩個重要的成員: 其一是 se
,其內容是一個 sched_entity
指標的陣列,對於該 task_group
,每個 CPU 都具有一個 sched_entity
可以註冊到 runqueue 中。另一個則是 cfs_rq
,是 task_group
對應每個 CPU 的 runqueue。
因為引入了 group scheduling 的觀念,scheduler 在排程時,會去尋找最需要 CPU 的 sched_entity
,如果 sched_entity
表現的不是一個 task,則再去 sched_entity
下的 runqueue 挑出下一個 entity,重複直到找到是表現一個 task 的 sched_entity
。最後,在把 CPU 時間加回 virtual runtime 時,會把該 sched_entity
包含往上 parent 階層的都一併更新。因此,面對一開始提到的問題,系統的管理者只要針對 A 君和 B 君建立一個 sched_entity
,就可以做到針對兩個使用者的公平。實際上,所有任務都會根源於 root_task_group
這個群組中。
cfs_rq
描述 CFS 之 runqueue 的結構是 cfs_rq
:
可以看到 cfs_rq
中也存在一個 load_weight
結構,該數值其實是 runqueue 中所有任務的權重總和,藉此可以有效的更新對應層級的 task_group
之 vruntime,而 nr_running
則記錄該 runqueue 中的 sched_entity
之數量。
min_vruntime
是 runqueue 中所有 entity 的 vruntime 之最小值,該值是用來在 enqueue sched_entity
的時候協助初始化其 vruntime。思考任務被移出 runqueue 時的議題: 假設有任務 A 需要處理 I/O 而被暫時移出 runqueue,其 vruntime
會維持同樣的值,而仍處在在 runqueue 中的任務則會持續增加。當這個任務 A 被喚醒並且放回 runqueue 時,如果 vruntime
是依離開時的值設定,這會導致這個任務的優先遠超其他原本在 runqueue 中的任務,這違反了 CFS "fair" 的精神。
類似的情形,如果一個新任務被建立,我們不可能直接 assign 0 值給它,否則舊的任務等於有很大一段時間都不能取得 CPU 資源,同樣不 "fair"。
因此,CFS scheduler 會追蹤整個 runqueue 中的最小 min_vruntime
,當每個任務被挑選並運行時,這個最小值也同步更新。如此一來,當新的任務被建立,就可以透過這個值去初始化其 vruntime
。對於進入 wait queue 而後被喚醒的任務,則是確保其原本的 vruntime
需不小於記錄的最小值,否則就重設為該最小值再放回 runqueue 中,藉此避免被喚醒的任務會長時間的霸佔 CPU 資源。
接著,我們可以在 cfs_rq
結構中看到 rb_root_cached
類型的成員 tasks_timeline
,後者包含了 RB-tree 的根節點(root, rnuqueue 直接擁有)與最左側的節點(leftmost, 指到某個 sched_entity
之 rb_node
)之 reference 以更有效率的操作 RB-tree。
cfs_rq
也可以透過其下的 tg
來找到自己所屬的 task_group
。