--- tags: Linux Kernel Internals, 作業系統 --- # Linux 核心設計: Scheduler(2): 概述 CFS Scheduler ## 前言 ### Problem of scheduler Scheduler 需要考慮的議題相當複雜,如何 "公平" 的排程呢?顯然不是每個任務都享有相同的 CPU 時間那麼單純而已。如何把抽象的 "priority" 轉換成公平的時間分配?Interactive 的程式與 batch 的程式該如何排程才可以滿足使用的期待?諸多的問題讓 scheduler 的設計可說是沒有正確答案的。 :::info interactive 指接受某些形式輸入並產生回應的任務類型。例如一個文字編輯器,會一直等待使用者輸入內容,且當輸入後儘量快速的回應(例如顯示在畫面上) ::: 在 linux 上,scheduler 需要思考更多問題。即使 linux 最初是以桌面系統發展的,但如今它已經被廣泛運用在 server、嵌入式系統、[mainframes](https://en.wikipedia.org/wiki/Mainframe_computer)、及超級電腦上。顯然,這些系統的 schedule domain 各自不同,硬體結構的差異、對任務優先的差異等,更是讓 linux 排程器的設計充滿困難。 O(1) scheduler 雖然比起其前面的版本具有更高的彈性,也考量了對於 I/O bound 及 CPU bound 的 "公平" 問題,但是因為其仰賴 heuristics 的計算來調整任務,導致了程式的架構非常難以維護,而 heuristics 也缺乏算術實質,沒有具體的邏輯建立排程的演算法。正因如此,CFS 在其後被提出以改善 O(1) scheduler 所存在的問題。 ### Overview of CFS CFS 的核心想法是保持 task 對於使用 CPU time 的 "平衡",當有 task 被給予的可用的時間落後於其他時,scheduler 會對此做出補償,給予其更多的執行時間。 那麼 CFS 該怎麼定義 "平衡" 呢?先簡而言之: CFS 會維護每一個 task 的 *virtual runtime*。 Task 的 virtual runtime 愈小,表示其被允許使用的 CPU 時間愈短,也間接代表此 task 對 CPU 的需要更高,因此更需要被排程。CFS 同時也思考到了進入 sleep 狀態的任務(例如等待 I/O)之公平性,確保當他們醒來時可以得到足夠的 CPU 額度。 :::success 補充: Virtual runtime 指 process 截至目前為止已經運行的虛擬時間,所以一個任務的 vruntime 越小,代表它比起其他任務還需要更多的 CPU 額度。 ::: 這麼說明可能有些不清楚,下面就讓我們更仔細的來認識 CFS 中的設計考量和核心架構吧! ## sched_class 在探討 CFS 本身之前,可以先認識一下在先今的 Linux 版本(5.16)中設計 scheduler 的框架。由於核心提供許多不同種類的 scheduler ,例如 RT scheduler、Deadline scheduler、CFS scheduler 及 Idle scheduler 等。近似於物件導向的 parent class(沒錯! Linux 中有許多透過 C 語言設計的物件導向思維),這些不同的 scheduler 透過 [`struct sched_class`](https://elixir.bootlin.com/linux/v5.16.6/source/kernel/sched/sched.h#L2095) 結構作為一致的抽象(abstraction)定義,這樣的模組化使得設計 scheduler 時可以不需要修改大量的程式碼,只需要定義 scheduler 對應的 operation 即可。 scheduler 的定義透過 [`DEFINE_SCHED_CLASS`](https://elixir.bootlin.com/linux/v5.16.6/source/kernel/sched/sched.h#L2180) 這個 macro 完成,後者將 scheduler 根據 linker script 擺放到編譯出的 linux image 的對應位置。設計上,每一個執行單元在建立之後,都要選擇一種調度策略,而每一種調度策略會對應一個 sched_class。因此,除了執行單元在 scheduler 中有優先級,不同的 scheduler 之間也存在先後之分。 有趣的是,這些 scheduler 的先後順序是透過很特別的方法決定的,前面我們有提到 `DEFINE_SCHED_CLASS` 會將 scheduler 按照 linker script 擺在記憶體中。實際上,這些 scheduler 會緊密的排列在相鄰的位置,而參見 [/include/asm-generic/vmlinux.lds.h](https://elixir.bootlin.com/linux/v5.16.6/source/include/asm-generic/vmlinux.lds.h#L127): ```cpp /* * The order of the sched class addresses are important, as they are * used to determine the order of the priority of each sched class in * relation to each other. */ #define SCHED_DATA \ STRUCT_ALIGN(); \ __begin_sched_classes = .; \ *(__idle_sched_class) \ *(__fair_sched_class) \ *(__rt_sched_class) \ *(__dl_sched_class) \ *(__stop_sched_class) \ __end_sched_classes = .; ``` 註解告訴我們 `sched_class` 在記憶體中的先後順序其實就對應優先程度!這使得核心實際上不需要透過額外的資料結構來紀錄優先序。在 [`_pick_next_task`](https://elixir.bootlin.com/linux/v5.16.6/source/kernel/sched/core.c#L5579) 的 [`for_each_class`](https://elixir.bootlin.com/linux/v5.16.6/source/kernel/sched/sched.h#L2195) 也驗證了此種設計。 :::info 之所以用 section 來決定優先順序是為考慮對 cache 使用上的友善,詳見: (感謝 @Uduru0522 補充) * [sched: Micro optimization in pick_next_task() and in check_preempt_curr()](https://lore.kernel.org/all/157675913272.349305.8936736338884044103.stgit@localhost.localdomain) * [sched: Force the address order of each sched class descriptor](https://lore.kernel.org/all/20191219214558.845353593@goodmis.org/T/#m74565019ad2c2ebbe4eff82190a72ed70c683d2c) ::: 也因為所有的 scheduler 都需要透過 `DEFINE_SCHED_CLASS` 做宣告,我們可以在 [`DEFINE_SCHED_CLASS` 的搜尋頁面](https://elixir.bootlin.com/linux/v5.16.6/C/ident/DEFINE_SCHED_CLASS) 找到不同種類的 scheduler 所在。因此,我們將從此 macro 出發,並深入解析實作一個 scheduler 所需定義的介面。 ## CFS scheduler 的關鍵元素 > [CFS调度器(1)-基本原理](http://www.wowotech.net/process_management/447.html) ### Weight Function 所謂公平並不是使所有任務都具有相同的執行時間,而是應該根據每個任務的優先級給予合適的時間比例。為此,核心引入權重的概念,權重代表著進程的優先級。各個進程之間按照權重的比例分配 cpu 時間。 而權重的數值就透過 nice 值的概念進行對應,CFS 可以透過陣列表對應 nice value 與一個 weight 數值。nice 值的範圍在 [-20, 19] 之間,值越小,代表優先級越大,權重值越大。如下所示為在 linux kernel 4.4 以前在 kernel/sched/sched.h header 中的定義。 ```cpp /* * Nice levels are multiplicative, with a gentle 10% change for every * nice level changed. I.e. when a CPU-bound task goes from nice 0 to * nice 1, it will get ~10% less CPU time than another CPU-bound task * that remained on nice 0. * * The "10% effect" is relative and cumulative: from _any_ nice level, * if you go up 1 level, it's -10% CPU usage, if you go down 1 level * it's +10% CPU usage. (to achieve that we use a multiplier of 1.25. * If a task goes up by ~10% and another task goes down by ~10% then * the relative distance between them is ~25%.) */ static const int prio_to_weight[40] = { /* -20 */ 88761, 71755, 56483, 46273, 36291, /* -15 */ 29154, 23254, 18705, 14949, 11916, /* -10 */ 9548, 7620, 6100, 4904, 3906, /* -5 */ 3121, 2501, 1991, 1586, 1277, /* 0 */ 1024, 820, 655, 526, 423, /* 5 */ 335, 272, 215, 172, 137, /* 10 */ 110, 87, 70, 56, 45, /* 15 */ 36, 29, 23, 18, 15, }; ``` nice value 換算到 weight 的公式近似 $\frac{1024} {1.25 ^ {nice}}$。因此 nice value 每增加 1,weight 就乘上 1.25 倍。這使得 nice value 每增加 1 可以減少約 10% 的 CPU time 的比重。 :::info 我們可以簡單的計算出 10% 的由來,假設 process A 的 nice 為 n 而 process B 的 nice 為 n + 1。因此 $weight_A = \frac{1024} {1.25 ^ {n}}$ 而 $weight_B = \frac{1024} {1.25 ^ {n+1}}$,則 A 佔比重 $\frac{weight_A}{ weight_A+weight_B}$,B 佔比重 $\frac{weight_B}{ weight_A+weight_B}$,兩者比重相減的值(計算略)得到的數值就約為 10% ::: 不過這其實存在一個容易被忽略的缺陷: 因為該陣列是 static 宣告在 header 中的,這導致所有 include 都會建立一份獨立的 header 到記憶體中。由於該陣列理應只需在記憶體中存在一份且不必被修改,該結構被修改成 [`const int sched_prio_to_weight`](https://elixir.bootlin.com/linux/v5.16.6/source/kernel/sched/core.c#L10848)。對照 [sched/core: Move the sched_to_prio[] arrays out of line](https://lore.kernel.org/lkml/tip-ed82b8a1ff76ed7b2709e36ed361ddd022fe2407@git.kernel.org/) 的 patch 郵件,從這裡我們也可以發現一個也許可以著手的修改思考! ### Schedule Latency ```cpp /* * The idea is to set a period in which each task runs once. * * When there are too many tasks (sched_nr_latency) we have to stretch * this period because otherwise the slices get too small. * * p = (nr <= nl) ? l : l*nr/nl */ static u64 __sched_period(unsigned long nr_running) { if (unlikely(nr_running > sched_nr_latency)) return nr_running * sysctl_sched_min_granularity; else return sysctl_sched_latency; } ``` schedule lantency 所指是每個任務在被排程之後,下一次可以再被排程的時間,一般狀況下固定為 [`sysctl_sched_latency`](https://elixir.bootlin.com/linux/v5.16.6/source/kernel/sched/fair.c#L38)(6ms)。但假設想固定這個延遲時間,可以想像如果 queue 中任務一多,每個任務能夠獲得的 time slice 將被瓜分而導致過於頻繁的 context switch。因此,CFS 中會根據 queue 中的任務數量動態調整 latency 的。當任務數量超過 [`sched_nr_latency`](https://elixir.bootlin.com/linux/v5.16.6/source/kernel/sched/fair.c#L73),會以 `nr_running` 為權重提高 latency,確保每個任務至少有足夠 `sysctl_sched_min_granularity` 的運行時間可以使用。 可以在 [`__sched_period`](https://elixir.bootlin.com/linux/v5.16.6/source/kernel/sched/fair.c#L668) 看到這一系列行為的呈現。 ### Virtual Runtime 前面我們有提到 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`](https://elixir.bootlin.com/linux/v5.16.6/source/kernel/sched/fair.c#L652),先閱讀其中的註解部份: ```cpp /* * delta_exec * weight / lw.weight * OR * (delta_exec * (weight * lw->inv_weight)) >> WMULT_SHIFT * * Either weight := NICE_0_LOAD and lw \e sched_prio_to_wmult[], in which case * we're guaranteed shift stays positive because inv_weight is guaranteed to * fit 32 bits, and NICE_0_LOAD gives another 10 bits; therefore shift >= 22. * * Or, weight =< lw.weight (because lw.weight is the runqueue weight), thus * weight/lw.weight <= 1, and therefore our shift will also be positive. */ ``` 根據註解,計算式為: $$ \text{vruntime_delta} = \text{delta_exec} \times \frac{\text{weigth}}{\text{lw.weigth}} ) \\ \text{vruntime_delta} = (\text{delta_exec} \times \text{weigth} \times \text{lw->inv_weigth}) >> \text{WMULT_SHIFT} $$ 為了方便說明,我們先考慮 `weight == NICE_0_LOAD` 的一般情形下([`CONFIG_64BIT`](https://elixir.bootlin.com/linux/v5.16.6/source/kernel/sched/sched.h#L122) not defined),可以把計算式理解成: $$ \text{vruntime_delta} = \text{wall_time_delta} \times \frac{\text{NICE_0_weigth}}{\text{NICE_k_weigth}} $$ 不過在核心的計算中可能需要遇到沒有 FPU 的情況,為此我們需要針對會用到的除法運算進行調整。因為 $1/ \text{NICE_k_weigth}$ 顯然是 1 以下的小數(對照 `sched_prio_to_weight` 都是正整數),我們可以把式子調整成: $$ \text{vruntime_delta} = \text{wall_time_delta} \times \frac{\text{NICE_0_weigth}}{\text{NICE_k_weigth}} \\ = \text{wall_time_delta} \times \frac{\text{NICE_0_weigth} \times 2^{32}}{\text{NICE_k_weigth} \times 2^{32}} \\ = (\text{wall_time_delta} \times \frac{\text{NICE_0_weigth} \times 2^{32}}{\text{NICE_k_weigth}} ) >> 32 \\ = (\text{wall_time_delta} \times \text{NICE_0_weigth} \times \frac{2^{32}}{\text{NICE_k_weigth}} ) >> 32 $$ 也就是說,我們可以先把 $1/ \text{NICE_k_weigth}$ 放大 $2^{32}$ 倍和 `wall_time_delta` 進行計算,也就是 $\frac{2^{32}}{\text{NICE_k_weigth}}$(`lw->inv_weigth`),然後最後再把放大的部份補償回來即可。 $\frac{2^{32}}{\text{NICE_k_weigth}}$ 這個數字在 [`sched_prio_to_wmult`](https://elixir.bootlin.com/linux/v5.16.6/source/kernel/sched/core.c#L10866) 中被記錄起來,例如我們可以在 [`reweight_task`](https://elixir.bootlin.com/linux/v5.16.6/source/kernel/sched/fair.c#L3070) 中看到其被用來指派給一個 `sched_entity` 中的 `load_weight` 中儲存起來。 ```cpp void reweight_task(struct task_struct *p, int prio) { struct sched_entity *se = &p->se; struct cfs_rq *cfs_rq = cfs_rq_of(se); struct load_weight *load = &se->load; unsigned long weight = scale_load(sched_prio_to_weight[prio]); reweight_entity(cfs_rq, se, weight); load->inv_weight = sched_prio_to_wmult[prio]; } ``` 藉此 [`calc_delta_fair`](https://elixir.bootlin.com/linux/v5.16.6/source/kernel/sched/fair.c#L652) 可以拿到 `lw->inv_weigth` 並使用,可以看到核心的計算都被放在 `__calc_delta`,而 `calc_delta_fair` 做了一點小優化因為 nice 值為 0 的任務 vruntime = wall time。 ```cpp static inline u64 calc_delta_fair(u64 delta, struct sched_entity *se) { if (unlikely(se->load.weight != NICE_0_LOAD)) delta = __calc_delta(delta, NICE_0_LOAD, &se->load); return delta; } ``` 下面來看 vruntime 計算的核心函數 `__calc_delta` 是怎麼實現的: ```cpp /* /include/linux/sched.h */ # define SCHED_FIXEDPOINT_SHIFT 10 # define SCHED_FIXEDPOINT_SCALE (1L << SCHED_FIXEDPOINT_SHIFT) /* kernel/sched/sched.h */ #ifdef CONFIG_64BIT # define NICE_0_LOAD_SHIFT (SCHED_FIXEDPOINT_SHIFT + SCHED_FIXEDPOINT_SHIFT) # define scale_load(w) ((w) << SCHED_FIXEDPOINT_SHIFT) # define scale_load_down(w) \ ({ \ unsigned long __w = (w); \ if (__w) \ __w = max(2UL, __w >> SCHED_FIXEDPOINT_SHIFT); \ __w; \ }) #else # define NICE_0_LOAD_SHIFT (SCHED_FIXEDPOINT_SHIFT) # define scale_load(w) (w) # define scale_load_down(w) (w) #endif /* kernel/sched/fair.c */ static u64 __calc_delta(u64 delta_exec, unsigned long weight, struct load_weight *lw) { u64 fact = scale_load_down(weight); u32 fact_hi = (u32)(fact >> 32); int shift = WMULT_SHIFT; int fs; __update_inv_weight(lw); if (unlikely(fact_hi)) { fs = fls(fact_hi); shift -= fs; fact >>= fs; } fact = mul_u32_u32(fact, lw->inv_weight); ... ``` 首先,我們想計算 $\text{weigth} \times \text{lw->inv_weigth}$。然而,如果 $\text{weigth}$ 超過 32 bits(在 `CONFIG_64BIT` defined 時可能發生),而 $\text{lw->inv_weigth}$(也就是 $\frac{2^{32}}{\text{NICE_k_weigth}}$) 是 32 bits 表示的,直接相乘可能會發生超過 64 bits 的 overflow 的問題。為此,我們可以先把最後要做的 $\times \frac{1}{2^{32}}$( $>> 32$) 中的一部分 shift 先 apply 到 weight 上,也就是把 $\text{weigth} \times \frac{2^{32}}{\text{NICE_k_weigth}} \times \frac{1}{2^{32}}$ 改寫成 $\frac{\text{weigth}}{2^{fs}} \times \frac{2^{32}}{\text{NICE_k_weigth}} \times \frac{2^{fs}}{2^{32}}$。然後上面就是先計算 $\frac{\text{weigth}}{2^{fs}} \times \frac{2^{32}}{\text{NICE_k_weigth}}$ 的部份 ```cpp fact_hi = (u32)(fact >> 32); if (fact_hi) { fs = fls(fact_hi); shift -= fs; fact >>= fs; } return mul_u64_u32_shr(delta_exec, fact, shift); } ``` 最後要再乘上 $\text{wall_time_delta} \times \frac{2^{fs}}{2^{32}}$,類似於前面,直接乘上 $\text{wall_time_delta}$ 可能有 overflow,因此可以先 apply 一部分 shift 到前面運算得到的結果。 :::warning - [ ] TODO: [`CONFIG_64BIT`](https://elixir.bootlin.com/linux/v5.16.6/source/kernel/sched/sched.h#L122) 的目的以及實作上的額外考量點 ::: ### CFS 的資料結構 [`sched_entity`](https://elixir.bootlin.com/linux/v5.16.6/source/include/linux/sched.h#L529), [`task_struct`](https://elixir.bootlin.com/linux/v5.16.6/source/include/linux/sched.h#L723), [`task_group`](https://elixir.bootlin.com/linux/v5.16.6/source/kernel/sched/sched.h#L391) 和 [`cfs_rq`](https://elixir.bootlin.com/linux/v5.16.6/source/kernel/sched/sched.h#L532) 是 CFS 最為核心的資料結構,釐清這些資料結構之間的架構可說是弄懂 CFS 的關鍵。我們可以先透過下面的圖簡單的了解四個資料結構彼此的大致關係(紅色的箭頭表示指標變數的 reference,灰色虛線則是對應變數與其 instance 內部的詳細內容)。接著,就讓我們更詳盡的用這張圖進行說明吧! ![](https://i.imgur.com/gOgkZ2I.png) #### `sched_entity` 我們首先關注排程器管理資源分配的單元: [`sched_entity`](https://elixir.bootlin.com/linux/v5.16.6/source/include/linux/sched.h#L529)。前面我們先探討了 CFS 如何實現其公平的目標,提及了每個任務會根據優先度對應的權重調配可以分到的 time slice。若從程式碼的實作而言,CFS 實際上調配權重以排程的單位就是 `sched_entity` 實體。 ```cpp struct sched_entity { /* For load-balancing: */ struct load_weight load; struct rb_node run_node; ... u64 vruntime; ... struct sched_entity *parent; /* rq on which this entity is (to be) queued: */ struct cfs_rq *cfs_rq; /* rq "owned" by this entity/group: */ struct cfs_rq *my_q; ... ``` 也因此,首先我們可以看到 `struct sched_entity` 中包含 `struct cfs_rq` 的指標 `cfs_rq`,指向其所在的 runqueue 中。而另一個 `struct cfs_rq` 指標 `my_q` 則是當該 se 是屬於 group entity 是,會 reference 到自己底下的 run queue。 結構中都有自己的 [`struct load_weight`](https://elixir.bootlin.com/linux/v5.16.6/source/include/linux/sched.h#L393) 結構的成員,代表該排程單元在排程中的被分配權重。最後,`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 的紅黑樹下。 ```cpp struct rb_node { unsigned long __rb_parent_color; struct rb_node *rb_right; struct rb_node *rb_left; } __attribute__((aligned(sizeof(long)))); ``` ![](https://i.imgur.com/NBHmRwV.png) > [Inside the Linux 2.6 Completely Fair Scheduler](https://developer.ibm.com/tutorials/l-completely-fair-scheduler/) 對於 scheduler 如何使用紅黑樹進行排程,我們會在後續的章節透過程式碼仔細探討。先簡單從概念上解釋 CFS 維護任務調度的思維: 那些急需 CPU(virtual runtime 小) 的 task 會被放在紅黑樹的左邊,而不迫切的 task 則在右邊。為保持公平,Scheduler 會去挑選最左邊(leftmost)的任務,任務被切換掉後,把 virtual runtime 加上使用的 CPU 時間重新插入 RB-tree。如此一來,左側的任務就被確保有時間,並且樹的內容會從右向左遷移以保持公平,每個 task 會不斷追逐其他使用 CPU 多於自己的任務以保持平衡。 #### `task_struct` 對於 Linux 有稍微了解的人應該知道,Linux 可以透過 [`task_struct`](https://elixir.bootlin.com/linux/v5.16.6/source/include/linux/sched.h#L723) 結構來描述在核心中被建立之任務。關注與 CFS 有關的部分的話,`task_struct` 中包含的 [`sched_entity`](https://elixir.bootlin.com/linux/v5.16.6/source/include/linux/sched.h#L529) 即可用來向 CFS scheduler 註冊排程,而 `sched_task_group` 則會指向所屬的 `task_group`。 ```cpp struct task_struct { struct sched_entity se; struct task_group *sched_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`](https://elixir.bootlin.com/linux/v5.16.6/source/kernel/sched/sched.h#L391) 結構描述由多個任務組成的群組。`task_group` 中也包含 `sched_entity` 實體,因此其也可以做為一個 CFS 所管理的對象,被加入到一個 runqueue 之中。並且,`task_group` 中對於自己群組中的任務也存在自己的 runqueue 中。換句話說,系統中的 runqueue 其實是階層式樹狀結構的,在前面的章節中我們已經介紹過這點。 ```cpp /* Task group related information */ struct task_group { ... /* schedulable entities of this group on each CPU */ struct sched_entity **se; /* runqueue "owned" by this group on each CPU */ struct cfs_rq **cfs_rq; ``` `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`](https://elixir.bootlin.com/linux/v5.16.6/source/kernel/sched/core.c#L9252) 這個群組中。 #### `cfs_rq` 描述 CFS 之 runqueue 的結構是 [`cfs_rq`](https://elixir.bootlin.com/linux/v5.16.6/source/kernel/sched/sched.h#L532): ```cpp struct cfs_rq { struct load_weight load; unsigned int nr_running; u64 min_vruntime; struct rb_root_cached tasks_timeline; struct task_group *tg; /* group that "owns" this runqueue */ ``` 可以看到 `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`](https://elixir.bootlin.com/linux/v5.16.6/source/include/linux/rbtree_types.h#L26) 類型的成員 `tasks_timeline`,後者包含了 RB-tree 的根節點(root, rnuqueue 直接擁有)與最左側的節點(leftmost, 指到某個 `sched_entity` 之 `rb_node`)之 reference 以更有效率的操作 RB-tree。 ```cpp struct rb_root_cached { struct rb_root rb_root; struct rb_node *rb_leftmost; }; ``` `cfs_rq` 也可以透過其下的 `tg` 來找到自己所屬的 `task_group`。 ## Reference * [Inside the Linux 2.6 Completely Fair Scheduler](https://developer.ibm.com/technologies/linux/tutorials/l-completely-fair-scheduler/) * [CFS group scheduling](https://lwn.net/Articles/240474/) * [Linux CFS and task group](https://mechpen.github.io/posts/2020-04-27-cfs-group/index.html)