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

1 虛擬運行時間(今日內容提醒)

1.1 虛擬運行時間的引入

CFS爲了實現公平,必須懲罰當前正在運行的進程,以使那些正在等待的進程下次被調度。

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

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

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

在CFS調度器中,將進程優先級這個概念弱化,而是強調進程的權重。一個進程的權重越大,則說明這個進程更需要運行,因此它的虛擬運行時間就越小,這樣被調度的機會就越大。

那麼,在用戶態進程的優先級nice值與CFS調度器中的權重又有什麼關係?在內核中通過prio_to_weight數組進行nice值和權重的轉換。

1.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值和權重的轉換而計算出來的

2 虛擬時鐘相關的數據結構

2.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實現的, 該函數在多處調用.

2.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;      /*當前執行進程的可調度實體*/
        ...
};

3 update_curr函數計算進程虛擬時間

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

update_curr的流程如下

  • 首先計算進程當前時間與上次啓動時間的差值
  • 通過負荷權重和當前時間模擬出進程的虛擬運行時鐘
  • 重新設置cfs的min_vruntime保持其單調性

3.1 計算時間差

首先, 該函數確定就緒隊列的當前執行進程, 並獲取主調度器就緒隊列的實際時鐘值, 該值在每個調度週期都會更新

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

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

cfs_rq就緒隊列中存儲了指向就緒隊列的實例,參見kernel/sched/sched.h, line412, 而rq_of就返回了這個指向rq的指針, rq_of定義在kernel/sched/fair.c, line 249

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);

3.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函數所做的就是根據下列公式計算:

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

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

條件

公式

curr.nice != NICE_0_LOAD

curr.nice == NICE_0_LOAD

curr−>vruntime+=delta

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

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

3.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, 原值)

4 紅黑樹的鍵值entity_key和entity_before

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

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

我們可以在早期的linux-2.6.30(僅有entity_key函數)和linux-2.6.32(定義了entity_keyentity_befire函數)來查看

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函數卻早已消失不見, 這是爲什麼呢?

sched: Replace use of entity_key

我們在linux-2.6.32的kernel/sched_fair.c中搜索entity_key函數關鍵字, 會發現內核僅在__enqueue_entity(定義在linux-2.6.32的kernel/sched_fair.c, line 309)函數中使用了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值得大小

因此內核定義了函數entity_before來實現此功能, 函數定義在linux+v2.6.32/kernel/sched_fair.c, line 269, 在我們新的linux-4.6內核中定義在kernel/sched/fair.c, line 452

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

5 延遲跟蹤(調度延遲)與虛擬時間在調度實體內部的再分配

5.1 調度延遲與其控制字段

內核有一個固定的概念, 稱之爲良好的調度延遲, 即保證每個可運行的進程都應該至少運行一次的某個時間間隔. 它在sysctl_sched_latency給出, 可通過/proc/sys/kernel/sched_latency_ns控制, 默認值爲20000000納秒, 即20毫秒.

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

__sched_period = sysctl_sched_latency * nr_running / sched_nr_latency

5.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函數中也被用來轉換分配到的延遲時間間隔.

6 總結

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;

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