CFS排程器(3)-組排程
CFS排程器(3)-組排程
前言
現在的計算機基本都支援多使用者登陸。如果一臺計算機被兩個使用者A和B使用。假設使用者A執行9個程序,使用者B只執行1個程序。按照之前文章對CFS排程器的講解,我們認為使用者A獲得90% CPU時間,使用者B只獲得10% CPU時間。隨著使用者A不停的增加執行程序,使用者B可使用的CPU時間越來越少。這顯然是不公平的。因此,我們引入組排程(Group Scheduling )的概念。我們以使用者組作為排程的單位,這樣使用者A和使用者B各獲得50% CPU時間。使用者A中的每個程序分別獲得5.5%(50%/9)CPU時間。而使用者B的程序獲取50% CPU時間。這也符合我們的預期。本篇文章講解CFS組排程實現原理。
注:程式碼分析基於Linux 4.18.0。使能組排程需要配置CONFIG_CGROUPS和CONFIG_FAIR_GROUP_SCHED。圖片變形了該怎麼辦?瀏覽器單獨開啟吧!我也沒辦法
再談排程實體
通過之前的文章,我們已經介紹了CFS排程器主要管理的是排程實體。每一個程序通過 task_struct
描述, task_struct
包含排程實體 sched_entity
參與排程。暫且針對這種排程實體,我們稱作 task se 。現在引入組排程的概念,我們使用 task_group
描述一個組。在這個組中管理組內的所有程序。因為CFS就緒佇列管理的單位是排程實體,因此, task_group
也脫離不了 sched_entity
,所以在 task_group
結構體也包含排程實體 sched_entity
,我們稱這種排程實體為 group se 。 task_group
定義在 kernel/sched/sched.h
檔案。
struct task_group { struct cgroup_subsys_state css; #ifdef CONFIG_FAIR_GROUP_SCHED /* schedulable entities of this group on each CPU */ struct sched_entity**se;/* 1 */ /* runqueue "owned" by this group on each CPU */ struct cfs_rq**cfs_rq;/* 2 */ unsigned longshares;/* 3 */ #ifdefCONFIG_SMP atomic_long_tload_avg ____cacheline_aligned;/* 4 */ #endif #endif struct cfs_bandwidthcfs_bandwidth; /* ... */ };
task_group
如果我們CPU數量等於2,並且只有一個使用者組,那麼系統中組排程示意圖如下。

系統中一共執行8個程序。CPU0上執行3個程序,CPU1上執行5個程序。其中包含一個使用者組A,使用者組A中包含5個程序。CPU0上group se獲得的CPU時間為group se對應的group cfs_rq管理的所有程序獲得CPU時間之和。系統啟動後預設有一個 root_task_group
,管理系統中最頂層CFS就緒佇列cfs_rq。在2個CPU的系統上, task_group
結構體se和cfs_rq成員陣列長度是2,每個group se都對應一個group cfs_rq。
資料結構之間的關係
假設系統包含4個CPU,組排程的開啟的情況下,各種結構體之間的關係如下圖。
在每個CPU上都有一個全域性的就緒佇列 struct rq
,在4個CPU的系統上會有4個全域性就緒佇列,如圖中紫色結構體。系統預設只有一個根 task_group
叫做root_task_group。 rq->cfs_rq
指向系統根CFS就緒佇列。根CFS就緒佇列維護一棵紅黑樹,紅黑樹上一共10個就緒態排程實體,其中9個是task se,1個group se(圖上藍色se)。group se的 my_q
成員指向自己的就緒佇列。該就緒佇列的紅黑樹上共9個task se。其中 parent
成員指向group se。每個group se對應一個group cfs_rq。4個CPU會對應4個group se和group cfs_rq,分別儲存在 task_group
結構體se和cfs_rq成員。 se->depth
成員記錄se巢狀深度。最頂層CFS就緒佇列下的se的深度為0,group se往下一層層遞增。 cfs_rq->nr_runing
成員記錄CFS就緒佇列所有排程實體個數,不包含子就緒佇列。 cfs_rq->h_nr_running
成員記錄就緒佇列層級上所有排程實體的個數,包含group se對應group cfs_rq上的排程實體。例如,圖中上半部,nr_runing和h_nr_running的值分別等於10和19,多出的9是group cfs_rq的h_nr_running。group cfs_rq由於沒有group se,因此nr_runing和h_nr_running的值都等於9。
組程序排程
使用者組內的程序該如何排程呢?通過上面的分析,我們可以通過根CFS就緒佇列一層層往下便利選擇合適程序。例如,先從根就緒佇列選擇適合執行的group se,然後找到對應的group cfs_rq,再從group cfs_rq上選擇task se。在CFS排程類中,選擇程序的函式是pick_next_task_fair()。
static struct task_struct * pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct rq_flags *rf) { struct cfs_rq *cfs_rq = &rq->cfs;/* 1 */ struct sched_entity *se; struct task_struct *p; put_prev_task(rq, prev); do { se = pick_next_entity(cfs_rq, NULL);/* 2 */ set_next_entity(cfs_rq, se); cfs_rq = group_cfs_rq(se);/* 3 */ } while (cfs_rq);/* 4 */ p = task_of(se); return p; }
se->my_q
組程序搶佔
週期性排程會呼叫task_tick_fair()函式。
static void task_tick_fair(struct rq *rq, struct task_struct *curr, int queued) { struct cfs_rq *cfs_rq; struct sched_entity *se = &curr->se; for_each_sched_entity(se) { cfs_rq = cfs_rq_of(se); entity_tick(cfs_rq, se, queued); } }
for_each_sched_entity()是一個巨集定義 for (; se; se = se->parent)
,順著se的parent連結串列往上走。entity_tick()函式繼續呼叫check_preempt_tick()函式,這部分在之前的文章已經說過了。check_preempt_tick()函式會根據滿足搶佔當前程序的條件下設定 TIF_NEED_RESCHED 標誌位。滿足搶佔條件也很簡單,只要順著 se->parent
這條連結串列便利下去,如果有一個se執行時間超過分配限額時間就需要重新排程。
使用者組的權重
每一個程序都會有一個權重,CFS排程器依據權重的大小分配CPU時間。同樣 task_group
也不例外,前面已經提到使用share成員記錄。按照前面的舉例,系統有2個CPU, task_group
中勢必包含兩個group se和與之對應的group cfs_rq。這2個group se的權重按照比例分配 task_group
權重。如下圖所示。
CPU0上group se下有2個task se,權重和是3072。CPU1上group se下有3個task se,權重和是4096。 task_group
權重是1024。因此,CPU0上group se權重是439(1024*3072/(3072+4096)),CPU1上group se權重是585(1024-439)。當然這裡的計算group se權重的方法是最簡單的方式,程式碼中實際計算公式是考慮每個group cfs_rq的負載貢獻比例,而不是簡單的考慮權重比例。
使用者組時間限額分配
分配給每個程序時間計算函式是sched_slice(),之前的分析都是基於不考慮組排程的情況下。現在考慮組排程的情況下程序應該分配的時間如何調整呢?先舉個簡單不考慮組排程的例子,在一個單核系統上2個程序,權重都是1024。在不考慮組排程的情況下,排程實體se分配的時間限額計算公式如下:
se->load.weight time = sched_period * ------------------------- cfs_rq->load.weight
我們還需要計算se的權重佔整個CFS就緒佇列權重的比例乘以排程週期時間即可。2個程序根據之前文章的分析,排程週期是6ms,那麼每個程序分配的時間是6ms*1024/(1024+1024)=3ms。
現在考慮組排程的情況。系統依然是單核,存在一個 task_group
,所有的程序權重是1024, task_group
權重是2048。如下圖所示。
group cfs_rq下的程序分配時間計算公式如下(gse := group se; gcfs_rq := group cfs_rq):
se->load.weightgse->load.weight time = sched_period * ------------------------- * ------------------------ gcfs_rq->load.weightcfs_rq->load.weight
根據公式,計算group cfs_rq下程序的配時間如下:
10242048 time = 6ms * --------------- * ---------------- = 2ms 1024 + 10242048 + 1024
依據上面的2個計算公式,我們可以計算上面例子中每個程序分配的時間如下圖所示。
以上簡單介紹了 task_group
巢狀一層的情況,如果 task_group
下面繼續包含 task_group
,那麼上面的計算公式就要再往上計算一層比例。實現該計算公式的函式是sched_slice()。
static u64 sched_slice(struct cfs_rq *cfs_rq, struct sched_entity *se) { u64 slice = __sched_period(cfs_rq->nr_running + !se->on_rq);/* 1 */ for_each_sched_entity(se) {/* 2 */ struct load_weight *load; struct load_weight lw; cfs_rq = cfs_rq_of(se); load = &cfs_rq->load;/* 3 */ if (unlikely(!se->on_rq)) { lw = cfs_rq->load; update_load_add(&lw, se->load.weight); load = &lw; } slice = __calc_delta(slice, se->load.weight, load);/* 4 */ } return slice; }
- 根據當前就緒程序個數計算排程週期,預設情況下,程序不超過8個情況下,排程週期預設6ms。
- for迴圈根據se->parent連結串列往上計算比例。
- 獲得se依附的cfs_rq的負載資訊。
- 計算slice = slice * se->load.weight / cfs_rq->load.weight的值。
Group Se權重計算
上面舉例說到group se的權重計算是根據權重比例計算。但是,實際的程式碼並不是。當我們dequeue task、enqueue task以及task tick的時候會通過update_cfs_group()函式更新group se的權重資訊。
static void update_cfs_group(struct sched_entity *se) { struct cfs_rq *gcfs_rq = group_cfs_rq(se);/* 1 */ long shares, runnable; if (!gcfs_rq) return; shares= calc_group_shares(gcfs_rq);/* 2 */ runnable = calc_group_runnable(gcfs_rq, shares); reweight_entity(cfs_rq_of(se), se, shares, runnable);/* 3 */ }
- 獲得group se對應的group cfs_rq。
- 計算新的權重值。
- 更新group se的權重值為shares。
calc_group_shares()根據當前group cfs_rq負載情況計算新的權重。
static long calc_group_shares(struct cfs_rq *cfs_rq) { long tg_weight, tg_shares, load, shares; struct task_group *tg = cfs_rq->tg; tg_shares = READ_ONCE(tg->shares); load = max(scale_load_down(cfs_rq->load.weight), cfs_rq->avg.load_avg); tg_weight = atomic_long_read(&tg->load_avg); /* Ensure tg_weight >= load */ tg_weight -= cfs_rq->tg_load_avg_contrib; tg_weight += load; shares = (tg_shares * load); if (tg_weight) shares /= tg_weight; return clamp_t(long, shares, MIN_SHARES, tg_shares); }
根據calc_group_shares()函式,我們可以得到權重計算公式如下(grq := group cfs_rq):
tg->shares * load ge->load.weight = ------------------------------------------------- tg->load_avg - grq->tg_load_avg_contrib + load load = max(grq->load.weight, grq->avg.load_avg)
tg->load_avg
指所有的group cfs_rq負載貢獻和。 grq->tg_load_avg_contrib
是指該group cfs_rq已經向 tg->load_avg
貢獻的負載。因為tg是一個全域性共享變數,多個CPU可能同時訪問,為了避免嚴重的資源搶佔。group cfs_rq負載貢獻更新的值並不會立刻加到 tg->load_avg
上,而是等到負載貢獻大於tg_load_avg_contrib一定差值後,再加到 tg->load_avg
上。例如,2個CPU的系統。CPU0上group cfs_rq初始值tg_load_avg_contrib為0,當group cfs_rq每次定時器更新負載的時候並不會訪問tg變數,而是等到group cfs_rq的負載grp->avg.load_avg大於tg_load_avg_contrib很多的時候,這個差值達到一個數值(假設是2000),才會更新 tg->load_avg
為2000。然後,tg_load_avg_contrib的值賦值2000。又經過很多個週期後,grp->avg.load_avg和tg_load_avg_contrib的差值又等於2000,那麼再一次更新 tg->load_avg
的值為4000。這樣就避免了頻繁訪問tg變數。
但是上面的計算公式的依據是什麼呢?如何得到的?首先我覺得我們能介紹的計算方法是上一節《使用者組的權重》說的方法,計算group cfs_rq的權重佔的比例。公式如下。
tg->shares * grq->load.weight ge->load.weight = -------------------------------(1) \Sum grq->load.weight
由於計算 \Sum grq->load.weight 這個總和開銷太大(原因可能是CPU數量比較大的系統,訪問其他CPU group cfs_rq造成資料訪問競爭激烈)。因此我們使用平均負載來近似處理,平均負載值變化緩慢,因此近似後的值更容易計算且更穩定。近似處理條件如下,將權重和平均負載近似處理。
grq->load.weight -> grq->avg.load_avg(2)
經過近似處理後的公式(1)變換如下:
tg->shares * grq->avg.load_avg ge->load.weight = ------------------------------(3) tg->load_avg Where: tg->load_avg ~= \Sum grq->avg.load_avg
公式(3)問題在於,因為平均負載值變化很慢 (它的設計正是如此) ,這會導致在邊界條件的時候的瞬變。 具體而言,當空閒group開始執行一個程序的時候。 我們的CPU的grq->avg.load_avg需要花費時間來慢慢變化,產生不良的延遲。在這種特殊情況下(單核CPU也是這種情況),公式(1)計算如下:
tg->shares * grq->load.weight ge->load.weight = ------------------------------- = tg->shares (4) grq->load.weight
我們的目標就是將近似公式(3)在UP情景時修改成公式(4)的情況。
ge->load.weight = tg->shares * grq->load.weight ---------------------------------------------------(5) tg->load_avg - grq->avg.load_avg + grq->load.weight
但是因為grq->load.weight可以降到0,導致除數是0。因此我們需要使用grq->avg.load_avg作為其下限,然後給出:
tg->shares * grq->load.weight ge->load.weight = -----------------------------(6) tg_load_avg' Where: tg_load_avg' = tg->load_avg - grq->avg.load_avg + max(grq->load.weight, grq->avg.load_avg)
在UP系統上,公式(6)和公式(4)相似。在正常情況下,公式(6)和公式(3)相似。
說實話,真的是一大堆的公式,而且是各種近似處理和懟引數。一下看到公式的結果總是一頭霧水,因為這可能涉及多次不同的優化修改,有些可能是經驗總結,有些可能是實際環境測試。當你看不懂公式的時候,不妨會退到這個功能剛剛新增時候的樣子,最初的版本總是讓人容易接受。然後,順著每一筆提交記錄檢視優化程式碼的原因,一步一個腳印,或許“面向大海春暖花開”。
