ucore作業系統學習(六) ucore lab6執行緒調度器

1. ucore lab6介紹

  ucore在lab5中實現了較為完整的進程/執行緒機制,能夠創建和管理位於內核態或用戶態的多個執行緒,讓不同的執行緒通過上下文切換並發的執行,最大化利用CPU硬體資源。ucore在lab5中使用FIFO的形式進行執行緒調度,不同的執行緒按照先來先服務的策略,直到之前創建的執行緒完全執行完畢並退出,後續的執行緒才能獲得執行機會。

  FIFO的策略固然簡單,但實際效果卻非常差。在非搶佔的FIFO調度策略中,如果之前的執行緒任務耗時很長,將導致後續的執行緒遲遲得不到執行機會而陷入飢餓;即使後續的執行緒是短任務、能很快的執行完,也會由於被迫等待前面長任務執行緒的執行而導致系統整體的任務吞吐量大幅下降。如果前面執行緒出現了bug陷入死循環,則整個系統將會被阻塞。

  為此,電腦科學家提出了很多執行緒調度策略來解決這一問題。例如在批處理作業系統中除了FIFO先來先服務策略,還有短任務優先最短剩餘時間優先等多種非搶佔式調度演算法;而在互動式作業系統中又提出了時間片輪轉調度優先順序調度多級隊列調度基於搶佔的調度演算法(在影片公開課的原理篇以及《現代作業系統》中的調度一節對此都有著詳細介紹)。

  ucore在lab6實現了可以滿足上述不同調度演算法的的執行緒調度框架。lab6中採用了和之前記憶體調度演算法框架類似的方式,通過函數指針集合,以面向對象的方式抽象出了一個執行緒調度器框架。並在參考答案中實現了一個基於執行緒優先順序、時間片輪轉的、搶佔式的stride調度演算法。

  通過lab6的學習,使得原本枯燥乏味的關於各種調度演算法的純理論知識有了實踐的機會,可以更深入的了解作業系統執行緒調度演算法的工作機制

  lab6是建立在之前實驗的基礎之上的,需要先理解之前的實驗內容才能順利理解lab6的內容。

可以參考一下我關於前面實驗的部落格:

  1. ucore作業系統學習(一) ucore lab1系統啟動流程分析

  2. ucore作業系統學習(二) ucore lab2物理記憶體管理分析

  3. ucore作業系統學習(三) ucore lab3虛擬記憶體管理分析

  4. ucore作業系統學習(四) ucore lab4內核執行緒管理

  5. ucore作業系統學習(五) ucore lab5用戶進程管理

2. ucore lab6實驗細節分析

  ucore在lab6中的改進大致可以分為幾個部分:

  1. 為了支援基於優先順序的stride調度演算法,改進執行緒控制塊,加入了相關的新欄位。

  2. 以面向對象的方式實現了基本的執行緒調度器框架, 在對應的地方以介面的形式(函數指針)進行訪問;不同的調度演算法只需要實現對應的調度器框架介面即可簡單的接入ucore。

  3. 實現了stride執行緒調度演算法。

2.1 lab6中執行緒控制塊的變化

  lab6中為了能夠實現執行緒調度框架,需要在執行緒控制塊中額外引入例如就緒隊列、執行緒調度優先順序、時間片等屬性,用於兼容多種調度演算法。

lab6中的proc_struct:

/**
 * 進程式控制制塊結構(ucore進程和執行緒都使用proc_struct進行管理)
 * */
struct proc_struct {
    。。。 只列出了lab6中新增加的屬性項

    // 包含該執行緒的就緒隊列(多級多列調度時,系統中存在多個就緒隊列)
    struct run_queue *rq;                       // running queue contains Process
    // 就緒隊列節點
    list_entry_t run_link;                      // the entry linked in run queue
    // 執行緒能夠佔有的CPU時間片
    int time_slice;                             // time slice for occupying the CPU
    // lab6中支援stride演算法的斜堆節點
    skew_heap_entry_t lab6_run_pool;            // FOR LAB6 ONLY: the entry in the run pool
    // lab6中支援stride演算法的當前執行緒stride步長
    uint32_t lab6_stride;                       // FOR LAB6 ONLY: the current stride of the process 
    // 執行緒的特權級
    uint32_t lab6_priority;                     // FOR LAB6 ONLY: the priority of process, set by lab6_set_priority(uint32_t)
};

就緒隊列介紹:

  為了能夠支援不同的執行緒調度演算法,lab6中引入了就緒隊列的概念。就緒隊列(run_queue)是一個包含了所有就緒態執行緒集合的隊列結構,能夠在有就緒執行緒出現時令其高效的入隊,當執行緒脫離就緒態時高效的將其從就緒隊列中移除。

  就緒隊列是一個抽象的隊列,其底層實現可以是雙向鏈表,平衡二叉樹或是堆等等。由於堆結構中的元素只需要滿足堆序性,而不像平衡二叉樹一樣需要滿足全局的有序性,因此其整體效率還要略高於平衡二叉樹,很適合用來實現優先順序隊列。這也是lab6中引入斜堆skew_heap作為stride調度演算法中就緒隊列的底層實現的原因。

2.2 執行緒調度器框架介紹

  前面提到過,ucore抽象出了一系列的調度器的行為,並通過函數指針以面向對象的形式提供服務。調度器本身比較簡單,主要包括以下幾部分(位於/kern/schedule/sched.[ch]):

  1. 就緒隊列的初始化、入隊、出隊操作(init、enqueue、dequeue)

  2. 由特定的就緒演算法處理器實現,從就緒隊列中挑選下一個進行調度的執行緒(pick_next)

  3. 當時鐘中斷髮生時,令調度器感知並修改對應數據的邏輯(proc_tick)。例如在時間片輪轉演算法中每當發生時鐘中斷時,減少當前執行緒對應的時間片。

執行緒調度器框架:  

// The introduction of scheduling classes is borrrowed from Linux, and makes the 
// core scheduler quite extensible. These classes (the scheduler modules) encapsulate 
// the scheduling policies. 
struct sched_class {
    // the name of sched_class
    // 調度類的名字
    const char *name;
    // Init the run queue
    // 初始化就緒隊列
    void (*init)(struct run_queue *rq);
    // put the proc into runqueue, and this function must be called with rq_lock
    // 將一個執行緒加入就緒隊列(調用此方法一定要使用隊列鎖調用(lab6中直接關中斷來避免並發))
    void (*enqueue)(struct run_queue *rq, struct proc_struct *proc);
    // get the proc out runqueue, and this function must be called with rq_lock
    // 將一個執行緒從就緒隊列中移除(調用此方法一定要使用隊列鎖調用(lab6中直接關中斷來避免並發))
    void (*dequeue)(struct run_queue *rq, struct proc_struct *proc);
    // choose the next runnable task
    // 調度框架從就緒隊列中選擇出下一個可運行的執行緒
    struct proc_struct *(*pick_next)(struct run_queue *rq);
    // dealer of the time-tick
    // 發生時鐘中斷時,調度器的處理邏輯
    void (*proc_tick)(struct run_queue *rq, struct proc_struct *proc);
};

2.3 執行緒調度器的工作原理

  通過分析執行緒調度器具體在哪些地方被調用,可以更好的理解ucore中執行緒調度器的工作原理。

sched_class.init使用分析:

  在總控函數kern_init中,調用了sched_init函數用於初始化和調度器相關的數據結構。其中設置當前系統對應的調度框架(sched_class = &default_sched_class),並通過調度器框架的init函數進行了調度器的初始化。

/**
 * 初始化任務調度器
 * */
void
sched_init(void) {
    // 清空定時器隊列
    list_init(&timer_list);

    // 令當前的調度框架為default_sched_class(stride_schedule)
    sched_class = &default_sched_class;

    rq = &__rq;
    // 設置最大的時間片
    rq->max_time_slice = MAX_TIME_SLICE;
    // 初始化全局就緒隊列
    sched_class->init(rq);

    cprintf("sched class: %s\n", sched_class->name);
}

schedule執行緒調度函數分析:

  相比於lab5,在lab6中由於引入了執行緒調度器,因此schedule函數的執行緒調度邏輯進行了一定的改動。

  schedule函數被調用時,意味著需要當前執行緒讓出CPU而令另一個就緒執行緒獲得CPU。lab6實現了調度框架後,調度函數需要完成幾個步驟:

  1. 如果當前執行緒依然是就緒態,將其放入就緒隊列(enqueue),令其有機會再度被選中獲得CPU(比如特權級很高,很可能下一次調度依然被選中)。

  2. 由調度器根據特定的調度演算法實現,選出下一個需要獲取CPU的執行緒(pick_next)。

  3. 將挑選出的執行緒從就緒隊列中移除(dequeue)。(這麼做的目的我認為是因為被選中調度的執行緒將要變成運行態了,語義上不適合繼續放在就緒隊列中,造成理解上的困難)

  4. 被挑選的執行緒作為proc_run的參數,與當前執行緒進行上下文切換(與lab5中的邏輯一樣)。

/**
 * 就緒執行緒進行CPU調度
 * */
void
schedule(void) {
    bool intr_flag;
    struct proc_struct *next;
    // 暫時關閉中斷,避免被中斷打斷,引起並發問題
    local_intr_save(intr_flag);
    {
        // 令current執行緒處於不需要調度的狀態
        current->need_resched = 0;
        if (current->state == PROC_RUNNABLE) {
            // 如果當前執行緒依然是就緒態,將其置入就緒隊列(有機會再被調度演算法選出來)
            sched_class_enqueue(current);
        }

        // 通過調度演算法篩選出下一個需要被調度的執行緒
        if ((next = sched_class_pick_next()) != NULL) {
            // 如果選出來了,將其從就緒隊列中出隊
            sched_class_dequeue(next);
        }
        if (next == NULL) {
            // 沒有找到任何一個就緒執行緒,則由idleproc獲得CPU
            next = idleproc;
        }
        // 被選中進行調度執行的執行緒,被調度執行次數+1
        next->runs ++;
        if (next != current) {
            // 如果被選出來的執行緒不是current當前正在執行的執行緒,進行執行緒上下文切換,令被選中的next執行緒獲得CPU
            proc_run(next);
        }
    }
    local_intr_restore(intr_flag);
}

static inline void
sched_class_enqueue(struct proc_struct *proc) {
    if (proc != idleproc) {
        // 如果不是idleproc,令proc執行緒加入就緒隊列
        sched_class->enqueue(rq, proc);
    }
}

static inline void
sched_class_dequeue(struct proc_struct *proc) {
    // 將proc執行緒從就緒隊列中移除
    sched_class->dequeue(rq, proc);
}

static inline struct proc_struct *
sched_class_pick_next(void) {
    // 有調度框架從就緒隊列中挑選出下一個進行調度的執行緒
    return sched_class->pick_next(rq);
}

sched_class.proc_tick使用分析:

  在lab6中,時鐘中斷的處理邏輯中主動調用了調度器的proc_tick函數,使得調度器能感知到時鐘中斷的產生並調整調度相關的數據結構。

  例如在基於時間片輪轉調度的搶佔式執行緒調度演算法中,當周期性的時鐘中斷髮生時就減少當前執行緒所分配的的時間片。當發現為當前執行緒分配的時間片用完時,便強制進行一次執行緒調度,令其它的執行緒可以獲得CPU,避免長時間的飢餓。

trap_dispatch時間中斷處理邏輯:

static void
trap_dispatch(struct trapframe *tf) {
    char c;

    int ret=0;

    switch (tf->tf_trapno) { 
        。。。 省略其它中斷處理邏輯
        case IRQ_OFFSET + IRQ_TIMER:

            ticks ++;
            assert(current != NULL);
            // 令調度框架得以監聽到時鐘中斷,修改對應的調度狀態
            sched_class_proc_tick(current);
            break;
        。。。 省略其它中斷處理邏輯
    }
}

void
sched_class_proc_tick(struct proc_struct *proc) {
    if (proc != idleproc) {
        // 處理時鐘中斷,令調度框架更新對應的調度參數
        sched_class->proc_tick(rq, proc);
    }
    else {
        // idleproc處理時鐘中斷,需要進行調度
        proc->need_resched = 1;
    }
}

2.4 stride調度演算法實現

  在了解了ucore的執行緒調度器是如何工作之後,下面分析執行緒調度器的實現。

  執行緒調度器是一個抽象的介面框架,可以簡單的接入不同的調度演算法實現。在ucore lab6的參考程式碼示例中實現了名為stride的執行緒調度演算法(大致工作原理在實驗公開課影片中有提到,比較容易理解)。

stride調度演算法就緒隊列初始化:

/*
 * stride_init initializes the run-queue rq with correct assignment for
 * member variables, including:
 *
 *   - run_list: should be a empty list after initialization.
 *   - lab6_run_pool: NULL
 *   - proc_num: 0
 *   - max_time_slice: no need here, the variable would be assigned by the caller.
 *
 * hint: see proj13.1/libs/list.h for routines of the list structures.
 */
static void
stride_init(struct run_queue *rq) {
    /* LAB6: YOUR CODE */

    // 初始化就緒隊列
    list_init(&(rq->run_list));
    rq->lab6_run_pool = NULL;
    rq->proc_num = 0;
}

/**
 * 就緒隊列
 * */
struct run_queue {
    list_entry_t run_list;
    unsigned int proc_num;
    int max_time_slice;
    // For LAB6 ONLY 斜堆堆頂節點
    skew_heap_entry_t *lab6_run_pool;
};

stride調度演算法入隊、出隊和挑選下一個就緒執行緒:

  stride調度演算法的入隊、出隊和挑選下一個就緒執行緒的邏輯比較類似,因此放到一起說明。

  就緒隊列作為一個抽象數據結構,底層可以由各種常用的數據結構實現,在lab6給出的程式碼實例中通過USE_SKEN_HEAP宏來決定就緒隊列的底層實現。如果USE_SKEN_HEAP為真則使用斜堆,如果USE_SKEN_HEAP不為真則使用普通的雙向鏈表來實現。

  斜堆結構實現的就緒隊列其入隊、出隊操作能達到O(logn)的對數複雜度,比其雙向鏈表實現的就緒隊列入隊、出隊效率O(n)要高出一個數量級(限於篇幅就不在這裡展開關於斜堆的內容了)。

/*
 * stride_enqueue inserts the process ``proc'' into the run-queue
 * ``rq''. The procedure should verify/initialize the relevant members
 * of ``proc'', and then put the ``lab6_run_pool'' node into the
 * queue(since we use priority queue here). The procedure should also
 * update the meta date in ``rq'' structure.
 *
 * proc->time_slice denotes the time slices allocation for the
 * process, which should set to rq->max_time_slice.
 * 
 * hint: see proj13.1/libs/skew_heap.h for routines of the priority
 * queue structures.
 */
static void
stride_enqueue(struct run_queue *rq, struct proc_struct *proc) {
    /* LAB6: YOUR CODE */
#if USE_SKEW_HEAP
    // 使用斜堆實現就緒隊列(lab6中默認USE_SKEW_HEAP為真)
    // 將proc插入就緒隊列,並且更新就緒隊列的頭元素
    rq->lab6_run_pool =
            skew_heap_insert(rq->lab6_run_pool, &(proc->lab6_run_pool), proc_stride_comp_f);
#else
    // 不使用斜堆實現就緒隊列,而是使用雙向鏈表實現就緒隊列
    assert(list_empty(&(proc->run_link)));
    // 將proc插入就緒隊列
    list_add_before(&(rq->run_list), &(proc->run_link));
#endif
     if (proc->time_slice == 0 || proc->time_slice > rq->max_time_slice) {
         // 入隊時,如果執行緒之前時間片被用完進行過調度則time_slice會為0,再次入隊時需要重置時間片(或者時間片未正確設置,大於了就緒隊列的max_time_slice)
         // 令其time_slice=rq->max_time_slice(最大分配的時間片)
         proc->time_slice = rq->max_time_slice;
     }
     // 令執行緒和就緒隊列進行關聯
     proc->rq = rq;
     // 就緒隊列中的就緒執行緒數加1
     rq->proc_num ++;
}

/*
 * stride_dequeue removes the process ``proc'' from the run-queue
 * ``rq'', the operation would be finished by the skew_heap_remove
 * operations. Remember to update the ``rq'' structure.
 *
 * hint: see proj13.1/libs/skew_heap.h for routines of the priority
 * queue structures.
 */
static void
stride_dequeue(struct run_queue *rq, struct proc_struct *proc) {
     /* LAB6: YOUR CODE */
#if USE_SKEW_HEAP
    // 使用斜堆實現就緒隊列(lab6中默認USE_SKEW_HEAP為真)
    // 將proc移除出就緒隊列,並且更新就緒隊列的頭元素
    rq->lab6_run_pool =
          skew_heap_remove(rq->lab6_run_pool, &(proc->lab6_run_pool), proc_stride_comp_f);
#else
    // 不使用斜堆實現就緒隊列,而是使用雙向鏈表實現就緒隊列
    assert(!list_empty(&(proc->run_link)) && proc->rq == rq);
    // 將proc移除出就緒隊列,並且更新就緒隊列的頭元素
    list_del_init(&(proc->run_link));
#endif
    // 移除完成之後,就緒隊列所擁有的執行緒數減1
    rq->proc_num --;
}
/*
 * stride_pick_next pick the element from the ``run-queue'', with the
 * minimum value of stride, and returns the corresponding process
 * pointer. The process pointer would be calculated by macro le2proc,
 * see proj13.1/kern/process/proc.h for definition. Return NULL if
 * there is no process in the queue.
 *
 * When one proc structure is selected, remember to update the stride
 * property of the proc. (stride += BIG_STRIDE / priority)
 *
 * hint: see proj13.1/libs/skew_heap.h for routines of the priority
 * queue structures.
 */
static struct proc_struct *
stride_pick_next(struct run_queue *rq) {
     /* LAB6: YOUR CODE */
#if USE_SKEW_HEAP
    // 使用斜堆實現就緒隊列(lab6中默認USE_SKEW_HEAP為真)
    if (rq->lab6_run_pool == NULL) return NULL; // 就緒隊列為空代表沒找到,返回null
    // 獲取就緒隊列的頭結點,轉換為所關聯的執行緒返回
    struct proc_struct *p = le2proc(rq->lab6_run_pool, lab6_run_pool);
#else
    // 不使用斜堆實現就緒隊列,而是使用雙向鏈表實現就緒隊列
    // 獲取雙向鏈表的頭結點
    list_entry_t *le = list_next(&(rq->run_list));

    if (le == &rq->run_list)
        // 雙向鏈表為空代表沒找到,返回null
        return NULL;
     
    struct proc_struct *p = le2proc(le, run_link);
    le = list_next(le);
    // 遍歷整個雙向鏈表,找到p->lab6_stride最小的那一個(p)
    while (le != &rq->run_list)
    {
        struct proc_struct *q = le2proc(le, run_link);
        if ((int32_t)(p->lab6_stride - q->lab6_stride) > 0){
            // 如果執行緒q的lab6_stride小於當前lab6_stride最小的執行緒p
            // 令p=q,即q成為當前找到的lab6_stride最小的那一個執行緒
            p = q;
        }
        // 指向雙向鏈表的下一個節點,進行遍歷
        le = list_next(le);
    }
#endif
    // 最終找到的執行緒指針p指向的是lab6_stride最小的那一個執行緒,即按照stride調度演算法被選中的那一個執行緒
    if (p->lab6_priority == 0){
        // 特權級為0比較特殊代表最低許可權,一次的步進為BIG_STRIDE
        p->lab6_stride += BIG_STRIDE;
    }else{
        // 否則一次的步進為BIG_STRIDE / p->lab6_priority
        // 即lab6_priority(正整數)越大,特權級越高,一次步進的就越小
        // 更容易被stride調度演算法選中,相對而言被執行的次數也就越多,因此滿足了執行緒特權級越高,被調度越頻繁的需求
        p->lab6_stride += BIG_STRIDE / p->lab6_priority;
    }
    return p;
}

stride調度演算法時鐘中斷處理:

  stride調度演算法是搶佔式的。在stride_enqueue中,每當就緒隊列入隊時都會為其分配一定的時間片,當執行緒運行的過程中發生時鐘中斷時則會通過stride_proc_tick函數扣減對應的時間片。當為執行緒分配的時間片扣減為0時,則會將執行緒的need_resched設置為1。

  在trap中斷處理函數中,當對應中斷號的處理常式返回時會單獨的檢查need_resched的值,當發現為1時,則會觸發schedule函數進行一次強制的執行緒調度,從而令當前時間片扣減為0的執行緒得以讓出CPU,使其它的就緒執行緒能得到執行的機會。這也是stride調度演算法被稱為搶佔式調度演算法的原因:無論當前執行的執行緒是否主動的讓出cpu,在分配的時間片用完之後,作業系統將會強制的撤下當前執行緒,進行一次調度。

/*
 * stride_proc_tick works with the tick event of current process. You
 * should check whether the time slices for current process is
 * exhausted and update the proc struct ``proc''. proc->time_slice
 * denotes the time slices left for current
 * process. proc->need_resched is the flag variable for process
 * switching.
 */
static void
stride_proc_tick(struct run_queue *rq, struct proc_struct *proc) {
     /* LAB6: YOUR CODE */
     if (proc->time_slice > 0) {
         // 如果執行緒所分配的時間片還沒用完(time_slice大於0),則將所擁有的的時間片減1
          proc->time_slice --;
     }
     if (proc->time_slice == 0) {
         // 當時間片減為0時,說明為當前執行緒分配的時間片已經用完,需要重新進行一次執行緒調度
         proc->need_resched = 1;
     }
}

trap函數(中斷處理):

/* *
 * trap - handles or dispatches an exception/interrupt. if and when trap() returns,
 * the code in kern/trap/trapentry.S restores the old CPU state saved in the
 * trapframe and then uses the iret instruction to return from the exception.
 * */
void
trap(struct trapframe *tf) {
    // dispatch based on what type of trap occurred
    // used for previous projects
    if (current == NULL) {
        trap_dispatch(tf);
    }
    else {
        // keep a trapframe chain in stack
        struct trapframe *otf = current->tf;
        current->tf = tf;
    
        bool in_kernel = trap_in_kernel(tf);
    
        trap_dispatch(tf);
    
        current->tf = otf;
        if (!in_kernel) {
            if (current->flags & PF_EXITING) {
                // 如果當前執行緒被殺了(do_kill),將自己退出(被喚醒之後發現自己已經被判了死刑,自我了斷)
                do_exit(-E_KILLED);
            }
            if (current->need_resched) {
                // 可能執行了阻塞的系統調用等情況,need_resched為真,進行執行緒調度切換
                schedule();
            }
        }
    }
}

3. 總結

  lab6是一個承上啟下的實驗,ucore在lab4、lab5中實現了執行緒機制,而lab6在執行緒調度切換上做了拓展。正是因為搶佔式執行緒調度機制的出現,使得執行緒可能在執行時的任意時刻被打斷。並發的執行緒執行流在提高cpu利用率的同時也帶來了執行緒安全的問題,也引出了後續lab7中將要介紹的執行緒同步概念。

  通過lab6實驗的學習,可以更深刻的理解作業系統中執行緒調度的工作原理,也有機會在ucore中親手實現作業系統書籍上介紹的各種調度演算法,使得其不再是抽象的純理論知識,理解時印象會更深刻。

  這篇部落格的完整程式碼注釋在我的github上://github.com/1399852153/ucore_os_lab (fork自官方倉庫)中的lab6_answer

  希望我的部落格能幫助到對作業系統、ucore os感興趣的人。存在許多不足之處,還請多多指教。