1. 程式人生 > >Linux CFS排程器之虛擬時鐘vruntime與排程延遲--Linux程序的管理與排程(二十六)

Linux CFS排程器之虛擬時鐘vruntime與排程延遲--Linux程序的管理與排程(二十六)

CFS負責處理普通非實時程序, 這類程序是我們linux中最普遍的程序, 今天我們把注意力轉向CFS的虛擬時鐘

1 前景回顧

1.1 CFS排程器類

Linux核心使用CFS是來排程我們最常見的普通程序, 其所屬排程器類為fair_sched_class, 使用的排程策略包括SCHED_NORMAL和SCHED_BATCH, 程序task_struct中struct sched_entity se;欄位標識的就是CFS排程器類的排程實體.

1.2 程序的優先順序

前面我們詳細的瞭解了linux下程序優先順序的表示以及其計算的方法, 我們瞭解到linux針對普通程序和實時程序分別使用靜態優先順序static_prio和實時優先順序rt_priority來指定其預設的優先級別, 然後通過normal_prio函式將他們分別轉換為普通優先順序normal_prio, 最終換算出動態優先順序prio, 動態優先順序prio才是核心排程時候有限考慮的優先順序欄位

1.3 CFS排程的普通程序的負荷權重

但是CFS完全公平排程器在排程程序的時候, 程序的重要性不僅是由優先順序指定的, 而且還需要考慮儲存在task_struct->se.load的負荷權重.

1.4 CFS演算法的基本思想

簡單說一下CFS排程演算法的思想:理想狀態下每個程序都能獲得相同的時間片,並且同時執行在CPU上,但實際上一個CPU同一時刻執行的程序只能有一個。 也就是說,當一個程序佔用CPU時,其他程序就必須等待。

2 虛擬執行時間(今日內容提醒)

2.1 虛擬執行時間的引入

CFS為了實現公平,必須懲罰當前正在執行的程序,以使那些正在等待的程序下次被排程。

具體實現時,CFS通過每個程序的虛擬執行時間(vruntime)來衡量哪個程序最值得被排程。

CFS中的就緒佇列是一棵以vruntime為鍵值的紅黑樹,虛擬時間越小的程序越靠近整個紅黑樹的最左端。因此,排程器每次選擇位於紅黑樹最左端的那個程序,該程序的vruntime最小

虛擬執行時間是通過程序的實際執行時間和程序的權重(weight)計算出來的。

在CFS排程器中,將程序優先順序這個概念弱化,而是強調程序的權重。一個程序的權重越大,則說明這個程序更需要執行,因此它的虛擬執行時間就越小,這樣被排程的機會就越大。
那麼,在使用者態程序的優先順序nice值與CFS排程器中的權重又有什麼關係?在核心中通過prio_to_weight陣列進行nice值和權重的轉換。

2.2 CFS虛擬時鐘

完全公平排程演算法CFS依賴於虛擬時鐘, 用以度量等待程序在完全公平系統中所能得到的CPU時間. 但是資料結構中任何地方都沒有找到虛擬時鐘. 這個是由於所有的必要資訊都可以根據現存的實際時鐘和每個程序相關的負荷權重推算出來.

假設現在系統有A,B,C三個程序,A.weight=1,B.weight=2,C.weight=3.那麼我們可以計算出整個公平排程佇列的總權重是cfs_rq.weight = 6,很自然的想法就是,公平就是你在重量中佔的比重的多少來拍你的重要性,那麼,A的重要性就是1/6,同理,B和C的重要性分別是2/6,3/6.很顯然C最重要就應改被先排程,而且佔用的資源也應該最多,即假設A,B,C執行一遍的總時間假設是6個時間單位的話,A佔1個單位,B佔2個單位,C佔三個單位。這就是CFS的公平策略.

CFS排程演算法的思想:理想狀態下每個程序都能獲得相同的時間片,並且同時執行在CPU上,但實際上一個CPU同一時刻執行的程序只能有一個。也就是說,當一個程序佔用CPU時,其他程序就必須等待。CFS為了實現公平,必須懲罰當前正在執行的程序,以使那些正在等待的程序下次被排程.

具體實現時,CFS通過每個程序的虛擬執行時間(vruntime)來衡量哪個程序最值得被排程. CFS中的就緒佇列是一棵以vruntime為鍵值的紅黑樹,虛擬時間越小的程序越靠近整個紅黑樹的最左端。因此,排程器每次選擇位於紅黑樹最左端的那個程序,該程序的vruntime最小.

虛擬執行時間是通過程序的實際執行時間和程序的權重(weight)計算出來的。在CFS排程器中,將程序優先順序這個概念弱化,而是強調程序的權重。一個程序的權重越大,則說明這個程序更需要執行,因此它的虛擬執行時間就越小,這樣被排程的機會就越大。而,CFS排程器中的權重在核心是對使用者態程序的優先順序nice值, 通過prio_to_weight陣列進行nice值和權重的轉換而計算出來的

3 虛擬時鐘相關的資料結構

3.1 排程實體的虛擬時鐘資訊

為了實現完全公平排程,核心引入了虛擬時鐘(virtual clock)的概念,實際上我覺得這個虛擬時鐘為什叫虛擬的,是因為這個時鐘與具體的時鐘晶振沒有關係,他只不過是為了公平分配CPU時間而提出的一種時間量度,它與程序的權重有關,這裡就知道權重的作用了,權重越高,說明程序的優先順序比較高,進而該程序虛擬時鐘增長的就慢

既然虛擬時鐘是用來衡量排程實體(一個或者多個程序)的一種時間度量, 因此必須在排程實體中儲存其虛擬時鐘的資訊

struct sched_entity
{
    struct load_weight      load;           /* for load-balancing負荷權重,這個決定了程序在CPU上的執行時間和被排程次數 */
    struct rb_node          run_node;
    unsigned int            on_rq;          /*  是否在就緒佇列上  */

    u64                     exec_start;         /*  上次啟動的時間*/

    u64                     sum_exec_runtime;
    u64                     vruntime;
    u64                     prev_sum_exec_runtime;
    /* rq on which this entity is (to be) queued: */
    struct cfs_rq           *cfs_rq;
    ...
};

sum_exec_runtime是用於記錄該程序的CPU消耗時間,這個是真實的CPU消耗時間。在程序撤銷時會將sum_exec_runtime儲存到prev_sum_exec_runtime

vruntime是本程序生命週期中在CPU上執行的虛擬時鐘。那麼何時應該更新這些時間呢?這是通過呼叫update_curr實現的, 該函式在多處呼叫.

3.2 就緒佇列上的虛擬時鐘資訊

完全公平排程器類sched_fair_class主要負責管理普通程序, 在全域性的CPU就讀佇列上儲存了在CFS的就緒佇列struct cfs_rq cfs

程序的就緒佇列中就儲存了CFS相關的虛擬執行時鐘的資訊, struct cfs_rq定義如下:

struct cfs_rq
{
    struct load_weight load;   /*所有程序的累計負荷值*/
    unsigned long nr_running;  /*當前就緒佇列的程序數*/

    // ========================
    u64 min_vruntime;  //  佇列的虛擬時鐘, 
    // =======================
    struct rb_root tasks_timeline;  /*紅黑樹的頭結點*/
    struct rb_node *rb_leftmost;    /*紅黑樹的最左面節點*/

    struct sched_entity *curr;      /*當前執行程序的可排程實體*/
        ...
};

4 update_curr函式計算程序虛擬時間

所有與虛擬時鐘有關的計算都在update_curr中執行, 該函式在系統中各個不同地方呼叫, 包括週期性排程器在內.

update_curr的流程如下

  • 首先計算程序當前時間與上次啟動時間的差值

  • 通過負荷權重和當前時間模擬出程序的虛擬執行時鐘

  • 重新設定cfs的min_vruntime保持其單調性

4.1 計算時間差

首先, 該函式確定就緒佇列的當前執行程序, 並獲取主排程器就緒佇列的實際時鐘值, 該值在每個排程週期都會更新

/*  確定就緒佇列的當前執行程序curr  */
struct sched_entity *curr = cfs_rq->curr;

其中輔助函式rq_of用於確定與CFS就緒佇列相關的struct rq例項, 其定義在kernel/sched/fair.c, line 248

rq_clock_task函式返回了執行佇列的clock_task成員.

/*  rq_of -=> return cfs_rq->rq 返回cfs佇列所在的全域性就緒佇列  
*  rq_clock_task返回了rq的clock_task  */
u64 now = rq_clock_task(rq_of(cfs_rq));
u64 delta_exec;

如果就佇列上沒有程序在執行, 則顯然無事可做, 否則核心計算當前和上一次更新負荷權重時兩次的時間的差值

/*   如果就佇列上沒有程序在執行, 則顯然無事可做  */
if (unlikely(!curr))
    return;

/*  核心計算當前和上一次更新負荷權重時兩次的時間的差值 */
delta_exec = now - curr->exec_start;
if (unlikely((s64)delta_exec <= 0))
    return;

然後重新更新更新啟動時間exec_start為now, 以備下次計算時使用

最後將計算出的時間差, 加到了先前的統計時間上

/*  重新更新啟動時間exec_start為now  */
curr->exec_start = now;

schedstat_set(curr->statistics.exec_max,
              max(delta_exec, curr->statistics.exec_max));

/*  將時間差加到先前統計的時間即可  */
curr->sum_exec_runtime += delta_exec;
schedstat_add(cfs_rq, exec_clock, delta_exec);

4.2 模擬虛擬時鐘

有趣的事情是如何使用給出的資訊來模擬不存在的虛擬時鐘. 這一次核心的實現仍然是非常巧妙地, 針對最普通的情形節省了一些時間. 對於執行在nice級別0的程序來說, 根據定義虛擬時鐘和物理時間相等. 在使用不同的優先順序時, 必須根據程序的負荷權重重新衡定時間

curr->vruntime += calc_delta_fair(delta_exec, curr);
update_min_vruntime(cfs_rq);

其中calc_delta_fair函式是計算的關鍵

//  http://lxr.free-electrons.com/source/kernel/sched/fair.c?v=4.6#L596
/*
 * delta /= w
 */
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;
}

忽略舍入和溢位檢查, calc_delta_fair函式所做的就是根據下列公式計算:

delta=delta×NICE_0_LOADcurr>se>load.weight

每一個程序擁有一個vruntime, 每次需要排程的時候就選執行佇列中擁有最小vruntime的那個程序來執行, vruntime在時鐘中斷裡面被維護, 每次時鐘中斷都要更新當前程序的vruntime, 即vruntime以如下公式逐漸增長

那麼curr->vruntime += calc_delta_fair(delta_exec, curr); 即相當於如下操作

條件 公式
curr.nice != NICE_0_LOAD curr>vruntime+=delta_exec×NICE_0_LOADcurr>se>load.weight
curr.nice == NICE_0_LOAD curr>vruntime+=delta

在該計算中可以派上用場了, 回想一下子, 可知越重要的程序會有越高的優先順序(即, 越低的nice值), 會得到更大的權重, 因此累加的虛擬執行時間會小一點,

根據公式可知, nice = 0的程序(優先順序120), 則虛擬時間和物理時間是相等的, 即current->se->load.weight等於NICE_0_LAD的情況.

4.3 重新設定cfs_rq->min_vruntime

接著核心需要重新設定min_vruntime. 必須小心保證該值是單調遞增的, 通過update_min_vruntime函式來設定

//  http://lxr.free-electrons.com/source/kernel/sched/fair.c?v=4.6#L457

static void update_min_vruntime(struct cfs_rq *cfs_rq)
{
    /*  初始化vruntime的值, 相當於如下的程式碼
    if (cfs_rq->curr != NULL)
        vruntime = cfs_rq->curr->vruntime;
    else
        vruntime = cfs_rq->min_vruntime;
    */
    u64 vruntime = cfs_rq->min_vruntime;

    if (cfs_rq->curr)
        vruntime = cfs_rq->curr->vruntime;


    /*  檢測紅黑樹是都有最左的節點, 即是否有程序在樹上等待排程
     *  cfs_rq->rb_leftmost(struct rb_node *)儲存了程序紅黑樹的最左節點
     *  這個節點儲存了即將要被排程的結點  
     *  */
    if (cfs_rq->rb_leftmost)
    {
        /*  獲取最左結點的排程實體資訊se, se中儲存了其vruntime
         *  rb_leftmost的vruntime即樹中所有節點的vruntiem中最小的那個  */
        struct sched_entity *se = rb_entry(cfs_rq->rb_leftmost,
                           struct sched_entity,
                           run_node);
        /*  如果就緒佇列上沒有curr程序
         *  則vruntime設定為樹種最左結點的vruntime
         *  否則設定vruntiem值為cfs_rq->curr->vruntime和se->vruntime的最小值
         */
        if (!cfs_rq->curr)  /*  此時vruntime的原值為cfs_rq->min_vruntime*/
            vruntime = se->vruntime;
        else                /* 此時vruntime的原值為cfs_rq->curr->vruntime*/
            vruntime = min_vruntime(vruntime, se->vruntime);
    }

    /* ensure we never gain time by being placed backwards. 
     * 為了保證min_vruntime單調不減
     * 只有在vruntime超出的cfs_rq->min_vruntime的時候才更新
     */
    cfs_rq->min_vruntime = max_vruntime(cfs_rq->min_vruntime, vruntime);
#ifndef CONFIG_64BIT
    smp_wmb();
    cfs_rq->min_vruntime_copy = cfs_rq->min_vruntime;
#endif
}

我們通過分析update_min_vruntime函式設定cfs_rq->min_vruntime的流程如下

  • 首先檢測cfs就緒佇列上是否有活動程序curr, 以此設定vruntime的值
    如果cfs就緒佇列上沒有活動程序curr, 就設定vruntime為curr->vruntime;
    否則又活動程序就設定為vruntime為cfs_rq的原min_vruntime;

  • 接著檢測cfs的紅黑樹上是否有最左節點, 即等待被排程的節點, 重新設定vruntime的值為curr程序和最左程序rb_leftmost的vruntime較小者的值

  • 為了保證min_vruntime單調不減, 只有在vruntime超出的cfs_rq->min_vruntime的時候才更新

update_min_vruntime依據當前程序和待排程的程序的vruntime值, 設定出一個可能的vruntime值, 但是隻有在這個可能的vruntime值大於就緒佇列原來的min_vruntime的時候, 才更新就緒佇列的min_vruntime, 利用該策略, 核心確保min_vruntime只能增加, 不能減少.

update_min_vruntime函式的流程等價於如下的程式碼

//  依據curr程序和待排程程序rb_leftmost找到一個可能的最小vruntime值
if (cfs_rq->curr != NULL cfs_rq->rb_leftmost == NULL)
    vruntime = cfs_rq->curr->vruntime;
else if(cfs_rq->curr == NULL && cfs_rq->rb_leftmost != NULL)
        vruntime = cfs_rq->rb_leftmost->se->vruntime;
else if (cfs_rq->curr != NULL cfs_rq->rb_leftmost != NULL)
    vruntime = min(cfs_rq->curr->vruntime, cfs_rq->rb_leftmost->se->vruntime);
else if(cfs_rq->curr == NULL cfs_rq->rb_leftmost == NULL)
    vruntime = cfs_rq->min_vruntime;

//  每個佇列的min_vruntime只有被樹上某個節點的vruntime((curr和程rb_leftmost兩者vruntime的較小值)超出時才更新
cfs_rq->min_vruntime = max_vruntime(cfs_rq->min_vruntime, vruntime);

其中尋找可能vruntime的策略我們採用表格的形式可能更加直接

活動程序curr 待排程程序rb_leftmost 可能的vruntime值 cfs_rq
NULL NULL cfs_rq->min_vruntime 維持原值
NULL 非NULL rb_leftmost->se->vruntime max(可能值vruntime, 原值)
非NULL NULL curr->vruntime max(可能值vruntime, 原值)
非NULL 非NULL min(curr->vruntime, rb_leftmost->se->vruntime) max(可能值vruntime, 原值)

5 紅黑樹的鍵值entity_key和entity_before

完全公平排程排程器CFS的真正關鍵點是, 紅黑樹的排序過程是程序的vruntime來進行計算的, 準確的來說同一個就緒佇列所有程序(或者排程實體)依照其鍵值se->vruntime - cfs_rq->min_vruntime進行排序.

鍵值通過entity_key計算, 該函式在linux-2.6之中被定義, 但是後來的核心中移除了這個函式, 但是我們今天仍然講解它, 因為它對我們理解CFS排程器和虛擬時鐘vruntime有很多幫助, 我們也會講到為什麼這麼有用的一個函式會被移除

static inline s64 entity_key(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
    return se->vruntime - cfs_rq->min_vruntime;
}

鍵值較小的結點, 在CFS紅黑樹中排序的位置就越靠左, 因此也更快地被排程. 用這種方法, 核心實現了下面兩種對立的機制

  • 在程式執行時, 其vruntime穩定地增加, 他在紅黑樹中總是向右移動的.

    因為越重要的程序vruntime增加的越慢, 因此他們向右移動的速度也越慢, 這樣其被排程的機會要大於次要程序, 這剛好是我們需要的

  • 如果程序進入睡眠, 則其vruntime保持不變. 因為每個佇列min_vruntime同時會單調增加, 那麼當程序從睡眠中甦醒, 在紅黑樹中的位置會更靠左, 因為其鍵值相對來說變得更小了.

好了我們瞭解了entity_key計算了紅黑樹的鍵值, 他作為CFS對紅黑樹中結點的排序依據. 但是在新的核心中entity_key函式卻早已消失不見, 這是為什麼呢?

即相當於如下程式碼

if (entity_key(cfs_rq, se) < entity_key(cfs_rq, entry))

等價於
if (se->vruntime-cfs_rq->min_vruntime < entry->vruntime-cfs_rq->min_vruntime)

進一步化簡為

if (se->vruntime < entry->vruntime)

即整個過程等價於比較兩個排程實體vruntime值得大小

static inline int entity_before(struct sched_entity *a,
                                struct sched_entity *b)
{
    return (s64)(a->vruntime - b->vruntime) < 0;
}

6 延遲跟蹤(排程延遲)與虛擬時間在排程實體內部的再分配

6.1 排程延遲與其控制欄位

核心有一個固定的概念, 稱之為良好的排程延遲, 即保證每個可執行的程序都應該至少執行一次的某個時間間隔. 它在sysctl_sched_latency給出, 可通過/proc/sys/kernel/sched_latency_ns控制, 預設值為20000000納秒, 即20毫秒.

第二個控制引數sched_nr_latency, 控制在一個延遲週期中處理的最大活動程序數目. 如果揮動程序的數目超過該上限, 則延遲週期也成比例的線性擴充套件.sched_nr_latency可以通過sysctl_sched_min_granularity間接的控制, 後者可通過/procsys/kernel/sched_min_granularity_ns設定. 預設值是4000000納秒, 即4毫秒, 每次sysctl_sched_latency/sysctl_sched_min_granularity之一改變時, 都會重新計算sched_nr_latency.

__sched_period確定延遲週期的長度, 通常就是sysctl_sched_latency, 但如果有更多的程序在執行, 其值有可能按比例線性擴充套件. 在這種情況下, 週期長度是

__sched_period = sysctl_sched_latency * nr_running / sched_nr_latency

6.2 虛擬時間在排程實體內的分配

排程實體是核心進行排程的基本實體單位, 其可能包含一個或者多個程序, 那麼排程實體分配到的虛擬執行時間, 需要在內部對各個程序進行再次分配.

通過考慮各個程序的相對權重, 將一個延遲週期的時間在活動程序之前進行分配. 對於由某個排程實體標識的給定程序, 分配到的時間通過sched_slice函式來分配, 其實現在kernel/sched/fair.c, line 626, 計算方式如下

/*
 * We calculate the wall-time slice from the period by taking a part
 * proportional to the weight.
 *
 * s = p*P[w/rw]
 */
static u64 sched_slice(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
        u64 slice = __sched_period(cfs_rq->nr_running + !se->on_rq);

        for_each_sched_entity(se) {
                struct load_weight *load;
                struct load_weight lw;

                cfs_rq = cfs_rq_of(se);
                load = &cfs_rq->load;

                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);
        }
        return slice;
}

回想一下子, 就緒佇列的負荷權重是佇列是那個所有活動程序負荷權重的總和, 結果時間段是按實際時間給出的, 但核心有時候也需要知道等價的虛擬時間, 該功能通過sched_vslice函式來實現, 其定義在kernel/sched/fair.c, line 626

/*
 * We calculate the vruntime slice of a to-be-inserted task.
 *
 * vs = s/w
 */
static u64 sched_vslice(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
        return calc_delta_fair(sched_slice(cfs_rq, se), se);
}

相對於權重weight的程序來說, 其實際時間段time相對應的虛擬時間長度為

time * NICE_0_LOAD / weight

該公式通過calc_delta_fair函式計算, 在sched_vslice函式中也被用來轉換分配到的延遲時間間隔.

7 總結

CFS排程演算法的思想

理想狀態下每個程序都能獲得相同的時間片,並且同時執行在CPU上,但實際上一個CPU同一時刻執行的程序只能有一個。也就是說,當一個程序佔用CPU時,其他程序就必須等待。CFS為了實現公平,必須懲罰當前正在執行的程序,以使那些正在等待的程序下次被排程.

虛擬時鐘是紅黑樹排序的依據

具體實現時,CFS通過每個程序的虛擬執行時間(vruntime)來衡量哪個程序最值得被排程. CFS中的就緒佇列是一棵以vruntime為鍵值的紅黑樹,虛擬時間越小的程序越靠近整個紅黑樹的最左端。因此,排程器每次選擇位於紅黑樹最左端的那個程序,該程序的vruntime最小.

優先順序計算負荷權重, 負荷權重和當前時間計算出虛擬執行時間

虛擬執行時間是通過程序的實際執行時間和程序的權重(weight)計算出來的。在CFS排程器中,將程序優先順序這個概念弱化,而是強調程序的權重。一個程序的權重越大,則說明這個程序更需要執行,因此它的虛擬執行時間就越小,這樣被排程的機會就越大。而,CFS排程器中的權重在核心是對使用者態程序的優先順序nice值, 通過prio_to_weight陣列進行nice值和權重的轉換而計算出來的

虛擬時鐘相關公式

linux核心採用了計算公式:

屬性 公式 描述
ideal_time sum_runtime *se.weight/cfs_rq.weight 每個程序應該執行的時間
sum_exec_runtime 執行佇列中所有任務執行完一遍的時間
se.weight 當前程序的權重
cfs.weight 整個cfs_rq的總權重

這裡se.weight和cfs.weight根據上面講解我們可以算出, sum_runtime是怎們計算的呢,linux核心中這是個經驗值,其經驗公式是

條件 公式
程序數 > sched_nr_latency sum_runtime=sysctl_sched_min_granularity *nr_running
程序數 <=sched_nr_latency sum_runtime=sysctl_sched_latency = 20ms

注:sysctl_sched_min_granularity =4ms

sched_nr_latency是核心在一個延遲週期中處理的最大活動程序數目

linux核心程式碼中是通過一個叫vruntime的變數來實現上面的原理的,即:

每一個程序擁有一個vruntime,每次需要排程的時候就選執行佇列中擁有最小vruntime的那個程序來執行,vruntime在時鐘中斷裡面被維護,每次時鐘中斷都要更新當前程序的vruntime,即vruntime以如下公式逐漸增長:

條件 公式
curr.nice!=NICE_0_LOAD vruntime += delta * NICE_0_LOAD/se.weight;
curr.nice=NICE_0_LOAD vruntime += delta;