天天看點

uCore作業系統程式設計實驗手記(六)實驗uCore程序排程的有關準備Lab6

uCore作業系統程式設計實驗手記(六)

  • 實驗uCore程序排程的有關準備
    • 排程時機和系統排程架構
    • Round Robin時間片輪轉算法
    • 程序切換的過程
  • Lab6
    • 使用 Round Robin 排程算法
    • 實作 Stride Scheduling 排程算法

實驗uCore程序排程的有關準備

實驗四、實驗五是uCore作業系統與核心線程、使用者程序建立有關的内容。其實在前兩個實驗中,已經或多或少地接觸到了程序排程的内容,隻不過遇到時沒怎麼深究。實驗六的内容就是探索uCore作業系統的排程器,了解排程器的原理、實作,徹底弄懂程序切換的過程。首先還是先進行有關的準備,看有什麼資料結構和接口可用,然後看RR排程算法的實作機制,最後理清switch_to()函數的實作過程。

排程時機和系統排程架構

欲清除排程器的實作,首先要弄懂排程的時機。即什麼時候要/可以執行程序排程?系統核心可以被排程、搶占嗎?在uCore中,系統核心是不能被搶占的。但是這與響應中斷、甚至嵌套中斷并不沖突。這個系統核心不能被搶占的意思是,執行核心代碼時,不能排程去執行其他程序。但有的系統卻可以被搶占,這與要同步機制的支援。

話說回來,什麼時候要/可以執行程序排程?這裡列舉幾個:

1.中斷傳回時。這是比較典型的,是我記住的第一個程序排程時機。

2.請求的服務不能立刻得到而阻塞時。

3.程序主動放棄CPU使用權。

4.和排程算法有關,如被高優先級搶占、時間片用完時…

注意,在uCore中隻有使用者程序是可以在任意點被搶占的。

wakup_proc()函數、schedule()函數、run_timer_list()函數是uCore系統完成程序排程的3個核心函數,最重要的是schedule()函數。實驗指導書中指出,如果我們能夠讓這三個排程相關函數的實作與具體排程算法無關,那麼就可以認為ucore實作了一個與排程算法無關的排程架構。實際上,uCore系統定義排程器接口時,也是這麼考慮的。

wakeup_proc()函數其實完成了把一個就緒程序放入到就緒程序隊列中的工作,為此還調用了一個排程類接口函數sched_class_enqueue(),這使得wakeup_proc()的實作與具體排程算法無關。

run_timer_list()函數在每次timer中斷處理過程中被調用,進而可用來調用排程算法所需的timer時間事件感覺操作,調整相關程序的程序排程相關的屬性值。通過調用排程類接口函數sched_class_proc_tick(),使得此操作與具體排程算法無關。

schedule()函數完成了與排程架構和排程算法相關三件事情:把目前繼續占用CPU執行的運作程序放放入到就緒程序隊列中,從就緒程序隊列中選擇一個“合适”就緒程序,把這個“合适”的就緒程序從就緒程序隊列中摘除。通過調用三個排程類接口函數sched_class_enqueue()、sched_class_pick_next()、sched_class_enqueue() 來使得完成這三件事情與具體的排程算法無關。

可見,實作排程算法,最核心的就是實作以下的幾個函數(sched.c):

sched_class_enqueue()
    sched_class_dequeue()
    sched_class_pick_next()
    sched_class_proc_tick()
           

是以uCore系統定義了如下排程接口類,一目了然,上述“關鍵函數”最終都對應調用下述類中的函數(sched.h):

struct sched_class {
     // 排程器的名字
     const char *name;
     // 初始化運作隊列
     void (*init) (struct run_queue *rq);
     // 将程序 p 插入隊列 rq
     void (*enqueue) (struct run_queue *rq, struct proc_struct *p);
     // 将程序 p 從隊列 rq 中删除
     void (*dequeue) (struct run_queue *rq, struct proc_struct *p);
     // 傳回 運作隊列 中下一個可執行的程序
     struct proc_struct* (*pick_next) (struct run_queue *rq);
     // timetick 處理函數
     void (*proc_tick)(struct  run_queue* rq, struct proc_struct* p);
};
           

為與相應的接口配套,需要有合适的資料結構。在引入排程功能後,需要對程序描述符做适當的改進(proc.h):

struct proc_struct {
    // . . .
    // 該程序是否需要排程,隻對目前程序有效
    volatile bool need_resched;
    // 該程序的排程連結清單結構,該結構内部的連接配接組成了 運作隊列 清單,見後說明
    list_entry_t run_link;
    // 該程序剩餘的時間片,隻對目前程序有效
    int time_slice;
    // 指向程序所在的運作隊列
    struct run_queue *rq;
    // round-robin 排程器并不會用到以下成員,實作stride算法時候用到
    // 該程序在優先隊列中的節點,僅在 LAB6 使用,斜堆
    skew_heap_entry_t  lab6_run_pool;
    // 該程序的排程優先級,僅在 LAB6 使用
    uint32_t lab6_priority;
    // 該程序的排程步進值,僅在 LAB6 使用
    uint32_t lab6_stride;
};
           

通過資料結構 struct run_queue 來描述完整的 run_queue運作隊列(sched.h):

struct run_queue {
    //其運作隊列的哨兵結構,可以看作是隊列頭和尾
    list_entry_t run_list;
    //優先隊列形式的程序容器,隻在 LAB6 練習2中使用
    skew_heap_entry_t  *lab6_run_pool;
    //表示其内部的程序總數
    unsigned int proc_num;
    //每個程序一輪占用的最多時間片
    int max_time_slice;
};
           

在 ucore 架構中,運作隊列存儲的是目前可以排程的程序,是以,隻有狀态為runnable的程序才能夠進入運作隊列。目前正在運作的程序并不會在運作隊列中。

Round Robin時間片輪轉算法

關于RR算法的理論性的知識、優劣等,這裡不贅述。

這部分重點是看在系統代碼層面,如何實作了RR算法。上一小節,熟悉了系統定義的排程器接口。要實作RR排程算法,首先要執行個體化接口類:

struct sched_class default_sched_class = {
    .name = "RR_scheduler",
    .init = RR_init,
    .enqueue = RR_enqueue,
    .dequeue = RR_dequeue,
    .pick_next = RR_pick_next,
    .proc_tick = RR_proc_tick,
};
           

在sched.c的void sched_init(void)函數中,有:

static struct sched_class *sched_class;
sched_class = &default_sched_class;
           

當執行過初始化函數之後,系統排程類指針指向了RR算法的排程結構,RR算法開始生效。下面把上面提出的4個核心函數列出來看看RR是怎麼實作的。(default_sched.c)

//被sched_class_enqueue()所調用,執行“入隊”的功能
static void RR_enqueue(struct run_queue *rq, struct proc_struct *proc) {
    assert(list_empty(&(proc->run_link)));
    //rq->run_list是RR運作隊列的雙向鍊頭結點,proc->run_link是插入程序描述符的連結結構
    list_add_before(&(rq->run_list), &(proc->run_link));
    //time_slice是目前程序剩餘時間片,如果它為0或者超過了系統定義的最大值
    if (proc->time_slice == 0 || proc->time_slice > rq->max_time_slice) {
        proc->time_slice = rq->max_time_slice;//目前時間片設定為時間片最大值
    }
    proc->rq = rq;//指向該程序所在的運作隊列
    rq->proc_num ++;//運作隊列計數器的值+1
}

//被sched_class_dequeue()所調用,執行“出隊”的功能
static void RR_dequeue(struct run_queue *rq, struct proc_struct *proc) {
    assert(!list_empty(&(proc->run_link)) && proc->rq == rq);
    list_del_init(&(proc->run_link));//把程序在所在隊列中删除
    rq->proc_num --;//運作隊列計數器的值-1
}

//被sched_class_pick_next()所調用,執行“選擇”的功能
static struct proc_struct * RR_pick_next(struct run_queue *rq) {
    list_entry_t *le = list_next(&(rq->run_list));//選擇頭結點的下一個結點
    if (le != &(rq->run_list)) {//如果這個下個結點不是頭結點,也就是運作隊列非空
        return le2proc(le, run_link);//傳回該節點程序控制塊的指針
    }
    return NULL;//如果程序運作隊列為空,則傳回NULL
}

//被sched_class_proc_tick()所調用,執行“時間響應”的功能
static void RR_proc_tick(struct run_queue *rq, struct proc_struct *proc) {
    if (proc->time_slice > 0) {
        proc->time_slice --;//如果時間片還大于0,則剩餘時間片-1
    }
    if (proc->time_slice == 0) {
        proc->need_resched = 1;//如果時間片等于0,則需要排程标志位置1
    }
}
           

以上即是RR時間片輪轉算法的基本實作。

程序切換的過程

這裡面不是讨論排程算法,而是弄懂把一個程序從處理機取下,換上另一個程序的實作細節,換句話說,就是弄懂switch_to()函數的細節。

其位于kern/process/switch.S:

.globl switch_to
switch_to:                      # switch_to(from, to)
	//switch_to函數的原型是switch_to(from, to)
	//from是原程序的PCB中的context指針,to是新程序的PCB中的上下文結構的指針
    # save from's registers
    //把原程序的context指針儲存在EAX中
    movl 4(%esp), %eax          # eax points to from
    //調用switch_to()函數時,會把下一條指令位址EIP壓入棧中,現在把它彈出存在上下文中
    popl 0(%eax)                # save eip !popl
    movl %esp, 4(%eax)//ESP入棧
    movl %ebx, 8(%eax)//EBX入棧
    movl %ecx, 12(%eax)//ECX入棧
    movl %edx, 16(%eax)//EDX入棧
    movl %esi, 20(%eax)//ESI入棧
    movl %edi, 24(%eax)//EDI入棧
    movl %ebp, 28(%eax)//EBP入棧

    # restore to's registers
    //把新程序的context指針儲存在EAX中,思考調用switch_to(from, to)時參數入棧順序
    movl 4(%esp), %eax          # not 8(%esp): popped return address already
                                # eax now points to to
    movl 28(%eax), %ebp//按照自高址向低址順序逐個恢複上下文中的寄存器
    movl 24(%eax), %edi
    movl 20(%eax), %esi
    movl 16(%eax), %edx
    movl 12(%eax), %ecx
    movl 8(%eax), %ebx
    movl 4(%eax), %esp
	//上下文context結構中的EIP存放着新程序即将要執行的下一指令的位址,先壓棧
    pushl 0(%eax)               # push eip
	//伴随着一條ret指令,棧頂儲存的EIP值被傳入EIP寄存器,開始新程序執行
    ret
           

以上就是switch_to(from, to)函數的具體工作過程。

那麼問題來了:

1.原程序在執行時那麼多寄存器怎麼切換時就儲存了這麼幾個寄存器?

答案是,原程序在被中斷後,進入排程程式。這個中斷的中斷幀儲存了目前現場幾乎的所有資訊(儲存于原程序的核心棧)。當下一次再被排程到時,它實行層層函數調用傳回,當傳回到中斷時,再把所有寄存器的内容恢複出來。

2.考慮新老程序的核心棧是怎樣切換的?

切換核心棧,就是正确切換新老程序的ESP。由上面的過程可以看出,ESP是被正确切換的,切換到新程序的ESP應該能夠正确執行新程序的中斷傳回,進而正确執行新程序的代碼。那問題又來了,程序切換了,程序頁目錄切換了沒有?新的ESP值能否正确尋址新程序的核心棧?答案是,當然切換了!在執行switch_to(from, to)函數之前剛好切換過。多說無益,看關鍵代碼(proc.c):

void proc_run(struct proc_struct *proc) {
    if (proc != current) {//如果下一個要排程上的程序不是目前程序,說明合法
        bool intr_flag;
        struct proc_struct *prev = current, *next = proc;//設定prev/next指針
        local_intr_save(intr_flag);
        {
            current = proc;//設定current指針為新程序
            //在任務狀态段中裝載新程序核心棧指針ESP0,注意不是ESP
            load_esp0(next->kstack + KSTACKSIZE);
            lcr3(next->cr3);//加載新程序的頁目錄CR3!!!此時,切換了頁表
            switch_to(&(prev->context), &(next->context));//執行切換函數
        }
        local_intr_restore(intr_flag);
    }
}
           

3.為什麼加載新程序的頁目錄不會影響到後面switch_to(from, to)函數的運作?不會導緻原程序prev -> context尋址失敗嗎?顯然不會,不同程序不同的地方在于使用者程序的位址空間。而程序控制塊PCB位于系統核心空間,使用虛位址可以在任何使用者程序頁目錄中正确對他們尋址。

Lab6

終于進入程序管理的最後一個實驗了,回想起來還是弄清楚了很多東西,先高興3秒…

下面開始實驗六。從這個實驗開始,我不能再手動合并實驗代碼了,否則能累死。看來,無論幹什麼,都得效率放在最前頭!為提升效率而學習新東西都不是無用的。

我選擇用meld。這個軟體還沒有完全使用順手。。。待繼續熟悉。

還有一個老師在視訊中用到的,查找關鍵字在檔案夾的檔案中的位置(shell指令):

使用 Round Robin 排程算法

由于RR時間片輪轉的算法實作在前面已經結合源碼做了分析,這裡就不重複。

下面回答問題:簡要說明如何設計實作”多級回報隊列排程算法“。

答:從原理上出發,該算法是有多個隊列,每個隊列都有不同的優先級。高優先級的隊列,時間片比較短,低優先級的隊列時間片比較長,不同的隊列時間片成2^n關系。當一個程序處于所在的優先級,并且被排程後:如果在所配置設定的時間片内執行完,那麼程序正常結束;如果在時間片内沒有執行完,那麼就在該隊列取下程序,放在更低一優先級的隊尾;最後一個隊列采用FCFS方法。任務到來時,首先進入高優先級隊列等待排程。同時,新任務可以搶占目前執行的低優先級任務。

可見,不同優先級之間采取優先級排程,同一優先級采取先來先服務、時間片輪轉的排程政策。其好處是,兼顧了優先級和時間片輪轉的優點,既能使高優先級的程序得到響應又可使短程序任務迅速完成。當隻有一個隊列時,算法退化為FCFS算法。

從實作上看,運作隊列是一個含有多個不同優先級的隊列的集合。可以用向量實作給定數目的優先級,向量中的元素是指定優先級隊列的頭結點(指針)。然後實作出隊、入隊、選擇和時間響應等4個“核心函數”。這裡PCB中的priority優先級随着被排程動态修改。

實作 Stride Scheduling 排程算法

這是本次實驗的重點内容。Stride Scheduling排程算法原理略。

首先,需要對程序PCB做什麼改進?其實系統已經為我們明确了。

struct proc_struct {
    // . . .
    // round-robin 排程器并不會用到以下成員,實作stride算法時候用到
    // 該程序在優先隊列中的節點,僅在 LAB6 使用,斜堆
    skew_heap_entry_t  lab6_run_pool;
    // 該程序的排程優先級,僅在 LAB6 使用
    uint32_t lab6_priority;
    // 該程序的排程步進值,僅在 LAB6 使用
    uint32_t lab6_stride;
           

其次,運作隊列run_queue怎麼改進?

struct run_queue {
    //其運作隊列的哨兵結構,可以看作是隊列頭和尾,本算法中不用
    list_entry_t run_list;
    //優先隊列形式的程序容器,隻在 LAB6 練習2中使用,本算法中使用
    skew_heap_entry_t  *lab6_run_pool;
    //表示其内部的程序總數
    unsigned int proc_num;
    //每個程序一輪占用的最多時間片
    int max_time_slice;
};
           

最後,就是實作Stride Scheduling排程算法的“核心函數”了。先執行個體化一個排程類:

//kern/process/default_sched.c
struct sched_class default_sched_class = {
     .name = "stride_scheduler",
     .init = stride_init,
     .enqueue = stride_enqueue,
     .dequeue = stride_dequeue,
     .pick_next = stride_pick_next,
     .proc_tick = stride_proc_tick,
};

           

然後針對新算法初始化運作隊列run_queue,重寫stride_init()函數:

static void stride_init(struct run_queue *rq) {
     /* LAB6: YOUR CODE */
     list_init(&(rq->run_list));//針對連結清單組織方式的初始化
     rq->lab6_run_pool = NULL;//針對斜堆組織方式的初始化
     //如果優先隊列為空,則其指向空指針(NULL)
     rq->proc_num = 0;//程序計數初始化為0
}
           

最後是,4個“核心函數”,如下:

//被sched_class_enqueue()所調用,執行“入隊”的功能
static void stride_enqueue(struct run_queue *rq, struct proc_struct *proc) {
     /* LAB6: YOUR CODE */
     //下面是參考答案的代碼,我先把它貼出來了,因為這個代碼充分考慮了兩種情況
     //即使用連結清單實作運作隊列和斜堆實作運作隊列,具體使用哪一個,在編譯時
     //使用條件編譯可以自由選擇,這種編譯時多選的情況很關鍵,應該掌握并運用
#if USE_SKEW_HEAP//如果定義了USE_SKEW_HEAP宏,就使用斜堆的代碼
     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)));
     list_add_before(&(rq->run_list), &(proc->run_link));//向連結清單最後插入一個節點
#endif//條件編譯結束
	 //如果目前程序剩餘時間片為0,或者大于系統最大時間片的值
     if (proc->time_slice == 0 || proc->time_slice > rq->max_time_slice) {
          proc->time_slice = rq->max_time_slice;//就把目前程序的時間片重設為最大時間片
     }
     proc->rq = rq;//設定目前程序所在的優先隊列指針指向rq
     rq->proc_num ++;//運作隊列中程序計數器+1
}

//被sched_class_dequeue()所調用,執行“出隊”的功能
static void stride_dequeue(struct run_queue *rq, struct proc_struct *proc) {
     /* LAB6: YOUR CODE */
#if USE_SKEW_HEAP//如果定義了USE_SKEW_HEAP宏,就使用斜堆的代碼
     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);
     list_del_init(&(proc->run_link));//從連結清單運作隊列中删除該節點(即取消指針的連結)
#endif//條件編譯結束
     rq->proc_num --;//運作隊列中程序計數器-1
}

//被sched_class_pick_next()所調用,執行“選擇”的功能
static struct proc_struct * stride_pick_next(struct run_queue *rq) {
     /* LAB6: YOUR CODE */
#if USE_SKEW_HEAP//如果定義了USE_SKEW_HEAP宏,就使用斜堆的代碼,從這裡可以看出斜堆優勢
     if (rq->lab6_run_pool == NULL) return NULL;//如果斜堆為空,則傳回NULL
     //否則,斜堆頂端的元素就是要取的元素,反查其PCB指針,并用指針p傳回
     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)//如果,該節點指向了rq->run_list,說明連結清單是空的,傳回NULL
          return NULL;     
     //以下到endif實際上是一個通過周遊實作的簡單比較算法
     struct proc_struct *p = le2proc(le, run_link);//讓p指向連結清單的第一個節點的PCB
     le = list_next(le);//le指針後移一個節點
     while (le != &rq->run_list)//隻要沒有到哨兵節點rq->run_list,就循環
     {
          struct proc_struct *q = le2proc(le, run_link);//讓q指向連結清單的第2個節點的PCB
          if ((int32_t)(p->lab6_stride - q->lab6_stride) > 0)//若p所指程序步長大于q的
               p = q;//p就指向q的程序
          le = list_next(le);//然後,q後移一位,重新指向一個PCB
          //可以看出,q是一個遊标,向後周遊所有PCB,而p始終指向目前的最小步長程序
     }
#endif
     if (p->lab6_priority == 0)//優先級如果為0,則程序步長+BIG_STRIDE
          p->lab6_stride += BIG_STRIDE;
     //否則程序步長+BIG_STRIDE / p->lab6_priority;
     else p->lab6_stride += BIG_STRIDE / p->lab6_priority;
     return p;//傳回標明的p(程序PCB指針)
}

//被sched_class_proc_tick()所調用,執行“時間響應”的功能
static void stride_proc_tick(struct run_queue *rq, struct proc_struct *proc) {
     /* LAB6: YOUR CODE */
     if (proc->time_slice > 0) {//如果程序剩餘時間片還沒用完,則-1即可
          proc->time_slice --;
     }
     if (proc->time_slice == 0) {//如果程序剩餘時間片用完,則需要排程标志位置1
          proc->need_resched = 1;
     }
}
           

以上就是4個所謂的“關鍵核心函數”,我幾乎照着答案搬過來了。

另外,uCore中BIG_STRIDE是這樣定義的:

為什麼這樣定義呢?保證stride屬性的不會溢出,或者說讓計算的結果仍然反應正确的值。在uCore中,stride用32位無符号整數表示。計算比較時,計算結果用帶符号整數表示仍得到正确的值。要達到這個目的,需要正确設定BIG_STRIDE的範圍。我們要保證任意兩個 Stride 之差都會在機器整數表示的範圍之内:因為stride單次加的值不超過BIG_STRIDE(規定優先權大于1),是以比較時計算兩個stride的差也不會超過BIG_STRIDE的範圍。在32位整數中,把最高一位做符号位,其餘31位表示BIG_STRIDE,當他們全1時是BIG_STRIDE可以的最大值。假設按照無符号加法,某一數值較大的stride發生了回卷,變成了0x000000FE,而原來stride較小的還沒有達到上限0xFFFFFFFF,現在是0xFFFFFCDE。想在把他們看作是帶符号減法比較,一個正數減去一個負數,仍然為整數,這個符号結果仍是正确的。但是,如果單次加減的stride值超過了0x7FFFFFFF,就會導緻所謂的數值較大的stride在有符号數表示下為負數,進而得出相反的結果!是以,32位下,這個BIG_STRIDE不能超過0x7FFFFFFF。

本次實驗六結束。

2019-07-29 09:05

繼續閱讀