Linux Kernel調度器的過去,現在和未來

引言

Linux Kernel Development 一書中,關於 Linux 的進程調度器並沒有講解的很全面,只是提到了 CFS 調度器的基本思想和一些實現細節;並沒有 Linux 早期的調度器介紹,以及最近這些年新增的在內核源碼樹外維護的調度器思想。所以在經過一番搜尋後,看到了這篇論文 A complete guide to Linux process scheduling,對 Linux 的調度器歷史進行了回顧,並且相對細緻地講解了 CFS 調度器。整體來說,雖然比較囉嗦,但是對於想要知道更多細節的我來說非常適合,所以就有了翻譯它的衝動。當然,在學習過程也參考了其它論文。下面開啓學習之旅吧,如有任何問題,歡迎指正~

需要注意的是,在 Linux 中,線程和進程都是由同一個結構體(task_struct,即任務描述符)表示的,所以文中會交叉使用進程、線程和任務等術語,可以將它們視作同義詞。當然,也可以將線程(任務)稱爲最小執行單元。但 Linux 的調度算法(如 CFS)可以應用更加通用的調度單元(如線程、cgroup、用戶等)。總之,不要過度糾結這裏的術語,重要的是瞭解每種調度算法的思想!

爲什麼需要調度

Linux 是一個多任務的操作系統,這就意味着它可以「同時」執行多個任務。在單核處理器上,任意時刻只能有一個進程可以執行(併發);而在多核處理器中,則允許任務並行執行。然而,不管是何種硬件類型的機器上,可能同時還有很多在內存中無法得到執行的進程,它們正在等待運行,或者正在睡眠。負責將 CPU 時間分配給進程的內核組件就是「進程調度器」。

調度器負責維護進程調度順序,選擇下一個待執行的任務。如同多數其它的現代操作系統,Linux 實現了搶佔式多任務機制。也就是說,調度器可以隨時決定任意進程停止運行,而讓其它進程獲得 CPU 資源。這種違背正在運行的進程意願,停止其運行的行爲就是所謂的「搶佔」。搶佔通常可以在定時器中斷時發生,當中斷髮生時,調度器會檢查是否需要切換任務,如果是,則會完成進程上下文切換。每個進程所獲得的運行時間叫做進程的時間片(timeslice)

任務通常可以區分爲交互式(I/O 密集型)非交互式(CPU 密集型)任務。交互式任務通常會重度依賴 I/O 操作(如 GUI 應用),並且通常用不完分配給它的時間片。而非交互式任務(如數學運算)則需要使用更多的 CPU 資源。它們通常會用完自己的時間片之後被搶佔,並不會被 I/O 請求頻繁阻塞。

當然,現實中的應用程序可能同時包含上述兩種分類任務。例如,文本編輯器,多數情況下,它會等待用戶輸入,但是在執行拼寫檢查時也會需要佔用大量 CPU 資源。

操作系統的調度策略就需要均衡這兩種類型的任務,並且保證每個任務都能得到足夠的執行資源,而不會對其它任務產生明顯的性能影響。 Linux 爲了保證 CPU 利用率最大化,同時又能保證更快的響應時間,傾向於爲非交互式任務分配更大的時間片,但是以較低的頻率運行它們;而針對 I/O 密集型任務,則會在較短週期內頻繁地執行。

調度有關的進程描述符

進程描述符(task_struct)中的很多字段會被調度機制直接使用。以下僅列出一些核心的部分,並在後文詳細討論。

struct task_struct {
    int prio, static_prio, normal_prio;
    unsigned int rt_priority;
    const struct sched_class *sched_class;
    struct sched_entity se;
    struct sched_rt_entity rt;
    …
    unsigned int policy;
    cpumask_t cpus_allowed;
    …
};

關於這些字段的說明如下:

  • prio 表示進程的優先級。進程運行時間,搶佔頻率都依賴於這些值。rt_priority 則用於實時(real-time)任務;

  • sched_class 表示進程位於哪個調度類;

  • sched_entity 的意義比較特殊。通常把一個線程(Linux 中的進程、任務同義詞)叫作最小調度單元。但是 Linux 調度器不僅僅只能夠調度單個任務,而且還可以將一組進程,甚至屬於某個用戶的所有進程作爲整體進行調度。這就允許我們實現組調度,從而將 CPU 時間先分配到進程組,再在組內分配到單個線程。當引入這項功能後,可以大幅度提升桌面系統的交互性。比如,可以將編譯任務聚集成一個組,然後進行調度,從而不會對交互性產生明顯的影響。這裏再次強調下,**Linux 調度器不僅僅能直接調度進程,也能對調度單元(schedulable entities)進行調度。這樣的調度單元正是用 struct sched_entity 來表示的。需要說明的是,它並非一個指針,而是直接嵌套在進程描述符中的。當然,後面的談論將聚焦在單進程調度這種簡單場景。由於調度器是面向調度單元設計的,所以它會將單個進程也視爲調度單元,因此會使用 sched_entity 結構體操作它們。sched_rt_entity 則是實時調度時使用的。

  • policy 表明任務的調度策略:通常意味着針對某些特定的進程組(如需要更長時間片,更高優先級等)應用特殊的調度決策。Linux 內核目前支持的調度策略如下:

    • SCHED_NORMAL:普通任務使用的調度策略;

    • SCHED_BATCH:不像普通任務那樣被頻繁搶佔,可允許任務運行儘可能長的時間,從而更好地利用緩存,但是代價自然是損失交互性能。這種非常適合批量任務調度(批量的 CPU 密集型任務);

    • SCHED_IDLE:它要比 nice 19 的任務優先級還要低,但它並非真的空閒任務;

    • SCHED_FIFO 和 SCHED_RR 是軟實時進程調度策略。它們是由 POSIX 標準定義的,由 <kernel/sched/rt.c> 裏面定義的實時調度器負責調度。RR 實現的是帶有固定時間片的輪轉調度方式;SCHED_FIFO 則使用的是先進先出的隊列機制。

  • cpus_allowed:用來表示任務的 CPU 親和性。用戶空間可以通過 sched_setaffinity 系統調用來設置。

優先級 Priority

進程優先級:

普通任務優先級:

所有的類 Unix 操作系統都實現了優先級調度機制。它的核心思想就是給任務設定一個值,然後通過該值決定任務的重要程度。如果任務的優先級一致,則一次重複運行它們。在 Linux 中,每一個普通任務都被賦予了一個 nice 值,它的範圍是 -20 到 +19,任務默認 nice 值是 0。

nice 值越高,任務優先級越低(it's nice to others)。Linux 中可以使用 nice(int increment) 系統調用來修改當前進程的優先級。該系統調用的實現位於 <kernel/shced/core.c> 中。默認情況下,用戶只能爲該用戶啓動的進程增加 nice 值(即降低優先級)。如果需要增加優先級(減少 nice 值),或者修改其它用戶進程優先級,則必須以 root 身份操作。

實時任務優先級:

在 Linux 中,除了普通任務外,還有一類任務屬於實時任務。實時任務是確保它們能夠在一定時間範圍內執行的任務,有兩類實時任務,列舉如下:

  • 硬實時任務:會有嚴格的時間限制,任務必須在時限內完成。比如直升機的飛控系統,就需要及時響應駕駛員的操控,並做出預期的動作。然而,Linux 本身並不支持硬實時任務,但是有一些基於它修改的版本,如 RTLinux(它們通常被稱爲 RTOS)則是支持硬實時調度的。

  • 軟實時任務:軟實時任務其實也會有時間限制,但不是那麼嚴格。也就是說,任務晚一點運行任務,並不會造成不可挽回的災難性事故。實踐中,軟實時任務會提供一定的時間限制保障,但是不要過度依賴這種特性。例如,VOIP 軟件會使用軟實時保障的協議傳來送音視頻信號,但是即便因爲操作系統負載過高,而產生一點延遲,也不會造成很大影響。無論如何,軟實時任務總會比普通任務的優先級更高。

Linux 中實時任務的優先級範圍是 0~99,但是有趣的是,它和 nice 值的作用剛好相反,這裏的優先級值越大,就意味着優先級越高。

類似其它的 Unix 系統,Linux 也是基於 POSIX 1b 標準定義的 「Real-time Extensions」實現實時優先級。可以通過如下的命令查看系統中的實時任務:

$ ps -eo pid, rtprio, cmd

也可通過 chrt -p pid 查看單個進程的詳情。Linux 中可以通過 chrt -p prio pid 更改實時任務優先級。這裏需要注意的是,如果操作的是一個系統進程(通常並不會將普通用戶的進程設置爲實時的),則必須有 root 權限纔可以修改實時優先級。

內核視角下的進程優先級:

實時上,內核看到的任務優先級和用戶看到的並不相同,在計算和管理優先級時也需要考慮很多方面。Linux 內核中使用 0~139 表示任務的優先級,並且,值越小,優先級越高(注意和用戶空間的區別)。其中 0~99 保留給實時進程,100~139(映射成 nice 值就是 -20~19)保留給普通進程。

我們可以在 <include/linux/sched/prio.h> 頭文件中看到內核表示進程優先級的單位(scale)和宏定義(macros),它們用來將用戶空間優先級映射到到內核空間。

#define MAX_NICE 19
#define MIN_NICE -20
#define NICE_WIDTH (MAX_NICE - MIN_NICE + 1)
…
#define MAX_USER_RT_PRIO 100
#define MAX_RT_PRIO MAX_USER_RT_PRIO
#define MAX_PRIO (MAX_RT_PRIO + NICE_WIDTH)
#define DEFAULT_PRIO (MAX_RT_PRIO + NICE_WIDTH / 2)
/*
* Convert user-nice values [ -20 ... 0 ... 19 ]
* to static priority [ MAX_RT_PRIO..MAX_PRIO-1 ],
* and back.
*/
#define NICE_TO_PRIO(nice) ((nice) + DEFAULT_PRIO)
#define PRIO_TO_NICE(prio) ((prio) - DEFAULT_PRIO)
/*
* 'User priority' is the nice value converted to something we
* can work with better when scaling various scheduler parameters,
* it's a [ 0 ... 39 ] range.
*/
#define USER_PRIO(p) ((p)-MAX_RT_PRIO)
#define TASK_USER_PRIO(p) USER_PRIO((p)->static_prio)
#define MAX_USER_PRIO (USER_PRIO(MAX_PRIO))

優先級計算:

在 task_struct 中有幾個字段用來表示進程優先級:

int prio, static_prio, normal_prio;
unsigned int rt_priority;

static_prio 是由用戶或系統設定的「靜態」優先級映射成內核表示的優先級:

p->static_prio = NICE_TO_PRIO(nice_value);

normal_prio 存放的是基於 static_prio 和進程調度策略(實時或普通)決定的優先級,相同的靜態優先級,在不同的調度策略下,得到的正常優先級是不同的。子進程在 fork 時,會繼承父進程的 normal_prio。 

prio 則是「動態優先級」,在某些場景下優先級會發生變動。一種場景就是,系統可以通過給某個任務優先級提升一段時間,從而搶佔其它高優先級任務,一旦 static_prio 確定,prio 字段就可以通過下面的方式計算: 

p->prio = effective_prio(p);
// kernel/sched/core.c 中定義了計算方法
static int effective_prio(struct task_struct *p)
{
    p->normal_prio = normal_prio(p);
    /*
    * If we are RT tasks or we were boosted to RT priority,
    * keep the priority unchanged. Otherwise, update priority
    * to the normal priority:
    */
    if (!rt_prio(p->prio))
        return p->normal_prio;
    return p->prio;
}


static inline int normal_prio(struct task_struct *p)
{
    int prio;
    if (task_has_dl_policy(p))
        prio = MAX_DL_PRIO-1;
    else if (task_has_rt_policy(p))
        prio = MAX_RT_PRIO-1 - p->rt_priority;
    else 
        prio = __normal_prio(p);
    return prio;
}


static inline int __normal_prio(struct task_struct *p)
{
    return p->static_prio;
}

負載權重(Load Weights):

優先級會讓一些任務比別的任務更重要,因此也會獲得更多的 CPU 使用時間。nice 值和時間片的比例關係是通過負載權重(Load Weights)進行維護的,我們可以在 task_struct->se.load 中看到進程的權重,定義如下:

struct sched_entity {
    struct load_weight load; /* for load-balancing */
    …
}
struct load_weight {
    unsigned long weight;
    u32 inv_weight;
};

爲了讓 nice 值的變化反映到 CPU 時間變化片上更加合理,Linux 內核中定義了一個數組,用於映射 nice 值到權重:

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,
};

來看看如何使用上面的映射表,假設有兩個優先級都是 0 的任務,每個都能獲得 50% 的 CPU 時間(1024 / (1024 + 1024) = 0.5)。如果突然給其中的一個任務優先級提升了 1 (nice 值 -1)。此時,一個任務應該會獲得額外 10% 左右的 CPU 時間,而另一個則會減少 10% CPU 時間。來看看計算結果:1277 / (1024 + 1277) ≈ 0.55,1024 / (1024 + 1277) ≈ 0.45,二者差距剛好在 10% 左右,符合預期。完整的計算函數定義在 <kernel/sched/core.c> 中: 

static void set_load_weight(struct task_struct *p)
{
    int prio = p->static_prio - MAX_RT_PRIO;
    struct load_weight *load = &p->se.load;
    /*
    * SCHED_IDLE tasks get minimal weight:
    */
    if (p->policy == SCHED_IDLE) {
        load->weight = scale_load(WEIGHT_IDLEPRIO);
        load->inv_weight = WMULT_IDLEPRIO;
        return;
    }
    load->weight = scale_load(prio_to_weight[prio]);
    load->inv_weight = prio_to_wmult[prio];
}

調度類 Scheduling Classes

雖說 Linux 內核使用的 C 語言並非所謂的 OOP 語言(沒有類似 C++/Java 中的 class 概念),但是我們可以在內核代碼中看到一些使用 C 語言結構體 + 函數指針(Hooks)的方式來模擬面向對象的方式,抽象行爲和數據。調度類也是這樣實現的(此外,還有 inode_operations, super_block_operations 等),它的定義如下(位於 <kernel/shced/sched.h>): 

// 爲了簡單起見,隱藏了部分代碼(如 SMP 相關的)
struct sched_class {
    // 多個 sched_class 是鏈接在一起的
    const struct sched_class *next;
    // 該 hook 會在任務進入可運行狀態時調用。它會將調度單元(如一個任務)放到
    // 隊列中,同時遞增 `nr_running` 變量(該變量表示運行隊列中可運行的任務數)
    void (*enqueue_task) (struct rq *rq, struct task_struct *p, int flags);
    // 該 hook 會在任務不可運行時調用。它會將任務移出隊列,同時遞減 `nr_running`
    void (*dequeue_task) (struct rq *rq, struct task_struct *p, int flags);
    // 該 hook 可以在任務需要主動放棄 CPU 時調用,但是需要注意的是,它不會改變
    // 任務的可運行狀態,也就是說依然會在隊列中等待下次調度。類似於先 dequeue_task,
    // 再 enqueue_task
    void (*yield_task) (struct rq *rq);
    // 該 hook 會在任務進入可運行狀態時調用並檢查是否需要搶佔當前任務
    void (*check_preempt_curr) (struct rq *rq, struct task_struct *p, int flags);
    // 該 hook 用來選擇最適合運行的下一個任務
    struct task_struct * (*pick_next_task) (struct rq *rq, struct task_struct *prev);
    // 該 hook 會在任務修改自身的調度類或者任務組時調用
    void (*set_curr_task) (struct rq *rq);
    // 通常是在時鐘中斷時調用,可能會導致任務切換
    void (*task_tick) (struct rq *rq, struct task_struct *p, int queued);
    // 當任務被 fork 時通知調度器
    void (*task_fork) (struct task_struct *p);
    // 當任務掛掉時通知調度器
    void (*task_dead) (struct task_struct *p);
};

關於調度策略的具體細節的實現有如下幾個模塊:

  • core.c 包含調度器的核心部分;

  • fair.c 實現了 CFS(Comple Faire Scheduler,完全公平任務調度器) 調度器,應用於普通任務;

  • rt.c 實現了實時調度,應用於實時任務;

  • idle_task.c 當沒有其它可運行的任務時,會運行空閒任務。
    內核是基於任務的調度策略(SCHED_*)來決定使用何種調度類實現,並會調用相應的方法。
    SCHED_NORMALSCHED_BATCH 和 SCHED_IDLE 進程會映射到 fair_sched_class (由 CFS 實現);SCHED_RR 和 SCHED_FIFO 則映射的 rt_sched_class (實時調度器)。

運行隊列 runqueue

所有可運行的任務是放在運行隊列中的,並且等待 CPU 運行。每個 CPU 核心都有自己的運行隊列,每個任務任意時刻只能處於其中一個隊列中。在多處理器機器中,會有負載均衡策略,任務就會轉移到其它 CPU 上運行的可能。


運行隊列數據結構定義如下(位於 <kernel/sched/sched.h>):

// 爲了簡單起見,隱藏了部分代碼(SMP 相關)
// 這個是每個 CPU 都會有的一個任務運行隊列
struct rq
{
    // 表示當前隊列中總共有多少個可運行的任務(包含所有的 sched class)
    unsigned int nr_running;
#define CPU_LOAD_IDX_MAX 5
    unsigned long cpu_load[CPU_LOAD_IDX_MAX];
    // 運行隊列負載記錄
    struct load_weight load;
    // 嵌套的 CFS 調度器運行隊列
    struct cfs_rq cfs;
    // 嵌套的實時任務調度器運行隊列
    struct rt_rq rt;
    // curr 指向當前正在運行的進程描述符
    // idle 則指向空閒進程描述符(當沒有其它可運行任務時,該任務纔會啓動)
    struct task_struct *curr, *idle;
    u64 clock;
    int cpu;
}

何時運行調度器?

實時上,調度函數 schedule() 會在很多場景下被調用。有的是直接調用,有的則是隱式調用(通過設置 TIF_NEED_RESCHED 來提示操作系統儘快運行調度函數)。以下三個調度時機值得關注下:

  • 時鐘中斷髮生時,會調用 scheduler_tick() 函數,該函數會更新一些和調度有關的數據統計,並觸發調度類的週期調度方法,從而間接地進行調度。以 2.6.39 源碼爲例,可能的調用鏈路如下:

scheduler_tick
└── task_tick
    └── entity_tick
        └── check_preempt_tick
            └── resched_task
                └── set_tsk_need_resched
  • 當前正在運行的任務進入睡眠狀態。在這種情況下,任務會主動釋放 CPU。通常情況下,該任務會因爲等待指定的事件而睡眠,它可以將自己添加到等待隊列,並啓動循環檢查期望的條件是否滿足。在進入睡眠前,任務可以將自己的狀態設置爲 TASK_INTERRUPTABLE(除了任務要等待的事件可喚醒外,也可以被信號喚醒)或者 TASK_UNINTERRUPTABLE(自然是不會理會信號咯),然後調用 schedule() 選擇下一個任務運行。

Linux 調度器

早期版本:

Linux 0.0.1 版本就已經有了一個簡單的調度器,當然並非適合擁有特別多處理器的系統。該調度器只維護了一個全局的進程隊列,每次都需要遍歷該隊列來尋找新的進程執行,而且對任務數量還有嚴格限制(NR_TASKS 在最初的版本中只有 32)。下面來看看這個調度器是如何實現的吧: 

// 'schedule()' is the scheduler function. 
// This is GOOD CODE! There probably won't be any reason to change 
// this, as it should work well in all circumstances (ie gives 
// IO-bound processes good response etc)...
void schedule(void)
{
    int i, next, c;
    struct task_struct **p;
    // 遍歷所有任務,如果有信號,則需要喚醒 `TASK_INTERRUPTABLE` 的任務
    for (p = &LAST_TASK; p > &FIRST_TASK; --p)
        if (*p) {
            if ((*p)->alarm && (*p)->alarm < jiffies) {
                (*p)->signal |= (1 << (SIGALRM - 1));
                (*p)->alarm = 0;
            }
            if ((*p)->signal && (*p)->state == TASK_INTERRUPTIBLE)
                (*p)->state = TASK_RUNNING;
        }
    while (1)
    {
        c = -1;
        next = 0;
        i = NR_TASKS;
        p = &task[NR_TASKS];
        // 遍歷所有任務,找到時間片最長的那個
        while (--i) {
            if (!*--p)
                continue;
            if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
                c = (*p)->counter, next = i;
        }
        if (c)
            break;
        // 遍歷任務,重新設值時間片
        for (p = &LAST_TASK; p > &FIRST_TASK; --p)
            if (*p)
                (*p)->counter = ((*p)->counter >> 1) + (*p)->priority;
    }
    // 切換到下一個需要執行的任務
    switch_to(next);
}

O(n):

2.4 版本的 Linux 內核使用的調度算法非常簡單和直接,由於每次在尋找下一個任務時需要遍歷系統中所有的任務(鏈表),因此被稱爲 O(n) 調度器(時間複雜度)。

當然,該調度器要比 0.01 版本內核中的調度算法稍微複雜點,它引入了 epoch 概念。也就是將時間分成紀元(epochs),也就是每個進程的生命週期。理論上來說,每個紀元結束,每個進程都應該運行過一次了,而且通常用光了它當前的時間片。但實際上,有些任務並沒有完全用完時間片,那麼它剩餘時間片的一半將會和新的時間片相加,從而在下一個紀元運行更長的時間。

我們來看下 schedule() 算法的核心源碼:

// schedule() 算法會遍歷所有的任務(O(N)),並且計算出每個任務的
// goodness 值,且挑選出「最好」的任務來運行。
// 以下是部分核心源碼,主要是瞭解下它的思路。
asmlinkage void schedule(void)
{
    // 任務(進程)描述符:
    // 1. prev: 當前正在運行的任務
    // 2. next: 下一個將運行的任務
    // 3. p: 當前正在遍歷的任務
    struct task_struct *prev, *next, *p;
    int this_cpu, c; // c 表示權重值
repeat_schedule:
    // 默認選中的任務
    next = idle_task(this_cpu);
    c = -1000;
    list_for_each(tmp, &runqueue_head) {
        p = list_entry(tmp, struct task_struct, run_list);
        if (can_schedule(p, this_cpu)) {
            int weight = goodness(p, this_cpu, prev->active_mm);
            if (weight > c)
                c = weight, next = p;
        }
    }
}

源碼中的 goodness() 函數會計算出一個權重值,它的算法基本思想就是基於進程所剩餘的時鐘節拍數(時間片),再加上基於進程優先級的權重值。返回值如下:

  • -1000 表示不要選擇該進程運行

  • 0 表示時間片用完了,需要重新計算 counters(可能會被選中運行)

  • 正整數:表示 goodness 值(越大越好)

  • +1000 表示實時進程,接下來就要選擇它運行

最後,針對 O(n) 調度器做下總結:

  1. 算法實現非常簡單,但是不高效(任務越多,遍歷耗費時間越久)

  2. 沒有很好的擴展性,多核處理器怎麼辦?

  3. 對於實時任務調度支持較弱(無論如何作爲優先級高的實時任務都需要在遍歷完列表後纔可以知道)

O(1):

Ingo Molnár 大佬在 2.6 版本的內核中加入了全新的調度算法,它能夠在常數時間內調度任務,因此被稱爲 O(1) 調度器。我們來看看它引入的一些新特性:

  • 全局優先級單位,範圍是 0~139,數值越低,優先級越高

  • 將任務拆分成實時(099)和正常(100139)兩部分。更高優先級任務獲得更多時間片

  • 即刻搶佔(early preemption)。當任務狀態變成 TASK_RUNNING 時,內核會檢查其優先級是否比當前運行的任務優先級更高,如果是的話,則搶佔當前正在運行的任務,切換到該任務

  • 實時任務使用靜態優先級

  • 普通任務使用使用動態優先級。任務優先級會在其使用完自己的時間片後重新計算,內核會考慮它過去的行爲,決定它的交互性等級。交互型任務更容易得到調度


O(n) 的調度器會在每個紀元結束後(所有任務的時間片都使用過),纔會重新計算任務優先級。而 O(1) 則是在每個任務時間片配額用完後就重新計算優先級。O(1) 調度器爲每個 CPU 維護了兩個隊列,即 active 和 expired。active 隊列存放的是時間片尚未用完的任務,而 expired 則是時間片已經耗盡的任務。當一個任務的時間片用完後,就會被轉到 expired 隊列,而且會重新計算它的優先級。當 active 隊列任務全部轉移到 expired 隊列後,會交換二者(讓 active 指向 expired 隊列,expired 指向 active 隊列)。可以看到,優先級的計算,隊列切換都和任務數量多寡無關,能夠在 O(1) 時間複雜度下完成。

在先前介紹的調度算法中,如果想要取一個優先級最高的任務,還需要遍歷整個任務鏈表纔可以。而 O(1) 調度器則很特別,它爲每種優先級提供了一個任務鏈表。所有的可運行任務會被分散在不同優先級隊應的鏈表中。

接下來看看全新的 runqueue 是怎麼定義的吧:

struct runqueue {
    unsigned long nr_running; /* 可運行的任務總數(某個 CPU) */
    struct prio_array *active; /* 指向 active 的隊列的指針 */
    struct prio_array *expired; /* 指向 expired 的隊列的指針 */
    struct prio_array arrays[2]; /* 實際存放不同優先級對應的任務鏈表 */
}

通過下面的圖可以直觀感受下任務隊列:

接下來看看 prio_array 是怎麼定義的:

struct prio_array {
    int nr_active; /* 列表中的任務總數 */
    unsigned long bitmap[BITMAP_SIZE]; /* 位圖表示對應優先級鏈表是否有任務存在 */
    struct list_head queue[MAX_PRIO]; /* 任務隊列(每種優先級對應一個雙向鏈表) */
};

可以看到,在 prio_array 中存在一個位圖,它是用來標記每個 priority 對應的任務鏈表是否存在任務的。接下來看看爲何 O(1) 調度器可以在常數時間找到需要運行的任務:

  • 常數時間確定優先級:首先會在位圖中查找到第一個設置爲 1 的位(總共有 140 bits,從第一個 bit 開始搜索,這樣可以保證高優先級的任務先得到機會運行),如果找到了就可以確定哪個優先級有任務,假設找到後的值爲 priority

  • 常數時間獲得下一個任務:在 queue[priority] 對應的任務鏈表中提取第一個任務來執行(多個任務會輪轉執行)。

好了,是時候總結下 O(1) 調度器的優缺點了:

  1. 設計上要比 O(n) 調度器更加複雜精妙;

  2. 相對來說擴展性更好,性能更優,在任務切換上的開銷更小;

  3. 用來標記任務是否爲交互類型的算法還是過於複雜,且容易出錯。

CFS:

單核調度:

CFS 的全稱是 Complete Fair Scheduler,也就是完全公平調度器。它實現了一個基於權重的公平隊列算法,從而將 CPU 時間分配給多個任務(每個任務的權重和它的 nice 值有關,nice 值越低,權重值越高)。每個任務都有一個關聯的虛擬運行時間 vruntime,它表示一個任務所使用的 CPU 時間除以其優先級得到的值。相同優先級和相同 vruntime 的兩個任務實際運行的時間也是相同的,這就意味着 CPU 資源是由它們均分了。爲了保證所有任務能夠公平推進,每當需要搶佔當前任務時,CFS 總會挑選出 vruntime 最小的那個任務運行。

內核版本在 2.6.38 之前,每個線程(任務)會被當成獨立的調度單元,並且和系統中其它線程共享資源,這就意味着一個多線程的應用會比單線程的應用獲得更多的資源。之後,CFS 不斷改進,目前已經支持將一個應用中的線程打包到 cgroup 結構中,cgroup 的 vruntime 是其中所有線程的 vuntime 之和。然後 CFS 就可以將它的算法應用於cgroup 之間,從而保證公平性。當某個 cgroup 被選中後,其中擁有最小 vruntime 的線程會被執行,從而保證 cgroup 中的線程之間的公平性。cgroup 還可以嵌套,例如 systemd 會自動配置 cgroup 來保證不同用戶之間的公平性,然後在用戶運行的多個應用之間維持公平性。 

CFS 通過在一定時間內運行調度所有的線程來避免飢餓問題。當運行的 線程數在 8 個及以下時,默認的時間週期是 48ms;而當多於 8 個線程時,時間週期就會隨着線程數量而增加(6ms * 線程數,之所以選擇 6ms,是爲了避免頻繁搶佔,導致上下文切換頻繁切換的開銷)。由於 CFS 總是會挑選 vruntime 最小的線程執行,它就需要避免某個線程的 vruntime 太小,以至於其它線程需要等待很久才能得到調度(會有飢餓問題)。所以在實踐中,CFS 會保證所有線程之間的 vruntime 之差低於搶佔時間(6ms),它是通過如下兩點來保證的:

  1. 當線程創建時,它的 vruntime 值等於運行隊列中等待執行線程的最大 vruntime;

  2. 當線程從睡眠中喚醒時,它的 vruntime 值會被更新爲大於或等於所有待調度線程中最小的 vruntime。使用最小 vruntime 還可以保證頻繁睡眠的線程優先被調度,這對於桌面系統非常適合,它會減少交互應用的響應延遲。

CFS 還引入了啓發式調度思想來改善高速緩存利用率。例如,當線程被喚醒時,它會檢查該線程的 vruntime 和正在運行的線程 vruntime 之差是否非常顯著(臨界值是 1ms),如果不是的話,則不會搶佔當前正在運行的任務。但是這種做法還是以犧牲調度延遲爲代價的,算是一種權衡吧。 

多核負載均衡:

在多核環境中,Linux CFS 會將工作(work)分攤到多個處理器核心中執行。但是這不等同於將線程均分到多個處理器。比如,一個 CPU 密集型的線程和 10 個頻繁睡眠的線程可能分別在兩個核上執行,其中一個專門執行 CPU 密集型線程;而另一個則處理那 10 個頻繁睡眠的線程。 

爲了多個處理器上的工作量均衡,CFS 使用了 load 指標來衡量線程和處理器的負載情況。線程的負載和線程的 CPU 平均使用率相關:經常睡眠的線程負載要低於不睡眠的線程負載。類似 vruntime,線程的負載也是線程的優先級加權得到的。而處理器的負載是在該處理器上可運行線程的負載之和。CFS 會嘗試均衡處理器的負載。 

CFS 會在線程創建和喚醒時關注處理器的負載情況,調度器首先要決定將任務放在哪個處理器的運行隊列中。這裏也會涉及到啓發式思想,比如,如果 CFS 檢查到生產者-消費者模型,那麼它會將消費者線程儘可能地分散到機器的多個處理器上,因爲多數核心都適合處理喚醒的線程。

負載均衡還會週期性發生,每隔 4ms,每個處理器都會嘗試從其它處理器偷取一些工作。當然,這種 work-stealing 均衡方法還會考慮機器的拓撲結構:處理器會嘗試從距離它們「更近」的其它處理器上嘗試竊取工作,而非距離「更遠」的處理器(如遠程 NUMA 節點)。當處理器決定要從其它處理器竊取任務時,它會嘗試在二者之間均衡負載,並且會竊取多達 32 個線程。此外,當處理器進入空閒狀態時,它也會立刻調用負載均衡器。

在大型的 NUMA 機器上,CFS 並不會粗暴地比較所有 CPU 的負載,而是以分層的方式進行負載均衡。以一臺有兩個 NUMA 節點的機器爲例,CFS 會先在 NUMA 節點內部的處理器之間進行負載均衡,然後比較 NUMA 節點之間的負載(通過節點內部處理器負載計算得到),再決定要不要在兩個節點之間進行負載均衡。如果 NUMA 節點之間的負載差距在 25% 以內,則不會進行負載均衡。總結來說,如果兩個處理器(或處理器組)之間的距離越遠,那麼只有在不平衡性差距越大的情況下才會考慮負載均衡。


運行隊列:

CFS 引入了紅黑樹(本質上是一棵半平衡二叉樹,對於插入和查找都有 O(log(N)) 的時間複雜度)來維護運行隊列,樹的節點值是調度單元的 vruntime,擁有最小 vruntime 的節點位於樹的最左下邊。 

接下來看看 cfs_rq 數據結構的定義(位於 <kernel/sched/sched.h>):

struct cfs_rq
{
    // 所有任務的累計權重值
    struct load_weight load;
    // 表示該隊列中有多少個可運行的任務
    unsigned int nr_running;
    // 運行隊列中最小的 vruntime
    u64 min_vruntime;
    // 紅黑樹的根節點,指向運行任務隊列
    struct rb_root tasks_timeline;
    // 下一個即將被調度的任務
    struct rb_node *rb_leftmost;
    // 指向當前正在運行的調度單元
    struct sched_entity *curr;
}

CFS 算法實際應用於調度單元(這是一個更通用的抽象,可以是線程、cgroups 等),調度單元數據結構定義如下(位於 <include/linux/sched.h>):

struct sched_entity
{
    // 表示調度單元的負載權重(比如該調度單元是一個組,則該值就是該組下所有線程的負載權重的組合)
    struct load_weight load; /* for load-balancing */
    // 表示紅黑樹的節點
    struct rb_node run_node;
    // 表示當前調度單元是否位於運行隊列
    unsigned int on_rq;
    // 開始執行時間
    u64 exec_start;
    // 總共運行的時間,該值是通過 `update_curr()` 更新的。
    u64 sum_exec_runtime;
    // 基於虛擬時鐘計算出該調度單元已運行的時間
    u64 vruntime;


    // 用於記錄之前運行的時間之和
    u64 prev_sum_exec_runtime;
};

虛擬時鐘:

前面提到的 vruntime 究竟是什麼呢?爲什麼叫作虛擬運行時間呢?接下來就要揭開它的神祕面紗。爲了更好地實現公平性,CFS 使用了虛擬時鐘來測量一個等待的調度單元在一個完全公平的處理器上允許執行的時間。然而,虛擬時鐘並沒有真實的實現,它只是一個抽象概念。

我們可以基於真實時間和任務的負載權重來計算出虛擬運行時間,該算法是在 update_cur() 函數中實現的,它會更新調度單元的時間記賬信息,以及 CFS 運行隊列的 min_vruntime(完整定義位於 <kernel/sched/fair.c>):

static void update_curr(struct cfs_rq *cfs_rq)
{
    struct sched_entity *curr = cfs_rq->curr;
    u64 now = rq_clock_task(rq_of(cfs_rq));
    u64 delta_exec;
    if (unlikely(!curr))
        return;
    // 計算出調度單元開始執行時間和當前之間的差值,即真實運行時間
    delta_exec = now - curr->exec_start;
    curr->vruntime += calc_delta_fair(delta_exec, curr);
    update_min_vruntime(cfs_rq);
}


static inline u64 calc_delta_fair(u64 delta, struct sched_entity *se)
{
    // 如果任務的優先級是默認的優先級(內部 nice 值是 120),那麼虛擬運行時間
    // 就是真實運行時間。否則,會基於 `__calc_delta` 計算出虛擬運行時間。
    if (unlikely(se->load.weight != NICE_0_LOAD))
        // 該計算過程基本等同於:
        // delta = delta_exec * NICE_0_LOAD / cur->load.weight;
        delta = __calc_delta(delta, NICE_0_LOAD, &se->load);
    return delta;
}


static void update_min_vruntime(struct cfs_rq *cfs_rq)
{
    u64 vruntime = cfs_rq->min_vruntime;
    if (cfs_rq->curr)
        // 如果此時有任務在運行,就更新最小運行時間爲當前任務的 vruntime
        vruntime = cfs_rq->curr->vruntime;
    if (cfs_rq->rb_leftmost)
    {
        // 獲得下一個要運行的調度單元
        struct sched_entity *se = rb_entry(cfs_rq->rb_leftmost,
                                           struct sched_entity,
                                           run_node);
        if (!cfs_rq->curr)
            vruntime = se->vruntime;
        else
            // 保證 min_vruntime 是二者之間較小的那個值
            vruntime = min_vruntime(vruntime, se->vruntime);
    }


    // 這裏之所以去二者之間的最大值,是爲了保證 min_vruntime 能夠單調增長
    // 可以想想爲什麼需要這樣做?
    cfs_rq->min_vruntime = max_vruntime(cfs_rq->min_vruntime, vruntime);
}

最後,來總結下使用虛擬時鐘的意義:

  • 當任務運行時,它的虛擬時間總是會增加,從而保證它會被移動到紅黑樹的右側;

  • 對於高優先級的任務,虛擬時鐘的節拍更慢,從而讓它移動到紅黑樹右側的速度就越慢,因此它們被再次調度的機會就更大些。

選擇下一個任務:

CFS 可以在紅黑樹中一直找到最左(leftmost)邊的節點作爲下一個運行的任務。但是真正實現 __pick_first_entity() 的函數其實並沒有真正地執行查找(雖然可以在 O(log(N)) 時間內找到),我們可以看下它的定義(完整定義位於 <kernel/sched/fair.c>): 

struct sched_entity *__pick_first_entity(struct cfs_rq *cfs_rq)
{
    // 其實這裏取的是緩存的 leftmost 節點
    // 所以執行就會更快了
    struct rb_node *left = cfs_rq->rb_leftmost;
    if (!left)
        return NULL;
    return rb_entry(left, struct sched_entity, run_node);
}

實時調度器:

Linux 實時任務調度器實現位於 <kernel/sched/rt.c,對於系統而言,實時任務屬於貴客,一旦存在實時任務需要調度,那就應當儘可能及時地爲它們服務。對於實時任務而言,有兩種調度策略存在: 

  • SCHED_FIFO: 這個其實就是一個先到先服務的調度算法。這類任務沒有時間片限制,它們會一直運行直到阻塞或者主動放棄 CPU,亦或者被更高優先級的實時任務搶佔。該類任務總會搶佔 SCHED_NORMAL 任務。如果多個任務具有相同的優先級,那它們會以輪詢的方式調度(也就是當一個任務完成後,會被放到隊列尾部等待下次執行);

  • SCHED_RR: 這種策略類似於 SCHED_FIFO,只是多了時間片限制。相同優先級的任務會以輪詢的方式被調度,每個運行的任務都會一直運行,直到其用光自己的時間片,或者被更高優先級的任務搶佔。當任務的時間片用光後,它會重新補充能量,並被加入到隊列尾部。默認的時間片是 100ms,可以在 <include/linux/sched/rt.h> 找到其定義。

實時任務的優先級是靜態的,不會像之前提到的算法,會重新計算任務優先級。用戶可以通過 chrt 命令更改任務優先級。

實現細節:

實時任務有自己的調度單元數據結構(位於 <include/linux/sched.h>),其定義如下:

struct sched_rt_entity
{
    struct list_head run_list;
    unsigned long timeout;
    unsigned long watchdog_stamp;
    unsigned int time_slice;
    struct sched_rt_entity *back;
    struct sched_rt_entity *parent;
    /* rq on which this entity is (to be) queued: */
    struct rt_rq *rt_rq;
};

SCHED_FIFO 的時間片是 0,可以在 <kernel/sched/rt.c> 中看到具體定義:

int sched_rr_timeslice = RR_TIMESLICE;
static unsigned int get_rr_interval_rt(struct rq *rq,
                                       struct task_struct *task)
{
    if (task->policy == SCHED_RR)
        return sched_rr_timeslice;
    else
        return 0;
}

而關於運行隊列的定義如下:

/* Real-Time classes' related field in a runqueue: */
struct rt_rq
{
    // 所有相同優先級的實時任務都保存在 `active.queue[prio]` 鏈表中
    struct rt_prio_array active;
    unsigned int rt_nr_running;
    struct rq *rq; /* main runqueue */
};


/*
* This is the priority-queue data structure of the RT scheduling class:
*/
struct rt_prio_array
{
    /* include 1 bit for delimiter */
    // 類似 O(1) 調度器,使用位圖來標記對應優先級的鏈表是否爲空
    DECLARE_BITMAP(bitmap, MAX_RT_PRIO + 1);
    struct list_head queue[MAX_RT_PRIO];
};

類似於 CFS 中的 update_curr() 函數,update_curr_rt() 函數用來跟蹤實時任務的 CPU 佔用情況,收集一些統計信息,更新時間片等,但這裏使用的是真實時間,而沒有虛擬時間的概念。完整定義可以參考 kernel/sched/rt.c。

BFS & MuqSS調度器:

總體來說,BFS 是一個適用於桌面或移動設備的調度器,設計地比較簡潔,用於改善桌面應用的交互性,減小響應時間,提升用戶體驗。它採用了全局單任務隊列設計,不再讓每個 CPU 都有獨立的運行隊列。雖然使用單個全局隊列,需要引入隊列鎖來保證併發安全性,但是對於桌面系統而言,處理器通常都比較少,鎖的開銷基本可以忽略。BFS 每次會在任務鏈表中選擇具有最小 virtual deadline 的任務運行。

MuqSS 是作者後來基於 BFS 改進的一款調度器,同樣是用於桌面環境任務調度。它主要解決了 BFS 的兩個問題:

  1. 每次需要在對應優先級鏈表中遍歷查找需要執行任務,這個時間複雜度爲 O(n)。所以新的調度器引入了跳錶來解決該問題,從而將時間複雜度降低到 O(1)。

  2. 全局鎖爭奪的開銷優化,採用 try_lock 替代 lock。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章