鼠眼看Linux調度器

一、耗子 vs Linux ?

        “鼠目寸光”,應該是個暴光率挺高的成語了,常用來說某人看事情沒有深度,看不透本質。毫無疑問,這是一個貶義100%的詞。但不管是認識什麼未知事物,都一定會有個“寸光”的過程,如果有進而持續不斷地努力,纔可能做到對之瞭如直掌。

        Linux內核是個複雜的軟件,作爲一個成熟的操作系統核心部件,最可貴的就是它的開放源碼。我想,有着“憂國憂民”抱負的程序員恐怕每天都會有“我要讀懂它”的衝動。正是在這股“賊心”的驅使下,我開始了對Linux內核的學習。一路走來,我還遠不能說是已經AtoZLinux內核,甚至不敢確定自己是不是已擺脫了“齧齒動物”的行列。但在領略其中的獨特風景後,踏踏實實地體會到真是有些欲罷不能了。例如,內核中表面上看似稀鬆平常的五六行代碼,有時卻隱藏了許多祕密,反覆思考之後,有時依然不得要領,在請教LKML上的牛人之後,才恍然大悟。每逢此時都禁不住感嘆,“同是程序員,咋差距湊這麼大哩?”。這種探索的樂趣是研究學習一般軟件所不能獲得的,我想這也是Linux內核的魅力所在吧。

        我猜想,有不少朋友都曾經嘗試過閱讀Linux內核源代碼,但沒有堅持下來。所幸自己“賊膽”不小,終於還算成功地邁出了第一小步。現在有了些小小的斬獲,不甘獨享,拿出來“顯擺”一下,真誠地期望有更多的“小鼠”朋友能夠加入學習Linux內核的行列。

        即便是解釋了這麼半天,以《鼠眼看XX》作爲文章題目是依舊是需要些勇氣的,澄清一下,我的本意其實是,文章中不會有太深的技術內容,難度水平是以一般Linux應用程序開發者能夠看懂爲限,當然,我會盡所能避免“寸光”,儘量使“知其所以然/閱讀難度”的比值大些。

        最後,本文討論Linux 2.6.13內核的調度功能,包括調度器的工作機制、時間片、優先級的計算等內容,也簡要地討論了一點staircase調度。閒話結束,開“看”!

二、“看上去很美"的調度器。

        討論調度,最直接就是從它們功能入口談起了。不同的調度器暴露其功能的方法不盡相同:

1、系統調用。這是最重要的方法。甚至於已經成爲了一部分POSIX標準,但其數量並不太多,即使如此,這裏也不可能一一展開討論,我們關心是最常用的兩個:nice()sched_setscheduler(),尤其是前者。

        2sysctl參數。某些調度器的配置項可以用sysctl命令調整。比如可以提高交互性的staircase調度補丁,它的幾個版本(比如CK8中的)就帶有compute等三個sysctl參數可以配置調度的行爲。

        3、編譯時的C常量。這是配置調度器功能的最底層手段。

顯然,(3)適合系統程序員、(2)更多是管理員的任務。只有(1)方法纔是我們應用程序開發者最常用的調度功能接口。

        先來簡單回顧一下Linux上這兩個系統調用的功能吧(下面可不是POSIX標準的原文哦):

   
                int nice(int inc);

調整當前進程的運行優先級。inc越小,當前進程的運行優先級會變的越高,反之優先級越低。inc可以是任意整數(尤其是可以小於0)。

                int sched_setscheduler(pid_t pid, int policy, const struct sched_param *p);

配置進程號爲pid的進程的調度參數,包括策略policy,和參數p,目前p只是運行優先級。policy可以是軟實時調度(SCHED_RR)、先來先服務調度(SCHED_FIFO)和正常的時間片輪轉調度和優先級調度混合的調度策略(SCHED_OTHER)

本文只關心默認的最常用的策略SCHED_OTHERSCHED_RRSCHED_FIFO也不是想像的那麼複雜,事實上,它們的實現比SCHED_OTHER簡單的多,只是作爲SCHED_OTHER的特殊情況對待罷了,體現在代碼上,就是在正常處理邏輯上多了幾個條件語句。下面的介紹中我們會繞過它們。有興趣的讀者可以過後直接研習代碼,難度不大。

需要指出的是,上面介紹的有些地方是不太準確的,特別是術語“進程”和“運行優先級”比較含糊,下面我們就逐個揭開它們的面紗。

三、先瞧兩眼Linux上的進程與線程。

        我想大家對“進程""線程"這兩個概念的含義已經爛熟於心了,小生就不班門弄斧了。不知是哪本牛書的結論了,好像是《現代操作系統》,有個很精闢的結論,回憶如下:進程主要作爲資源分配的單元,而線程更大程度上是作爲任務調度單元使用的。

衆所周知,UNIX進程模型是基於進程複製的,這個複製過程集中體現於fork()系統調用。系統啓動後,首先在用戶空間建立一個PID1叫作init的進程。然後,整個系統中的所有用戶空間上的進程都是由這個init進程直接或間接複製出來的,只要系統在正常運轉,這個init進程就可以說是“永不落進程”。

Linux也採用了UNIX進程模型,甚至更徹底:Linux上創建線程也是通過複製方法的得到的。新的NTPL線程庫採用的是1對1的線程模型,即1個用戶空間線程對應1個內核線程。雖然表層的API仍然是依照POSIX標準的,但已經與前任線程實現有了很大不同。

說到底,創建進程最終會用到fork()系統調用,而創建線程則最終使用clone()系統調用。讓我們看一眼這兩個系統調用的直接實現,不要慌張,它們很簡單:

asmlinkage int sys_fork(struct pt_regs regs)
{
    return do_fork(SIGCHLD, regs.esp,&regs, 0,NULL,NULL);
}

asmlinkage int sys_clone(struct pt_regs regs)
{
    unsignedlong clone_flags;
    unsignedlong newsp;
    int __user *parent_tidptr,*child_tidptr;

    /* 我們在這裏省略幾行代碼 */
    return do_fork(clone_flags, newsp,&regs, 0, parent_tidptr, child_tidptr);
}

很明顯,fork()clone()系統調用使用的是同一個函數do_fork(),區別只有參數不同,至此,我們可以大膽的推測,Linux在對待進程和線程的問題上,是將兩者看作基本相同的概念。事實上,在內核裏它們都使用task_struct結構體描述,傳統的進程號保存在該結構的tgid成員(線程組ID)中,而線程號則保存在pid成員中。所以,在下文中我們統稱“進程”和“線程”兩者爲“任務”。在後面介紹nice()sched_setscheduler()兩個系統調用的時,所用的術語“進程”或“線程”,如果不加特殊聲明,都可以換成“任務”。

    有Linux開發經驗讀者可能知道,Linux上還有一個系統調用可以創建新任務,這就是vfork()。你可能對它的實現方式有興趣,下面是就是它的直接實現:

asmlinkage int sys_vfork(struct pt_regs regs)
{
    return do_fork( CLONE_VFORK | CLONE_VM | SIGCHLD, regs.esp,&regs, 0,NULL,NULL);
}

嗯,看來vfork()依舊沒有擺脫do_fork()的如來掌心。

探討對Linux上進程和線程的實現是十分有意思的挑戰,但對它們作深入討論已經超出本文的範圍,如果有時間,下次我們再用“鼠眼“仔細瞧瞧Linux任務。

三、nice()系統調用。

        限於這篇文章的寫作目標,這裏不可能完整解釋與調度有關的每一行代碼,但絕不放過每一個有價值的地方。從現在開始,我會以簡化代碼的方式引用Linux內核的代碼,簡化的標準是隻保留與主功能直接相關的部分,例如內核中的各種同步細節、次要的錯誤檢查,甚至安全方面的代碼,我都會省略掉,但是我只刪代碼,絕不會修改原有代碼,這樣既有利於抓住核心環節,縮減篇幅,又不妨礙有興趣的讀者順着這些線索親自“咀嚼”代碼。

    下面就是與nice()系統調用有關的簡化代碼:

asmlinkage long sys_nice(int increment)
{
    int retval;
    long nice;

1>  if(increment <-40)
        increment =-40;
    if(increment > 40)
        increment = 40;

2>    nice = PRIO_TO_NICE(current->static_prio)+ increment;
3>    if(nice <-20)
        nice =-20;
    if(nice > 19)
        nice = 19;

4>    if(increment < 0 &&!can_nice(current, nice))
        return-EPERM;

5>    set_user_nice(current, nice);
    return 0;
}

    細心的讀者可能已經發現,所有系統調用實現函數都是以sys_加上系統調用名稱命名的,這是Linux內核的一個命名習慣。Linux系統調用的機制在不少的內核書上都有介紹,這裏就不多說了。可以粗略地認爲,sys_函數的參數就是我們在C程序裏面傳遞給系統調用時參數。

下面我們就按照上面代碼中的註腳的順序分析一下這個函數:

1>非常簡單,常規的邊界值檢查。小於0increment最終會提高優先級,大於0的反之會降低運行優先級。我想現在大家可以知道了,前面所說“inc越小,當前進程的運行優先級越高,反之優先級越低。”其實是不準確的——小於-40inc值都是按-40對待的。

2>這行代碼礙眼的地方有兩處:

1、宏current。我們知道,Linux內核是使用task_struct結構表示任務的。Current表示的就是正在執行nice()系統調用的那個任務的task_struct結構。current是從當前內核棧中獲取的,task_struct結構恐怕可以躋身於Linux內核裏最複雜的幾個數據結構了,但我們用後腳跟也可以想像的到,其中肯定保存有任務運行上下文信息、任務運行優先級、運行狀態、時間片之類的基本信息。成員static_prio就是所謂的“靜態優先級”。一會兒我們再詳細介紹它。

2、宏PRIO_TO_NICE()。顧名思義,它的功能就是將任務的靜態運行優先級(即current->static_prio)轉換成所謂的nice值。經過簡單的推導後,可以知道nice值的取值範圍是[-2019],這也是許多經典UNIX編程書籍所介紹的nice值範圍相同。接着,這個舊nice值與increment相加,將結果賦給變量nicenice數值越小,換算得到的靜態優先級越高。由於,nice值和任務的靜態優先級是一個線性對應關係。所以,我們可以將靜態優先級與nice值看成是一個東西。只不過nice是用戶直接可見的,靜態優先級則隱藏在內核裏的。


3>
常規的邊界值檢查,確保nice不超過邊界。


4>
我們知道,只有具有管理員權限的帳號纔可以增加任務的運行優先級。這裏所做的檢查就是這個審查這個條件了。如果increment<0就意味着提高運行優先級,此時就利用can_nice()進行實際的檢查。

5> set_user_nice(),正式開工嘍。代碼如下:

void set_user_nice(task_t *p,long nice)
{
    unsignedlong flags;
    prio_array_t *array;
    runqueue_t *rq;
    int old_prio, new_prio, delta;

1>    if(TASK_NICE(p)== nice || nice <-20 || nice > 19)
        return;
   
2>    array = p->array;
    if(array)
        dequeue_task(p, array);

3>    old_prio = p->prio;
    new_prio = NICE_TO_PRIO(nice);
    delta = new_prio - old_prio;
    p->static_prio = NICE_TO_PRIO(nice);
    p->prio += delta;

4>    if(array){
        enqueue_task(p, array);
        if(delta < 0 ||(delta > 0 && task_running(rq, p)))
            resched_task(rq->curr);
    }
}

        下面是代碼的解釋:

        雖然在我們的場景中,p肯定是當前任務(nice()系統調用只能修改當前任務的nice值),但set_user_nice()還能爲其它系統調用所使用,比如後面提到的setpriority(),所以我們不能就此斷定p就是當前任務。這樣,set_user_nice()有兩個參數也就不足爲怪了。

1> TASK_NICE()task_struct爲參數,作用與前面提到的PRIO_TO_NICE()相同,得到的結果還是任務的nice值。至此,這個if語句的含義已經很明顯了。

        2>這裏看起來簡單,但解釋起來有些複雜。我想許多讀者可能聽說過2.6Linux內核任務調度器是完全重新設計實現的,它的時間複雜性是O(1)級的。這裏的array,即“優先級數組”,是實現O(1)算法的重要數據結構。對它的集中介紹過後會呈現給大家。現在只要知道,這是在把任務p從運行隊列刪掉。

        3>這是我們關心的焦點:

先看old_prionew_prio。雖然兩者表示的都是運行優先級,但它們有很大不同。注意是old_prio是從p->prio複製過來的,其實它就是傳說中任務的“動態優先級”。再看new_prio,它是從nice值換算過來,換算過來的什麼呢?靜態優先級唄!

        再下面就是簡單地更新任務優先級的過程,不用多說了。

        4>先扼要的解釋如下吧:

首先,如果前面把任務從運行隊列中刪掉了,這裏就將任務重新加入到運行隊列的尾部。然後根據以下兩個條件判斷是不是讓運行隊列rq上的當前任務(rq->curr)放棄處理器,而選擇下一個任務進行(注意:此時p也在運行隊列rq裏):

        1、如果任務p的優先級提高了(delta<0)。

        2、任務優先級降低(delta>0)了並且任務p正在運行(task_running(rq, p))。把task_running的定義列出來也許會更直觀:

#define task_running(runqueue, task)    (runqueue->curr == task)

        因此,nice()系統調用一般都會使當前任務放棄處理器,但如果它滿足調度器的任務選擇要求,它還會馬上佔用處理器的。有些讀者可能覺得反覆加入運行隊列沒有什麼必要,但注意在再次加入運行隊列之前,任務的動態優先級很有可能已經發生了變化,這些看似多餘的操作,其實是實現O(1)調度的重要手段。

        到這裏了,不能不說還有一個能夠影響任務的優先級的系統調用:setpriority(),但它在本質上只是一個擴展了的nice(),它的實現代碼也非常簡單,就留給讀者自己“咀嚼”吧。

四、sched_setscheduler()系統調用。

        這個系統調用的調用層次和代碼都比nice複雜些,所涉及的也有不少我們這裏不感興趣的東西,因此就不再以展示代碼的方法介紹它們了。這裏僅在功能層次上,從與nice()對比的角度上對它做一個簡要介紹:

1、因爲sched_setscheduler()系統調用不僅可以修改當前任務的調度策略和優先級,還可以修改指定任務的這個信息。所以,它的合法性檢查更嚴格些,最重要的是增加了用戶身份驗證,當然這個檢查依舊是在task_struct結構上做的。

        2、和nice()一樣,sched_setscheduler()也不特別區分進程和線程,將兩者作等同處理,該任務也會有重新加入運行隊列的行爲。

        3sched_setscheduler()只修改動態優先級,對於默認調度策略,它被設置爲與靜態優先級相等。

五、靜態優先級裏的貓膩。

        說了這麼多靜態優先級如何如何,它到底是個什麼玩意兒?現在就讓我們剝掉其上所有可能的“耗子藥”,弄清楚它到底是怎樣影響進程的。“靜態優先級”,之所 以冠之以“靜態”前綴,是因爲內核自己從不主動修改它,只有通過系統調用才能修改它。那麼,它在調度裏到底扮演什麼角色呢?容俺仔細道來:

        1、計算任務時間片。

        讓代碼說話,先看task_timeslice()實現:

/*
* task_timeslice() scales user-nice values [ -20 ... 0 ... 19 ]
* to time slice values: [800ms ... 100ms ... 5ms]
*
* The higher a thread's priority, the bigger timeslices
* it gets during one round of execution. But even the lowest
* priority thread gets MIN_TIMESLICE worth of execution time.
*/


#define SCALE_PRIO(x, prio) /
    max(x *(MAX_PRIO -prio)/(MAX_USER_PRIO/2), MIN_TIMESLICE)

staticinlineunsignedint task_timeslice(task_t *p)
{
    if(p->static_prio < NICE_TO_PRIO(0))
        return SCALE_PRIO(DEF_TIMESLICE*4, p->static_prio);
    else
        return SCALE_PRIO(DEF_TIMESLICE, p->static_prio);
}

        從名字上也看得出來,這個函數就是用來計算任務時間片的。一般說來,只有在時間片消耗光的時候才重新計算任務的時間片,而這個計算過程只與靜態優先級有關。這個函數的邏輯很簡單,如果任務pnice<0(也就是說靜態優先級<120)的話,按表達式   

        SCALE_PRIO(DEF_TIMESLICE*4,p->static_prio)

        計算靜態優先級,否則按SCALE_PRIO(DEF_TIMESLICE, p->static_prio),讓我們仔細看一下靜態優先級是怎麼影響時間片計算的,如果設靜態優先級爲Ps,總結時間片的計算公式如下:

        當靜態優先級小於120時:

        time_slice_h = DEF_TIMESLICE*8*(MAX_PRIO-Ps) / MAX_USER_PRIO

        當靜態優先級大於等於120時:

        time_slice_l =  time_slice_h/4

其中:

DEF_TIMESLICE是默認時間片的意思,它的值是100 jiffies,在默認的配置裏(宏HZ等於1000),它等於100(單位:毫秒)。

MAX_PRIO是最大的任務優先級數值。由於數值越大的優先級表示的優先級越低,所以它代表實際上最低的優先級。取值140

MAX_USER_PRIOMAX_PRIO-MAX_RT_PRIO,即40。除了Ps外,所有參數都是編譯時常量。

        time_slice_h = 20 * (140-Ps)

        更明確地寫出它和time_slice_l的取值範圍(單位:毫秒)

        time_slice_h[20, 800]

        time_slice_l[5, 200]

        換句話說,靜態優先級越高,時間片就越長。

請注意,靜態優先級只有通過系統調用纔會改變。並且,nice值與靜態優先級是線性關係,因此,當我們修改任務的nice值時,實際上也會影響任務時間片的計算,當然只在具有管理員(root)權限的帳號下才能做到增大時間片。你任務的時間片越長會怎麼樣呢?它的吞吐量會增長,但響應能力或交互性變差。

2、判斷任務的交互性。

所有“體面”的操作系統參考書都會對所謂CPU-bound任務(計算密集型任務)和I/O-bound任務(I/O密集型任務)有所介紹。幾乎所有操作系統也都會對兩者區分對待,但真正的困難在於一個任務在其整個生命過程中,可能一會兒是計算密集型任務,一會兒又變成了I/O密集型任務。即便是有任務比較“本份”,依賴於任務自身提供這種提示信息也是不可取的,因爲這給了不良用戶危害整個系統的機會。

Linux不無例外地對這兩種任務做了區分,它是通過評估所謂的“平均休眠時間”的判斷任務的種類的,這種方法是基於這樣一個事實:現代操作系統的I/O模型絕大多數都使用中斷機制,這裏也包括以中斷爲基礎發展起來的其它I/O機制,例如最常用的DMADMAI/O操作完成時也是使用中斷機制將這個完成消息通知CPU。而觸發中斷的任務一般會引起任務狀態上的變化,通常都進入某種休眠狀態。通過計算“平均休眠時間”就可以知道一個任務I/O的歷史,進而根據程序的局部性原理預測它的I/O傾向。所謂“交互性任務”。直觀上看,就是經常與像鍵盤、鼠標這樣的輸入設備和顯示設備打交道的任務。這正符合I/O密集型任務的定義。所以,Linux將兩者一視同仁。

現在,我們有了用來判斷任務交互性的有力依據,但還缺一把尺子。這時靜態優先級再次“粉墨登場”,這就是宏TASK_INTERACTIVE(p)的實現了:

#define TASK_INTERACTIVE(p) ((p)->prio <= (p)->static_prio -DELTA(p))

        推導計算公式的過程有些繁瑣,這裏就不將步驟一一羅列出來,只給出來結果:

task->prio< = task->static_prio * 3/4 + 28

從中可以看出,低靜態優先級(靜態優先級數值較高)的任務,更容易被判定爲“交互性”任務,進而在進一步的調度中得到“晉升”的機會,這可以抑制低優先級任務長時間得不到CPU,從而使它們難以響應自身的IO子例程的完成通知。

        此外,還有一個宏用來判斷上次休眠是否屬於“交互性休眠”:


    #define INTERACTIVE_SLEEP(p) /
        (JIFFIES_TO_NS(MAX_SLEEP_AVG * /
            (MAX_BONUS / 2 + DELTA((p))+ 1)/ MAX_BONUS -1))

上面的DELTA(p)也與任務的nice值(靜態優先級)有關,簡化後如下:

INTERACTIVE_SLEEP(task) = 799 + 25 * nice

        對它的分析就留給讀者吧:如果靜態優先級越高,就會......

3、交換優先級數組的頻率。

拖了現在,終於到了“供述”調度器最核心的部分了。有着FreeBSD/Solaris經驗的朋友會覺得Linux在這裏與它們有些相似。

Linux的調度算法的O(1)效 果主要來自於它的優先級調度的設計。拍拍腦瓜,如果我們自己實現優先級調度的話,直接能夠想到的方法就是給每個任務結構分別加一個優先級成員,當需要選取 一個任務運行時,就搜索任務鏈表找到最大優先級的若干任務中的一個,拿出來運行就行了。這樣當然也沒有什麼錯誤,但效率一定不高,我相信,聰明的讀者一定 想到各種各樣的優化方法。但我們還是先來分析一下爲什麼這樣不好吧,首先在中等繁忙的系統中任務總數達到數千個是輕而易舉的事情,而動輒就得搜索整個任務 鏈表的設計顯然是不現實的,並且任務切換是個很頻繁的操作,這更是惡化了執行效率。其次,對於有實時要求的任務,在任務切換時這種不可預測的搜索延時更是 不可接受的。相似的環節還有重新計算時間片和優先級。如果我們在固定的時間點上集中計算所有任務的時間片和優先級的話,也會帶來與上述搜索任務相同的負面 效果。

不過,LinuxO(1)調度算法也沒有想像的複雜,它的基本想法是這樣的:事先規定好優先級的數量,在Linux上它是141,但只有IDLE任務才使用最低的優先級140MAX_PRIO)。所以,Linux將一整個任務鏈表拆分成140個任務鏈表,每個優先級對應一個。每當有任務使用完了它的時間片時,就立即重新計算它的新時間片和動態優先級,並將它插入到對應的動態優先級的任務鏈表尾部。這樣,即使任務狀態和數量隨時變化,每個任務鏈表上的任務也都會具有相同的優先級,並且是一個FIFO鏈表,因此更準確地說,任務鏈表應該稱爲“任務隊列”。這140個任務隊列構成一個任務隊列數組,這個任務隊列數組的索引其實就是動態優先級,所以它也稱爲優先級數組。在選擇任務運行時,它採用從索引0(優先級0, 最高優先級)開始的線性搜索這個優先級數組方法,它首先定位到一個非空的任務隊列。因而搜索結果會是一個具有最小索引值的任務隊列,而索引值其實就是動態 優先級呀,這便是“數值越小,級別越高”的真實原因了。最後,選取這個任務隊列中頭一個任務運行,也就是最先進入該任務隊列的那個任務。最後,這140個任務隊列和一些附加信息構成“運行隊列(runqueue)”。(注意區分“任務隊列”和“運行隊列”)。

當然,真實的Linux調度比上面我們所介紹的還要複雜一些,主要的區別是每個CPU有 兩個優先級數組(每種優先級對應兩個任務隊列)。一個是活動優先級數組、一個過期優先級數組。那些時間片沒消耗完的任務都在活動數組中,過期數組裏放的是 把時間片都花光了的窮光蛋任務。其實這麼定義它們也不是十分精確,但這樣的近似不傷大雅。當活動數組中沒有任務時,內核就交換這兩個數組。不用擔心,這個 交換過程也只是做個指針遊戲,很高效的。

說到這裏,讀者可能已經暈了,看看下面的圖可以清理一下有些紊亂的思緒:



如果你打算研究代碼,那麼“優先級數組”對應於prio_array_t類型,“運行隊列”對應於runqueue_t類型。

那爲什麼要有兩個優先級數組呢?先看看調度算法作者Ingo寫的文檔(Documentation/sched_design.txt)關於這點是怎麼說的吧,下面是原文和拙譯:

the split-array solution enables us to have an arbitrary number of active and expired tasks, and the recalculation of timeslices can be done immediately when the timeslice expires.

“數組分離”的解決方案允許我們在活動數組和過期數組中保存有任意數量的任務,並且可以在任務花光時間片時立刻就重新計算好計算片。

不知道Ingo給出的解釋有沒有說服大家,至少筆者認爲這兩個理由都不太令人滿意。首先,第一個理由“可能保存有任意數量的任務”,單優先級數組(SPA) 算法也可以滿足這個要求,所謂可以有“任意數量的任務”,一般考慮的是性能問題,但這裏的搜索過程是以優先級搜索的基礎的,和任務數量沒有什麼大關係。是 的,每種優先級可能會有很多任務,但搜索過程只需要使用其中頭一任務就行了,根本不需要遍歷特定優先級的任務隊列。但是,這並不是說這條理由不正確,只是 它沒有說的點子上。其次,“馬上重新計算好計算片”,計算時間片能否立即完成與有幾個優先級數組一點關係也沒有,事實上,甚至於像staircase調度算法,這樣有着更復雜時間片計算規則的SPA算法,它的時間片也是隨用隨算的。也許,這句解釋裏面有歷史上的原因,也許,還有筆者還沒領會到更深的技術背景原因,如果是這樣,請大家不吝賜教。

筆者認爲設置兩個優先級數組,更多的是杜絕飢餓任務的出現。所謂“飢餓任務”,就是長時間佔用不到CPU的任務。優先級調度的原則就是隻選高優先級的任務,不選低的。讓我們想象一種極端情況,系統內只設置兩個優先級,如果高優先級的任務隊列很長,此時低優先級的任務就長時間得不到CPU而成爲“飢餓任務”。你可能要說,低優先級任務嗎,就應該這樣處理呀。我覺得這樣的說法是錯誤的,筆者更認爲優先級的高低是任務與CPU的親近程度。優先級高任務與CPU打的更火熱,優先級低的任務離CPU疏遠一點,低優先級任務和CPU絕不是“老死不相往來”的關係。所以,長期不讓低優先級的任務佔用CPU是不合理的,尤其是桌面系統裏,這可以在一定程度上可以改善交互性。如果我們生生地在優先級調度上開個口子,不是不行,只是有些......,通過設置兩個優先級數組,適時交換兩者,實在是個十分巧妙的主意。只要任務的數量達到一定程度,飢餓任務的形成趨勢就可能越發明顯。這也是我剛纔爲什麼說Ingo的第一個理由不是不正確,只是沒說到點子上的原因。當然,即使是SPA算法,也有可能通過其他方法避免任務飢餓情況的發生。這裏沒有是非之分,在我們後面還要集中介紹的staircase算法裏,就是用調整任務優先級的方法避免飢餓任務的。

OK, 上段我們說到任務可能會感到“飢餓”。那我們怎麼判斷出這種情況呢?由於存在兩個優先級數組,活動優先級數組上的任務是可以很快得到運行的。可能飢餓的任 務只會發生在過期數組中。具體的判據需要綜合考慮,比如有運行時間上的考慮,運行中的任務數量、任務優先級的上考慮。這裏我們只關心與優先級有關的部分, 在判斷某運行隊列上的過期數組中任務是否飢餓的宏(EXPIRED_STARVING)有如下一個條件:

    runqueue->currrent_task->static_prio> runqueue->best_expired_prio

best_expired_prio成員記錄了runqueue(運 行隊列)中過期數組中最高優先級任務的優先級,特別說明的是,它記錄的是靜態優先級。所以這裏是在與剛剛消耗完時間片的那個任務的靜態優先級相比較。如果 過期數組中的這個任務的優先級高就說明有任務“飢餓”了,具體是怎麼處理呢?大家可以研究一下時間片輪轉算法的主實現函數scheduler_tick(), 難度不是太大,如果在這裏用語言描述的既不精確,也有故意騙稿費之嫌。這裏沒用動態優先級作爲比較標準是有道理的:馬上會講到,動態優先級只是任務在一個 時間片內的臨時優先級,它是根據任務的動態運行情況綜合評估出來,它不是用戶直接可控的,並不能準確代表要運行它的用戶的直接意圖。靜態優先級是用戶可以 直接控制的,並且只有用戶通過系統調用纔可以改變。因此判斷任務的飢餓與否也與用戶的取向有一定關係。高靜態優先級任務數量越多,判斷任務飢餓的標準越 低,從而交換優先級數組的效率就越少,帶來的直接性能影響就是吞吐量提高,但交互性會有所降低。

順便說一下,有些老兄可能覺得100多個優先級的搜索過程也不快呀,實際上,Linux在搜索優先級時也並不是真的去遍歷每個任務隊列檢查它們是否爲空。首先,Linux爲每個優先級數組都準備了一個對應的整數數組,這個整數數組中的每個位代表優先級數組中一個任務隊列,只要我們增加某個任務到任務隊列中去,就會將對應位置1,如果某個任務隊列爲空,就將對應的比特位清零。這兩個動作正是我們前面看到的enqueue_task()dequeue_task()的功能。比特級的置位和清零,搜索,在許多體系結構上都有單獨的指令支持,比如在i386上是它們是btslbtrlbsfl等指令。所以這個過程是很快的。

六、動態優先級裏的貓膩。

        動態優先級,又是個什麼玩意兒呢?

        相對於看似簡單實則內涵豐富的靜態優先級,動態優先級則剛好相反,它的計算比靜態優先級複雜一點,但其中道理很大一部分已經在介紹優先級數組時提到了。

        還是先看看代碼吧:



staticint effective_prio(task_t *p)
{
    int bonus, prio;
   
    bonus = CURRENT_BONUS(p)-MAX_BONUS / 2;
    prio = p->static_prio -bonus;
   
    /* MAX_RT_PRIO = 100 */
    /* MAX_PRIO = 140 */
    /* 小於MAX_RT_PRIO的優先級,只用實時任務。*/
    if(prio < MAX_RT_PRIO)        
        prio = MAX_RT_PRIO;
    if(prio > MAX_PRIO-1)
        prio = MAX_PRIO-1;
    return prio;
}

我想這裏對大家唯一感到神祕的就是CURRENT_BONUS(p)MAX_BONUS了。(如果不是這樣,我的這篇文章可就真是太失敗了~)。OK,讓我們一一揭開這些最後的謎底:

#define MAX_BONUS (MAX_USER_PRIO * PRIO_BONUS_RATIO / 100)

#define CURRENT_BONUS(p) /

(NS_TO_JIFFIES((p)->sleep_avg) * MAX_BONUS / /

MAX_SLEEP_AVG)

要想摸透這兩個宏,還得從任務的“交互性”說起。剛纔我們說到,從“平均休眠時間”裏我們得到了任務的是計算密集型的還是I/O密集型的。但僅僅分類不是我們的目標,我們的最終目的是對I/O密集型任務做些特殊照顧。那麼用什麼手段呢?時間片,我們已經討論過了,它和任務種類根本不靠譜。那就只有通過任務的優先級了,這就是任務的另一種優先級,即耳聞已久的“動態優先級",它與靜態優先級不同,這個優先級隨任務的實際運行情況(主要是平均休眠時間)調整。這個調整就是上面的代碼中反覆提到的“BONUS”了,它是一個“代數BONUS”,可正可負。既然有獎有罰。就得有個額度吧,這就是MAX_BONUS了,根據它的定義:

MAX_BONUS = ( 40 * 25 ) / 100 = 10

        這裏的MAX並不是直觀上的最大獎勵值,理解它的最好方法就是分析CURRENT_BONUS(p),它計算任務p應該獲得多少獎勵:

CURRENT_BONUS(task) = task->sleep_avg * MAX_BONUS / MAX_SLEEP_AVG

其中:

MAX_SLEEP_AVG是我們硬性規定的最大平均休眠時間,如果任務的休眠時間大於它,也強制將其設置成這個最大值。

CURRENT_BONUS(task)中的計算公式是不是有點繞?我作點手腳,大家再看看:

       CURRENT_BONUS(task) = task->sleep_avg MAX_SLEEP_AVG * MAX_BONUS

        在繼續之前,大家可以思考一下爲什麼內核使用第一種看似“彆扭”的寫法呢?限時120秒。計時開始!

        ......

        提醒一下下,回憶C語言的整數操作符/的運行規則。有答案了嗎?如果還沒有,看來你得複習複習C了。

        OK,言歸正傳。我想大家應該清楚了,再明確一下,CURRENT_BONUS(task)的返回的值的範圍是[010],再結合effective_prio()中第一行代碼:

        bonus = CURRENT_BONUS(p) - MAX_BONUS / 2;

        即,bonus = CURRENT_BONUS(p) - 5;

        我們現在就可以知道一個任務的動態優先級的獎勵最大是-5,懲罰值是+5。也就是說每次動態優先級的調整幅度是上下5級。不過還是這裏還是有點繞,以至於有時候我寫代碼也被繞了進去:不管是靜態優先級,還是動態優先級,優先級數值越高,代表的優先級越低。所以,-5是獎勵任務,+5是在懲罰任務。不僅僅是Linux,許多UNIX類的操作系統都是這設計的。

七、說了這麼半天,我還是不知道調度到底是怎麼工作的!!!

       的確,這裏說了一溜八開的Linux是怎麼通過擺弄優先級和時間片收拾一個個任務的,但這一切究竟是怎麼發生的哩,也就是我們怎麼進入調度過程的呢?雖然這不是本文的重點,但是如果不說一下就感覺有些不完整。

也不用把調度程序想像成“玉皇大帝”似的,它也是由代碼堆砌而成,只是功能和入口特殊一些罷了:毛德操老前輩有句比喻很貼切:“時鐘中斷好比系統的脈膊。”,它是實現時間片輪轉算法的命門所在。定時器每秒產生固定數量的時鐘中斷,在這個中斷的處理函數裏會調用scheduler_tick()函數。這個scheduler_tick()負責減少當前任務的時間片,並且也維護上面所說的“平均休眠時間”,只要當前任務的時間片使用乾淨了,就重新計算新時間片和動態優先級,然後做一些清理工作,最終觸發所謂的主調度功能入口函數schedule()schedule()主要負責實現優先級調度算法,比如搜索下一個要佔用CPU的任務,交換優先級數組等等都是在這個函數裏完成的,最後,它會導致任務的切換。

        進入調度的途徑當然不只一種:任務還可以通過休眠、主動放棄方法進入調度。主動休眠,比如任務使用waitpid()、啓動一次IO操作;有些系統調用也有使其調用者放棄CPU的副作用。可能大家都知道,2.6內核可以配置爲支持內核級搶佔。“搶佔”也是進入調度的一種方式,它也不像想像中的那麼神祕和無規律似的“霸道”,可惜詳細描述搶佔已經超出本文的目標,許多較新的內核材料對搶佔也有不錯的綜述。有機會,我們下次就再瞧瞧它。

八、再用餘光瞥一眼Staircase

        Staircase,這是我們上面反覆提到的另一種調度算法。它是有名的CK補丁的一個重要組成部分,主要用於提升Linux內核任務調度的交互性和及時響應能力,其目標是應用於桌面系統。如果諸位還沒把操作系統的知識還給老師的話,那麼可以將staircase算法看成是‘多級隊列調度算法’的一個變種,如果有讀者熟悉VMS的話,那麼,有人還說,Staircase讓他想起了VMS的調度。

        下面描述的是staircase v12版本,它和以前的v7/v8已經有些不同了:

        Staircase調度的基礎設施,與默認的Ingo調度相同,它也有“優先級數組”、“運行隊列”的數據結構,並且絕大多數成員還保持不變。在數據結構唯一重要的變化就是每個運行隊列只有一個優先級數組了,但算法複雜一些。

        在Stairecase中,每個任務初始時會用靜態優先級初始化動態優先級開始運行,每過一個時間片就向低優先級“降級”。如果動態優先級最終降成最低優先級(MAX_PRIO-2),就再從靜態優先級開始重複這個“降級”過程。不一定每次都最終“降級”到最低優先級。根據任務的運行情況,調度算法會增長其在最高優先級上的時間片。

        在這個算法裏,任務在佔用CPU時 有兩種時間片。這裏暫稱之爲一個“粗粒度時間片”,一個是“細粒度時間片”。粗粒度時間片由一定數量的細粒度時間片組成。在每個粗粒度時間片內,任務的優 先級由任務自身的靜態優先級向低優先級依次降低,純計算密集型任務在每個優先級上停留的時間都一樣,即一個細粒度時間片。I/O活動比較多的任務,會在最高優先級上停留的時間比較長,由於粗粒度時間片是固定的,所以這導致IO密集型任務不會滑落到很低的優先級上。不過,即使停留在高優先級上的時間片比較長,任務也不會一次使用完畢,它會像Ingo的算法那樣,將長時間片再打成幾個片斷分散執行。所以,所謂的“細粒度時間片”其實可以直接對應到Ingo算法中的時間片概念上;而“粗粒度時間片”可以看成是“細粒度時間片”的一個輪迴週期,它是“細粒度時間片”的整數倍。

        由上可見,在Staircase每個任務都頻繁徘徊在多個優先級之間,這一方面可以帶來交互性和響應能力的提高(這一點不太容易定量測量,但可以通過在重負荷下比較連續輸入字符的連續性得出結論),但是另一方面,細粒度時間片通常都比Ingo算法中的時間片短很多,而每次調整優先級實際上都是一次任務切換過程(這裏應該有優化的餘地!),這不可避免地帶來吞吐量的下降,這可以通過大規模複製文件測量,複製速率大約下降了30%

不過,值得指出的是,Staircase是可以在運行時調整調度參數的,我們可以選擇配置是傾向於桌面系統的,還是服務器系統的,上面的吞吐量下降30%是在設置爲“桌面系統”後測量得到的結果。如果設置爲服務器系統的調度方案,其內部會通過一些條件判斷,減少這種原因引起的任務切換次數。

十、一些閱讀代碼的建議。

        相對於複雜的虛存系統而言,任務調度是比較簡單的內核組成部件了。從個人經驗上上看,我也推薦從任務、調度這一塊內容開始閱讀內核。因爲它的難度不太大,至少牽扯到內核其它部分較少。但仍有一些必要的預備知識需要掌握:GCC內嵌彙編語言方法,特定體系結構(一般就是指i386)方面的知識。

        關於Linux調度的材料最好的可能就是這份來自於SGI公司的文檔《Understanding the Linux 2.6.8.1 CPU Scheduler》了,雖然其中有些內容與最新的代碼不符,但絕大多數介紹非常有閱讀價值。這篇文檔可以在網上很容易的找到。

閱讀代碼好比是在打一場艱苦的攻堅戰,武器對戰士至關重要。選擇好代碼閱讀工具可以事半功倍,當然每個人的習慣不同,在選擇工具時通常也是隻選合用的,最好的未必就合用。我個人絕大多數時間在Linux上工作,所以這裏只有Linux上的經驗可以介紹:我個人比較喜歡Source-Navigator Extensions,這是一個可以sf.net上找到的開放源代碼的軟件。它是對標準的Source-Navigator做了一些非常好用的擴展功能,截圖見下圖。缺點是需要使用者“寬容些”,因爲它有些bug可能導致無法在項目添加新目錄,甚至自身崩潰,例如在項目中增加單個文件時是會出現Stack trace窗口的。如果筆者熟悉Tk並且有時間的話,倒是很願意修復這些問題。不用擔心,這些無法使用的功能,都是可以想法繞過去的,筆者認爲它利大於弊。另一個推薦使用的工具是LXR。這是一個基於Web的代碼閱讀工具,此外,GNU Global也可以用用。



        如果你還打算研讀虛存系統,除了經典的《Understanding the Linux Virtual Memory Manager》,還建議先看一下這篇文章《2QA Low Overhead High Performance Buffer Management Replacement Algorithm》,因爲本質上,Linux現有的頁面替換算法是簡化過的2Q算法。如果你有精力,還可以簡要閱讀一下關於Clock-ProCAR算法的文章。現在似乎有這樣一種趨勢,頁面替換算法在朝自適應(Adaptive)方向的發展。此外,有篇關於《The Performance Impact of Kernel Prefetching on Buffer Cache Replacement Algorithms》的文章,結論雖然不出人意料,但也非常值得一讀(感謝周應超兄臺的推薦)

十一、結語。

        行文至此,聯想起以前翻譯過Python的一些東西,小有感觸,不管是翻譯還是寫作都不是件輕鬆之事。這篇文章雖然不長,卻也足足橫跨了俺幾周時間,但如果能激起大家能對Linux內核的興趣的話,我的這些時間就是非常物超所值了。同時也希望《鼠眼看Linux中斷處理》、《鼠眼看Linux VMM》,或者《鷹眼看Linux ABC》等類似文章的出現。

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