Linux排程器:程序優先順序

yooooooo發表於2024-07-16

一、前言

本文主要描述的是程序優先順序這個概念。從使用者空間來看,程序優先順序就是nice value和scheduling priority,對應到核心,有靜態優先順序、realtime優先順序、歸一化優先順序和動態優先順序等概念,我們希望能在第二章將這些相關的概念描述清楚。為了加深理解,在第三章我們給出了幾個典型資料流過程的分析。

二、overview

1、藍圖

image

2、使用者空間的視角

在使用者空間,程序優先順序有兩種含義:nice value和scheduling priority。對於普通程序而言,程序優先順序就是nice value,從-20(優先順序最高)~19(優先順序最低),透過修改nice value可以改變普通程序獲取cpu資源的比例。隨著實時需求的提出,程序又被賦予了另外一種屬性scheduling priority,而這些程序被稱為實時程序。實時程序的優先順序的範圍可以透過sched_get_priority_min和sched_get_priority_max,對於linux而言,實時程序的scheduling priority的範圍是1(優先順序最低)~99(優先順序最高)。當然,普通程序也有scheduling priority,被設定為0。

3、核心中的實現

核心中,task struct中有若干和程序優先順序有個的成員,如下:

struct task_struct {
......
    int prio, static_prio, normal_prio;
    unsigned int rt_priority;
......
    unsigned int policy;
......
}

policy成員記錄了該執行緒的排程策略,而其他的成員表示了各種型別的優先順序,下面的小節我們會一一描述。

4、靜態優先順序

(1)值越小,程序優先順序越高

(2)0 – 99用於real-time processes(沒有實際的意義),100 – 139用於普通程序

(3)預設值是 120

(4)使用者空間可以透過nice()或者setpriority對該值進行修改。透過getpriority可以獲取該值。

(5)新建立的程序會繼承父程序的static priority。

靜態優先順序是所有相關優先順序的計算的起點,要麼繼承自父程序,要麼使用者空間自行設定。一旦修改了靜態優先順序,那麼normal priority和動態優先順序都需要重新計算

5、實時優先順序

task struct中的rt_priority成員表示該執行緒的實時優先順序,也就是從使用者空間的視角來看的scheduling priority。0是普通程序,1~99是實時程序,99的優先順序最高。

6、歸一化優先順序

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

這裡我們先聊聊歸一化(Normalization)這個看起來稍微有點晦澀的術語。如果你做過音影片定點演算法的最佳化,應該對這個詞不陌生。不同的定點資料有不同的表示,有Q31的,有Q15,這些資料的小數點的位置不同,無法進行比較、加減等操作,因此需要歸一化,全部轉換成某個特定的資料格式(其實就是確定小數點的位置)。在數學上,1米和1mm在進行操作的時候也需要歸一化,全部轉換成同一個量綱就OK了。對於這裡的優先順序,排程器需要綜合考慮各種因素,例如排程策略,nice value、scheduling priority等,把這些factor全部考慮進來,歸一化成一個數軸上的number,以此來表示其優先順序,這就是normalized priority。對於一個執行緒,其normalized priority的number越小,其優先順序越大。

排程策略是deadline的程序比RT程序和normal程序的優先順序還要高,因此它的歸一化優先順序是負數:-1。如果採用實時排程策略,那麼該執行緒的normalized priority和rt_priority相關。task struct中的rt_priority成員是使用者空間視角的實時優先順序(scheduling priority),MAX_RT_PRIO-1是99,MAX_RT_PRIO-1 - p->rt_priority則翻轉了實時程序的scheduling priority,最高優先順序是0,最低是98。順便說一句,normalized priority是99的情況是沒有意義的。對於普通程序,normalized priority就是其靜態優先順序。

7.動態優先順序

task struct中的prio成員表示了該執行緒的動態優先順序,也就是排程器在進行排程時候使用的那個優先順序。動態優先順序在執行時可以被修改,例如在處理優先順序翻轉問題的時候,系統可能會臨時調升一個普通程序的優先順序。一般設定動態優先順序的程式碼是這樣的:p->prio = effective_prio(p),具體計算動態優先順序的程式碼如下:

static int effective_prio(struct task_struct *p)
{
    p->normal_prio = normal_prio(p);
    if (!rt_prio(p->prio))
        return p->normal_prio;
    return p->prio;
}

rt_prio是一個根據當前優先順序來確定是否是實時程序的函式,包括兩種情況,一種情況是該程序是實時程序,排程策略是SCHED_FIFO或者SCHED_RR。另外一種情況是人為的將該程序提升到RT priority的區域(例如在使用優先順序繼承的方法解決系統中優先順序翻轉問題的時候)。在這兩種情況下,我們都不改變其動態優先順序,即effective_prio返回當前動態優先順序p->prio。其他情況,程序的動態優先順序跟隨歸一化的優先順序。

三、典型資料流程分析

1、使用者空間設定nice value

使用者空間設定nice value的操作,在核心中主要是set_user_nice函式實現的,無論是sys_nice或者sys_setpriority,在引數檢查和許可權檢查之後都會呼叫set_user_nice函式,完成具體的設定。程式碼如下:

void set_user_nice(struct task_struct *p, long nice)
{
    int old_prio, delta, queued;
    unsigned long flags;
    struct rq *rq; 
    rq = task_rq_lock(p, &flags);
    if (task_has_dl_policy(p) || task_has_rt_policy(p)) {-----------(1)
        p->static_prio = NICE_TO_PRIO(nice);
        goto out_unlock;
    }
    queued = task_on_rq_queued(p);-------------------(2)
    if (queued)
        dequeue_task(rq, p, DEQUEUE_SAVE);

    p->static_prio = NICE_TO_PRIO(nice);----------------(3)
    set_load_weight(p);
    old_prio = p->prio;
    p->prio = effective_prio(p);
    delta = p->prio - old_prio;

    if (queued) {
        enqueue_task(rq, p, ENQUEUE_RESTORE);------------(2)
        if (delta < 0 || (delta > 0 && task_running(rq, p)))------------(4)
            resched_curr(rq);
    }
out_unlock:
    task_rq_unlock(rq, p, &flags);
}

(1)如果是實時程序或者deadline型別的程序,那麼nice value其實是沒有什麼實際意義的,不過我們還是設定其靜態優先順序,當然,這樣的設定其實不會起到什麼作用的,也不會實際改變排程器行為,因此直接返回,沒有dequeue和enqueue的動作。

(2)在step中已經處理了排程策略是RT類和DEADLINE類的程序,因此,執行到這裡,只可能是普通程序了,使用CFS演算法。如果該task在run queue上(queued 等於true),那麼由於我們修改了nice value,排程器需要重新審視當前runqueue中的task。因此,我們需要將該task從rq中摘下,在重新計算優先順序之後,再次插入該runqueue對應的runable task的紅黑樹中。

(3)最核心的程式碼就是p->static_prio = NICE_TO_PRIO(nice);這一句了,其他的都是side effect。比如說load weight。當cpu一刻不停的運算的時候,其load是100%,沒有機會排程到idle程序休息一下。當系統中沒有實時程序或者deadline程序的時候,所有的runnable的程序一起來瓜分cpu資源,以此不同的程序分享一個特定比例的cpu資源,我們稱之load weight。不同的nice value對應不同的cpu load weight,因此,當更改nice value的時候,也必須透過set_load_weight來更新該程序的cpu load weight。除了load weight,該執行緒的動態優先順序也需要更新,這是透過p->prio = effective_prio(p);來完成的。

(4)delta 記錄了新舊執行緒的動態優先順序的差值,當除錯了該執行緒的優先順序(delta < 0),那麼有可能產生一個排程點,因此,呼叫resched_curr,給當前正在執行的task做一個標記,以便在返回使用者空間的時候進行排程。此外,如果修改當前running狀態的task的動態優先順序,那麼調降(delta > 0)意味著該程序有可能需要讓出cpu,因此也需要resched_curr標記當前running狀態的task需要reschedule。

2、程序預設的排程策略和排程引數

我們先思考這樣的一個問題:在使用者空間設定排程策略和排程引數之前,一個執行緒的default scheduling policy是什麼呢?這需要追溯到fork的時候(具體程式碼在sched_fork函式中),這個和task struct中sched_reset_on_fork設定相關。如果沒有設定這個flag,那麼說明在fork的時候,子程序跟隨父程序的排程策略,如果設定了這個flag,則說明子程序的排程策略和排程引數不能繼承自父程序,而是需要設定為default。程式碼片段如下:

int sched_fork(unsigned long clone_flags, struct task_struct *p)
{

……
    p->prio = current->normal_prio; -------------------(1)
    if (unlikely(p->sched_reset_on_fork)) {
        if (task_has_dl_policy(p) || task_has_rt_policy(p)) {----------(2)
            p->policy = SCHED_NORMAL;
            p->static_prio = NICE_TO_PRIO(0);
            p->rt_priority = 0;
        } else if (PRIO_TO_NICE(p->static_prio) < 0)
            p->static_prio = NICE_TO_PRIO(0);

        p->prio = p->normal_prio = __normal_prio(p); ------------(3)
        set_load_weight(p); 
        p->sched_reset_on_fork = 0;
    }

……

}

(1)sched_fork只是fork過程中的一個片段,在fork一開始,dup_task_struct已經複製了一個和父程序完全一個的程序描述符(task struct),因此,如果沒有步驟2中的重置,那麼子程序是跟隨父程序的排程策略和排程引數(各種優先順序),當然,有時候為了解決PI問題而臨時調升父程序的動態優先順序,在fork的時候不宜傳遞到子程序中,因此這裡重置了動態優先順序。

(2)預設的排程策略是SCHED_NORMAL,靜態優先順序等於120(也就是說nice value等於0),rt priority等於0(普通程序)。不管父程序如何,即便是deadline的程序,其fork的子程序也需要恢復到預設引數。

(3)既然排程策略和靜態優先順序已經修改了,那麼也需要更新動態優先順序和歸一化優先順序。此外,load weight也需要更新。一旦子程序中恢復到了預設的排程策略和優先順序,那麼sched_reset_on_fork這個flag已經完成了歷史使命,可以clear掉了。

OK,至此,我們瞭解了在fork過程中對排程策略和排程引數的處理,這裡還是要追加一個問題:為何不一切繼承父程序的排程策略和引數呢?為何要在fork的時候reset to default呢?在linux中,對於每一個程序,我們都會進行資源限制。例如對於那些實時程序,如果它持續消耗cpu資源而沒有發起一次可以引起阻塞的系統呼叫,那麼我們猜測這個realtime程序跑飛了,從而鎖住了系統。對於這種情況,我們要進行干預,因此引入了RLIMIT_RTTIME這個per-process的資源限制項。但是,如果使用者空間的realtime程序透過fork其實也可以繞開RLIMIT_RTTIME這個限制,從而肆意的攫取cpu資源。然而,機智的核心開發人員早已經看穿了這一切,為了防止實時程序“洩露”到其子程序中,sched_reset_on_fork這個flag被提出來。

3、使用者空間設定排程策略和排程引數

透過sched_setparam介面函式可以修改rt priority的排程引數,而透過sched_setscheduler功能會更強一些,不但可以設定rt priority,還可以設定排程策略。而sched_setattr是一個集大成之介面,可以設定一個執行緒的排程策略以及該排程策略下的排程引數。當然,對於核心,這些介面都透過__sched_setscheduler這個核心函式來完成對指定執行緒排程策略和排程引數的修改。

__sched_setscheduler分成兩個部分,首先進行安全性檢查和引數檢查,其次進行具體的設定。

我們先看看安全性檢查。如果使用者空間可以自由的修改排程策略和排程優先順序,那麼世界就亂套了,每個程序可能都想把自己的排程策略和優先順序提升上去,從而獲取足夠的CPU 資源。因此使用者空間設定排程策略和排程引數要遵守一定的規則:如果沒有CAP_SYS_NICE的能力,那麼基本上該執行緒能被允許的操作只是降級而已。例如從SCHED_FIFO修改成SCHED_NORMAL,異或不修改scheduling policy,而是降低靜態優先順序(nice value)或者實時優先順序(scheduling priority)。這裡例外的是SCHED_DEADLINE的設定,按理說如果程序本身的排程策略就是SCHED_DEADLINE,那麼應該允許“優先順序”降低的操作(這裡用優先順序不是那麼合適,其實就是減小run time,或者加大period,這樣可以放鬆對cpu資源的獲取),但是目前的4.4.6核心不允許(也許以後版本的核心會允許)。此外,如果沒有CAP_SYS_NICE的能力,那麼設定排程策略和排程引數的操作只能是限於屬於同一個登入使用者的執行緒。如果擁有CAP_SYS_NICE的能力,那麼就沒有那麼多限制了,可以從普通程序提升成實時程序(修改policy),也可以提升靜態優先順序或者實時優先順序。

具體的修改比較簡單,是透過__setscheduler_params函式完成,其實也就是是根據sched_attr中的引數設定到task struct相關成員中,大家可以自行閱讀程式碼進行理解。

相關文章