[Linux][kernel]CFS調度策略

CFS調度策略

概述

CFS(完全公平調度器)是從內核2.6.23版本開始採用的進程調度器。基本原理:設定一個調度週期(sched_latency_ns),目標是讓每個進程在這個週期內至少有機會運行一次。也就是每個進程等待cpu的時間最長不超過這個調度週期;然後根據進程的數量,平分這個調度週期內cpu的使用權,由於進程的優先級與nice值不同,分割的時候需要加權,每個進程的累積運行時間保存在自己的vruntime字段裏,vruntime最小的就獲得本輪運行的權利。

CFS背後主要想法是維護爲進程提供處理器時間方面的平衡(公平性)。意味着應給進程分配相當數量的處理器。分給某個進程的時間失去平衡時,應給失去平衡的進程分配時間,讓其執行。

CFS在vruntime的地方提供給某個進程的時間量,進程的vruntime越小,意味着進程被允許訪問cpu的時間越短-其對cpu的需求越高,CFS還包含睡眠公平概念以便確保那些目前沒有運行的任務(如等待IO)在其最終需要時獲取相當份額的cpu。
與之前的Linux調度器不同是,它沒有將任務維護在運行隊列中,CFS維護一個以時間爲順序的紅黑樹。

紅黑樹:

  • 紅黑樹是自平衡的,沒有路徑比其它任何路徑長兩倍以上。
  • 樹上運行按O(log n)時間發生(n是樹中節點的數量),可以快速高效的插入或者刪除任務。

任務存儲在以時間爲順序的紅黑樹中(由 sched_entity 對象表示),對處理器需求最多的任務 (最低虛擬運行時)存儲在樹的左側,處理器需求最少的任務(最高虛擬運行時)存儲在樹的右側。 爲了公平,調度器然後選取紅黑樹最左端的節點調度爲下一個以便保持公平性。任務通過將其運行時間添加到虛擬運行時, 說明其佔用 CPU 的時間,然後如果可運行,再插回到樹中。這樣,樹左側的任務就被給予時間運行了,樹的內容從右側遷移到左側以保持公平。 因此,每個可運行的任務都會追趕其他任務以維持整個可運行任務集合的執行平衡。

紅黑樹

CFS內部原理

Linux 內的所有任務都由稱爲 task_struct 的任務結構表示。該結構(以及其他相關內容)完整地描述了任務幷包括了任務的當前狀態、其堆棧、進程標識、優先級(靜態和動態)等等。您可以在 ./linux/include/linux/sched.h 中找到這些內容以及相關結構。 但是因爲不是所有任務都是可運行的,您在 task_struct 中不會發現任何與 CFS 相關的字段。 相反,會創建一個名爲 sched_entity 的新結構來跟蹤調度信息。

CFS結構圖

紅黑樹的根通過rb_root元素通過cfs_rq結構(kernel/sched.c)引用。紅黑樹的葉子不包含信息,但是內部節點代表一個或者多個可運行的task。紅黑樹的每個節點都用rb_node表示,包含子引用和父對象的顏色。re_node包含在sched_entity結構中,該結構包含rb_node引用、負載權重以及各種統計數據。最重要的是,sched_entity包含vruntime(64位字段),它表示任務運行的時間量,並作爲紅黑樹的索引。 最後,task_struct位於頂端,它完整地描述任務幷包含 sched_entity 結構。

CFS 部分而言,調度函數非常簡單。在./kernel/sched.c中,您會看到通用schedule()函數,它會先搶佔當前運行任務(除非它通過yield()代碼先搶佔自己)。注意CFS沒有真正的時間切片概念用於搶佔,因爲搶佔時間是可變的。當前運行任務(現在被搶佔的任務)通過對put_prev_task調用(通過調度類)返回到紅黑樹。當schedule函數開始確定下一個要調度的任務時,它會調用pick_next_task函數。此函數也是通用的(在./kernel/sched.c中),但它會通過調度器類調用CFS調度器。CFS中的pick_next_task函數可以在./kernel/sched_fair.c(稱爲pick_next_task_fair())中找到。此函數只是從紅黑樹中獲取最左端的任務並返回相關sched_entity。通過此引用,一個簡單的task_of()調用確定返回的task_struct引用。通用調度器最後爲此任務提供處理器。

優先級與CFS

CFS 不直接使用優先級而是將其用作允許任務執行的時間的衰減係數。低優先級任務具有更高的衰減係數,而高優先級任務具有較低的衰減係數。這意味着與高優先級任務相比,低優先級任務允許任務執行的時間消耗得更快。這是一個絕妙的解決方案,可以避免維護按優先級調度的運行隊列。

組調度

CFS 另一個有趣的地方是組調度概念(在2.6.24內核中引入)。組調度是另一種爲調度帶來公平性的方式,尤其是在處理產生很多其他任務的任務時。假設一個產生了很多任務的服務器要並行化進入的連接(HTTP 服務器的典型架構)。不是所有任務都會被統一公平對待,CFS引入了組來處理這種行爲。產生任務的服務器進程在整個組中(在一個層次結構中)共享它們的虛擬運行時,而單個任務維持其自己獨立的虛擬運行時。這樣單個任務會收到與組大致相同的調度時間。您會發現 /proc 接口用於管理進程層次結構,讓您對組的形成方式有完全的控制。使用此配置,您可以跨用戶、跨進程或其變體分配公平性。

調度類與域

與 CFS 一起引入的是調度類概念。每個任務都屬於一個調度類,這決定了任務將如何調度。 調度類定義一個通用函數集(通過sched_class),函數集定義調度器的行爲。例如,每個調度器提供一種方式,添加要調度的任務、調出要運行的下一個任務、提供給調度器等等。每個調度器類都在一對一連接的列表中彼此相連,使類可以迭代(例如,要啓用給定處理器的禁用)。一般結構如圖所示。注意,將任務函數加入隊列或脫離隊列只需從特定調度結構中加入或移除任務。 函數 pick_next_task選擇要執行的下一個任務(取決於調度類的具體策略)。

調度類圖形視圖

但是不要忘了調度類是任務結構本身的一部分(參見圖2)。這一點簡化了任務的操作,無論其調度類如何。例如, 以下函數用 ./kernel/sched.c 中的新任務搶佔當前運行任務(其中 curr 定義了當前運行任務, rq 代表 CFS 紅黑樹而 p 是下一個要調度的任務):

static inline void check_preempt( struct rq *rq, struct task_struct *p )
{
  rq->curr->sched_class->check_preempt_curr( rq, p );
}

調度類是調度發生變化的另一個有趣的地方,但是隨着調度域的增加,功能也在增加。這些域允許您出於負載平衡和隔離的目的將一個或多個處理器按層次關係分組。一個或多個處理器能夠共享調度策略(並在其之間保持負載平衡)或實現獨立的調度策略從而故意隔離任務。

一些問題討論:
新進程的vruntime的初始值設置

如果新進程的vruntime值爲0,那麼相對於老進程來說值小很多,會長時間佔用cpu,老進程會餓死,是不對的,CFS的做法的是:每個cpu的運行隊列cfs_rq都維護一個min_vruntime的字段,記錄該運行隊列中所有的進程的vruntime最小值,新進程的初始vruntime值就以它所在運行隊列的min_vruntime未基礎來設置,與老進程保持在合理的差距範圍。

新進程的vruntime初始值與兩個參數有關係:
sched_child_runs_first:規定fork之後讓子進程先於父進程運行。
sched_features的START_DEBIT位:規定新進程的第一次運行要有延遲。

休眠進程的vruntime一直保持不變?

如果休眠進程的 vruntime保持不變,而其他運行進程的vruntime一直在推進,那麼等到休眠進程終於喚醒的時候,它的vruntime比別人小很多,會使它獲得長時間搶佔CPU的優勢,其他進程就要餓死了。這顯然是另一種形式的不公平。CFS是這樣做的:在休眠進程被喚醒時重新設置vruntime值,以min_vruntime值爲基礎,給予一定的補償,但不能補償太多。

static void
place_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int initial)
{
    u64 vruntime = cfs_rq->min_vruntime;

    /*
     * The 'current' period is already promised to the current tasks,
     * however the extra weight of the new task will slow them down a
     * little, place the new task so that it fits in the slot that
     * stays open at the end.
     */
    if (initial && sched_feat(START_DEBIT))
        vruntime += sched_vslice(cfs_rq, se);

    /* sleeps up to a single latency don't count. */
    if (!initial) {
        unsigned long thresh = sysctl_sched_latency;

        /*
         * Halve their sleep time's effect, to allow
         * for a gentler effect of sleepers:
         */
        if (sched_feat(GENTLE_FAIR_SLEEPERS))
            thresh >>= 1;

        vruntime -= thresh;
    }

    /* ensure we never gain time by being placed backwards. */
    se->vruntime = max_vruntime(se->vruntime, vruntime);
}
休眠進程在喚醒時會立刻搶佔CPU嗎?

這是由CFS的喚醒搶佔 特性決定的,即sched_features的WAKEUP_PREEMPT位。
由於休眠進程在喚醒時會獲得vruntime的補償,所以它在醒來的時候有能力搶佔CPU是大概率事件,這也是CFS調度算法的本意,即保證交互式進程的響應速度,因爲交互式進程等待用戶輸入會頻繁休眠。除了交互式進程以外,主動休眠的進程同樣也會在喚醒時獲得補償,例如通過調用sleep()、nanosleep()的方式,定時醒來完成特定任務,這類進程往往並不要求快速響應,但是CFS不會把它們與交互式進程區分開來,它們同樣也會在每次喚醒時獲得vruntime補償,這有可能會導致其它更重要的應用進程被搶佔,有損整體性能。我曾經處理過的一個案例:服務器上有兩類應用進程,A進程定時循環檢查有沒有新任務,如果有的話就簡單預處理後通知B進程,然後調用nanosleep()主動休眠,醒來後再重複下一個循環;B進程負責數據運算,是CPU消耗型的;B進程的運行時間很長,而A進程每次運行時間都很短,但睡眠/喚醒卻十分頻繁,每次喚醒就會搶佔B,導致B的運行頻繁被打斷,大量的進程切換帶來很大的開銷,整體性能下降很厲害。那有什麼辦法嗎?有,CFS可以禁止喚醒搶佔 特性:

echo NO_WAKEUP_PREEMPT > /sys/kernel/debug/sched_features

禁用喚醒搶佔 特性之後,剛喚醒的進程不會立即搶佔運行中的進程,而是要等到運行進程用完時間片之後。在以上案例中,經過這樣的調整之後B進程被搶佔的頻率大大降低了,整體性能得到了改善。

如果禁止喚醒搶佔特性對你的系統來說太過激進的話,你還可以選擇調大以下參數:

sched_wakeup_granularity_ns
這個參數限定了一個喚醒進程要搶佔當前進程之前必須滿足的條件:只有當該喚醒進程的vruntime比當前進程的vruntime小、並且兩者差距(vdiff)大於sched_wakeup_granularity_ns的情況下,纔可以搶佔,否則不可以。這個參數越大,發生喚醒搶佔就越不容易。

進程佔用的CPU時間片可以無窮小嗎?

假設有兩個進程,它們的vruntime初值都是一樣的,第一個進程只要一運行,它的vruntime馬上就比第二個進程更大了,那麼它的CPU會立即被第二個進程搶佔嗎?答案是這樣的:爲了避免過於短暫的進程切換造成太大的消耗,CFS設定了進程佔用CPU的最小時間值,sched_min_granularity_ns,正在CPU上運行的進程如果不足這個時間是不可以被調離CPU的。
sched_min_granularity_ns發揮作用的另一個場景是,本文開門見山就講過,CFS把調度週期sched_latency按照進程的數量平分,給每個進程平均分配CPU時間片(當然要按照nice值加權,爲簡化起見不再強調),但是如果進程數量太多的話,就會造成CPU時間片太小,如果小於sched_min_granularity_ns的話就以sched_min_granularity_ns爲準;而調度週期也隨之不再遵守sched_latency_ns,而是以 (sched_min_granularity_ns * 進程數量) 的乘積爲準。

進程從一個CPU遷移到另一個CPU上的時候vruntime會不會變?

在多CPU的系統上,不同的CPU的負載不一樣,有的CPU更忙一些,而每個CPU都有自己的運行隊列,每個隊列中的進程的vruntime也走得有快有慢,比如我們對比每個運行隊列的min_vruntime值,都會有不同。
如果一個進程從min_vruntime更小的CPU(A)上遷移到min_vruntime更大的CPU(B)上,可能就會佔便宜了,因爲CPU(B)的運行隊列中進程的vruntime普遍比較大,遷移過來的進程就會獲得更多的CPU時間片。這顯然不太公平。

CFS是這樣做的:
當進程從一個CPU的運行隊列中出來(dequeue_entity)的時候,它的vruntime要減去隊列的min_vruntime值;而當進程加入另一個CPU的運行隊列(enqueue_entiry)時,它的vruntime要加上該隊列的min_vruntime值。進程從一個CPU遷移到另一個CPU之後,vruntime保持相對公平。

static void
dequeue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags)
{
    /*
     * Normalize the entity after updating the min_vruntime because the
     * update can refer to the ->curr item and we need to reflect this
     * movement in our normalized position.
     */
    if (!(flags & DEQUEUE_SLEEP))
        se->vruntime -= cfs_rq->min_vruntime;
}

static void
enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags)
{
    /*
     * Update the normalized vruntime before updating min_vruntime
     * through calling update_curr().
     */
    if (!(flags & ENQUEUE_WAKEUP) || (flags & ENQUEUE_WAKING))
        se->vruntime += cfs_rq->min_vruntime;
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章