ucore作業系統學習(六) ucore lab6執行緒排程器

小熊餐館發表於2020-12-09

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上:https://github.com/1399852153/ucore_os_lab (fork自官方倉庫)中的lab6_answer

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

相關文章