ucoreOS_lab7 實驗報告

所有的實驗報告將會在 Github 同步更新,更多內容請移步至Github:https://github.com/AngelKitty/review_the_national_post-graduate_entrance_examination/blob/master/books_and_notes/professional_courses/operating_system/sources/ucore_os_lab/docs/lab_report/

練習0:填寫已有實驗

lab7 會依賴 lab1~lab6 ,我們需要把做的 lab1~lab6 的代碼填到 lab7 中缺失的位置上面。練習 0 就是一個工具的利用。這裏我使用的是 Linux 下的系統已預裝好的 Meld Diff Viewer 工具。和 lab6 操作流程一樣,我們只需要將已經完成的 lab1~lab6 與待完成的 lab7 (由於 lab7 是基於 lab1~lab6 基礎上完成的,所以這裏只需要導入 lab6 )分別導入進來,然後點擊 compare 就行了。

compare

然後軟件就會自動分析兩份代碼的不同,然後就一個個比較比較複製過去就行了,在軟件裏面是可以支持打開對比複製了,點擊 Copy Right 即可。當然 bin 目錄和 obj 目錄下都是 make 生成的,就不用複製了,其他需要修改的地方主要有以下七個文件,通過對比複製完成即可:

proc.c
default_pmm.c
pmm.c
swap_fifo.c
vmm.c
trap.c
sche.c

根據試驗要求,我們需要對部分代碼進行改進,這裏講需要改進的地方只有一處:

trap.c() 函數

修改的部分如下:

static void trap_dispatch(struct trapframe *tf) {
    ++ticks;
    /* 註銷掉下面這一句 因爲這一句被包含在了 run_timer_list()
        run_timer_list() 在之前的基礎上 加入了對 timer 的支持 */
    // sched_class_proc_tick(current);
    run_timer_list();
}

練習1: 理解內核級信號量的實現和基於內核級信號量的哲學家就餐問題(不需要編碼)

在完成本練習之前,先說明下什麼是哲學家就餐問題:

哲學家就餐問題,即有五個哲學家,他們的生活方式是交替地進行思考和進餐。哲學家們公用一張圓桌,周圍放有五把椅子,每人坐一把。在圓桌上有五個碗和五根筷子,當一個哲學家思考時,他不與其他人交談,飢餓時便試圖取用其左、右最靠近他的筷子,但他可能一根都拿不到。只有在他拿到兩根筷子時,方能進餐,進餐完後,放下筷子又繼續思考。

struct

在分析之前,我們先對信號量有個瞭解,既然要理解信號量的實現方法,我們可以先看看信號量的僞代碼:

struct semaphore {
  int count;
  queueType queue;
};

void P(semaphore S){
  S.count--;
  if (S.count<0) {
    把進程置爲睡眠態;
    將進程的PCB插入到S.queue的隊尾;
    調度,讓出CPU;
  }
}

void V(semaphore S){
    S.count++;
    if (S.count≤0) {
    喚醒在S.queue上等待的第一個進程;
  }
}

基於上訴信號量實現可以認爲,當多個進程可以進行互斥或同步合作時,一個進程會由於無法滿足信號量設置的某條件而在某一位置停止,直到它接收到一個特定的信號(表明條件滿足了)。爲了發信號,需要使用一個稱作信號量的特殊變量。爲通過信號量 s 傳送信號,信號量通過 V、P 操作來修改傳送信號量。

  • count > 0,表示共享資源的空閒數
  • count < 0,表示該信號量的等待隊列裏的進程數
  • count = 0,表示等待隊列爲空

實驗 7 的主要任務是實現基於信號量和管程去解決哲學家就餐問題,我們知道,解決哲學家就餐問題需要創建與之相對應的內核線程,而所有內核線程的創建都離不開 pid 爲 1 的那個內核線程——idle,此時我們需要去尋找在實驗 4 中討論過的地方,如何創建並初始化 idle 這個內核線程。

在實驗 7 中,具體的信號量數據結構被定義在(kern/sync/sem.h)中:

typedef struct {
    int value;
    wait_queue_t wait_queue;
} semaphore_t;

找到相關函數 init_main(kern/process/proc.c,838——863行)

static int init_main(void *arg) {
    size_t nr_free_pages_store = nr_free_pages();
    size_t kernel_allocated_store = kallocated();
 
    int pid = kernel_thread(user_main, NULL, 0);
    if (pid <= 0) {
        panic("create user_main failed.\n");
    }
    extern void check_sync(void);
    check_sync();                // check philosopher sync problem
 
    while (do_wait(0, NULL) == 0) {
        schedule();
    }
 
    cprintf("all user-mode processes have quit.\n");
    assert(initproc->cptr == NULL && initproc->yptr == NULL && initproc->optr == NULL);
    assert(nr_process == 2);
    assert(list_next(&proc_list) == &(initproc->list_link));
    assert(list_prev(&proc_list) == &(initproc->list_link));
    assert(nr_free_pages_store == nr_free_pages());
    assert(kernel_allocated_store == kallocated());
    cprintf("init check memory pass.\n");
    return 0;
}

該函數與實驗四基本沒有不同之處,唯一的不同在於它調用了 check_sync() 這個函數去執行了哲學家就餐問題。

我們分析 check_sync 函數(kern/sync/check_sync.c,182+行):

void check_sync(void)
{
    int i;
    //check semaphore
    sem_init(&mutex, 1);
    for(i=0;i<N;i++) {            //N是哲學家的數量
        sem_init(&s[i], 0);       //初始化信號量
        int pid = kernel_thread(philosopher_using_semaphore, (void *)i, 0);//線程需要執行的函數名、哲學家編號、0表示共享內存
        //創建哲學家就餐問題的內核線程
        if (pid <= 0) {     //創建失敗的報錯
            panic("create No.%d philosopher_using_semaphore failed.\n");
        }
        philosopher_proc_sema[i] = find_proc(pid);
        set_proc_name(philosopher_proc_sema[i], "philosopher_sema_proc");
    }
 
    //check condition variable
    monitor_init(&mt, N);
    for(i=0;i<N;i++){
        state_condvar[i]=THINKING;
        int pid = kernel_thread(philosopher_using_condvar, (void *)i, 0);
        if (pid <= 0) {
            panic("create No.%d philosopher_using_condvar failed.\n");
        }
        philosopher_proc_condvar[i] = find_proc(pid);
        set_proc_name(philosopher_proc_condvar[i], "philosopher_condvar_proc");
    }
}

通過觀察函數的註釋,我們發現,這個 check_sync 函數被分爲了兩個部分,第一部分使用了信號量來解決哲學家就餐問題,第二部分則是使用管程的方法。因此,練習 1 中我們只需要關注前半段。

首先觀察到利用 kernel_thread 函數創建了一個哲學家就餐問題的內核線程(kern/process/proc.c,270——280行)

int kernel_thread(int (*fn)(void *), void *arg, uint32_t clone_flags) {
    struct trapframe tf;  //中斷相關
    memset(&tf, 0, sizeof(struct trapframe));
    tf.tf_cs = KERNEL_CS;
    tf.tf_ds = tf.tf_es = tf.tf_ss = KERNEL_DS;
    tf.tf_regs.reg_ebx = (uint32_t)fn;
    tf.tf_regs.reg_edx = (uint32_t)arg;
    tf.tf_eip = (uint32_t)kernel_thread_entry;
    return do_fork(clone_flags | CLONE_VM, 0, &tf);
}

簡單的來說,這個函數需要傳入三個參數:

  • 第一個 fn 是一個函數,代表這個創建的內核線程中所需要執行的函數;
  • 第二個 arg 是相關參數,這裏傳入的是哲學家編號 i;
  • 第三部分是共享內存的標記位,內核線程之間內存是共享的,因此應該設置爲 0。

其餘地方則是設置一些寄存器的值,保留需要執行的函數開始執行的地址,以便創建了新的內核線程之後,函數能夠在內核線程中找到入口地址,執行函數功能。

接下來,讓我們來分析需要創建的內核線程去執行的目標函數 philosopher_using_semaphore(kern/sync/check_sync.c,52——70行)

int philosopher_using_semaphore(void * arg)/* i:哲學家號碼,從0到N-1 */
{
    int i, iter=0;
    i=(int)arg; //傳入的參數轉爲 int 型,代表哲學家的編號
    cprintf("I am No.%d philosopher_sema\n",i);
    while(iter++<TIMES) /* 無限循環 在這裏我們取了 TIMES=4*/
    {
        cprintf("Iter %d, No.%d philosopher_sema is thinking\n",iter,i);// 哲學家正在思考
        do_sleep(SLEEP_TIME);//等待
        phi_take_forks_sema(i);// 需要兩隻叉子,或者阻塞
        cprintf("Iter %d, No.%d philosopher_sema is eating\n",iter,i);// 進餐
        do_sleep(SLEEP_TIME);
        phi_put_forks_sema(i);// 把兩把叉子同時放回桌子
    }       //哲學家思考一段時間,吃一段時間飯
    cprintf("No.%d philosopher_sema quit\n",i);
    return 0;
}

參數及其分析:

  • 傳入參數 arg,代表在上一個函數中“參數”部分定義的 (void )i,是哲學家的編號。
  • iter++<TIMES,表示循環 4 次,目的在於模擬多次試驗情況。

從這個函數,我們看到,哲學家需要思考一段時間,然後吃一段時間的飯,這裏面的“一段時間”就是通過系統調用 sleep 實現的,內核線程調用 sleep,然後這個線程休眠指定的時間,從某種方面模擬了吃飯和思考的過程。

以下是 do_sleep 的實現:(kern/process/proc.c,922+行)

int do_sleep(unsigned int time) {
    if (time == 0) {
        return 0;
    }
    bool intr_flag;
    local_intr_save(intr_flag);//關閉中斷
    timer_t __timer, *timer = timer_init(&__timer, current, time);
    //聲明一個定時器,並將其綁定到當前進程 current 上
    current->state = PROC_SLEEPING;
    current->wait_state = WT_TIMER;
    add_timer(timer);
    local_intr_restore(intr_flag);
 
    schedule();
 
    del_timer(timer);
    return 0;
}

我們看到,睡眠的過程中是無法被打斷的,符合我們一般的認識,因爲它在計時器使用的過程中通過 local_intr_save 關閉了中斷,且利用了 timer_init 定時器函數,去記錄指定的時間(傳入的參數time),且在這個過程中,將進程的狀態設置爲睡眠,調用函數 add_timer 將綁定該進程的計時器加入計時器隊列。當計時器結束之後,打開中斷,恢復正常。

而反過來看傳入的參數,即爲定時器的定時值 time,在上一層函數中,傳入的是 kern/sync/check_sync.c,14 行的宏定義,TIME 的值爲 10。

相關的圖解如下:

time

目前看來,最關鍵的函數是 phi_take_forks_sema(i) 和 phi_take_forks_sema(i);

phi_take_forks_sema、phi_take_forks_sema 函數如下所示:(kern/sync/check_sync,c,34——50行)

void phi_take_forks_sema(int i)            /* i:哲學家號碼從 0 到 N-1 */
{
        down(&mutex);                      /* 進入臨界區 */
        state_sema[i]=HUNGRY;              /* 記錄下哲學家 i 飢餓的事實 */
        phi_test_sema(i);                  /* 試圖得到兩隻叉子 */
        up(&mutex);                        /* 離開臨界區 */
        down(&s[i]);                       /* 如果得不到叉子就阻塞 */
}
 
void phi_put_forks_sema(int i)             /* i:哲學家號碼從 0 到 N-1 */
{
        down(&mutex);                      /* 進入臨界區 */
        state_sema[i]=THINKING;            /* 哲學家進餐結束 */
        phi_test_sema(LEFT);               /* 看一下左鄰居現在是否能進餐 */
        phi_test_sema(RIGHT);              /* 看一下右鄰居現在是否能進餐 */
        up(&mutex);                        /* 離開臨界區 */
}

參數解釋:

  • 傳入參數 i:當前哲學家的編號;
  • mutex、state_sema:定義在當前文件的第 17——19 行,分別爲每個哲學家記錄當前的狀態。

其中,mutex 的數據類型是“信號量結構體”,其定義在 kern/sync/sem.h 中:

typedef struct {
    int value;
    wait_queue_t wait_queue;
} semaphore_t;

現在來到了最關鍵的核心問題解決部分,首先是 down 和 up 操作:(kern/sync/sem.c,16——54行)

static __noinline void __up(semaphore_t *sem, uint32_t wait_state) {
    bool intr_flag;
    local_intr_save(intr_flag);//關閉中斷
    {
        wait_t *wait;
        if ((wait = wait_queue_first(&(sem->wait_queue))) == NULL) {//沒有進程等待
            sem->value ++;      //如果沒有進程等待,那麼信號量加一
        }
        //有進程在等待
        else {      //否則喚醒隊列中第一個進程
            assert(wait->proc->wait_state == wait_state);
            wakeup_wait(&(sem->wait_queue), wait, wait_state, 1);//將 wait_queue 中等待的第一個 wait 刪除,並將該進程喚醒
        }
    }
    local_intr_restore(intr_flag);      //開啓中斷,正常執行
}

up 函數的作用是:首先關中斷,如果信號量對應的 wait queue 中沒有進程在等待,直接把信號量的 value 加一,然後開中斷返回;如果有進程在等待且進程等待的原因是 semophore 設置的,則調用 wakeup_wait 函數將 waitqueue 中等待的第一個 wait 刪除,且把此 wait 關聯的進程喚醒,最後開中斷返回。

static __noinline uint32_t __down(semaphore_t *sem, uint32_t wait_state) {
    bool intr_flag;
    local_intr_save(intr_flag);      //關閉中斷
    if (sem->value > 0) {            //如果信號量大於 0,那麼說明信號量可用,因此可以分配給當前進程運行,分配完之後關閉中斷
        sem->value --;//直接讓 value 減一
        local_intr_restore(intr_flag);//打開中斷返回
        return 0;
    }
    //當前信號量value小於等於0,表明無法獲得信號量
    wait_t __wait, *wait = &__wait;
    wait_current_set(&(sem->wait_queue), wait, wait_state);//將當前的進程加入到等待隊列中
    local_intr_restore(intr_flag);//打開中斷
    //如果信號量數值小於零,那麼需要將當前進程加入等待隊列並調用 schedule 函數查找下一個可以被運行調度的進程,此時,如果能夠查到,那麼喚醒,並將其中隊列中刪除並返回
    schedule();//運行調度器選擇其他進程執行
 
    local_intr_save(intr_flag);//關中斷
    wait_current_del(&(sem->wait_queue), wait);//被 V 操作喚醒,從等待隊列移除
    local_intr_restore(intr_flag);//開中斷
 
    if (wait->wakeup_flags != wait_state) {
        return wait->wakeup_flags;
    }
    return 0;
}

down 函數的作用是:首先關掉中斷,然後判斷當前信號量的 value 是否大於 0。如果是 >0,則表明可以獲得信號量,故讓 value 減一,並打開中斷返回即可;如果不是 >0,則表明無法獲得信號量,故需要將當前的進程加入到等待隊列中,並打開中斷,然後運行調度器選擇另外一個進程執行。如果被 V 操作喚醒,則把自身關聯的 wait 從等待隊列中刪除(此過程需要先關中斷,完成後開中斷)。

其中,這裏調用了 local_intr_save 和 local_intr_restore 兩個函數,它們被定義在(kern/sync/sync.h,11——25行):

static inline bool __intr_save(void) { //臨界區代碼
    if (read_eflags() & FL_IF) {
        intr_disable();
        return 1;
    }
    return 0;
}
static inline void __intr_restore(bool flag) {
    if (flag) {
        intr_enable();
    }
}

很容易發現他們的功能是關閉和打開中斷。

break

分析完了 up 和 down,讓我們來分析一下 test 函數:

phi_test_sema(LEFT); /* 看一下左鄰居現在是否能進餐 */

phi_test_sema(RIGHT); /* 看一下右鄰居現在是否能進餐 */

該函數被定義在(kern/sync/check_sync.c,86——94行):

void phi_test_sema(i)
{
    if(state_sema[i]==HUNGRY&&state_sema[LEFT]!=EATING
            &&state_sema[RIGHT]!=EATING)
    {
        state_sema[i]=EATING;
        up(&s[i]);
    }
}

在試圖獲得筷子的時候,函數的傳入參數爲 i,即爲哲學家編號,此時,他自己爲 HUNGRY,而且試圖檢查旁邊兩位是否都在吃。如果都不在吃,那麼可以獲得 EATING 的狀態。

在從吃的狀態返回回到思考狀態的時候,需要調用兩次該函數,傳入的參數爲當前哲學家左邊和右邊的哲學家編號,因爲他試圖喚醒左右鄰居,如果左右鄰居滿足條件,那麼就可以將他們設置爲 EATING 狀態。

其中,LEFT 和 RIGHT 的定義如下:

#define LEFT (i-1+N)%N

#define RIGHT (i+1)%N

由於哲學家坐圓桌,因此可以使用餘數直接獲取左右編號。

練習一的總體執行流程如下:

do_sleep

run_time_list

請在實驗報告中給出內核級信號量的設計描述,並說其大致執行流流程。

實現了內核級信號量機制的函數均定義在 sem.c 中,因此對上述這些函數分析總結如下:

  • sem_init:對信號量進行初始化的函數,根據在原理課上學習到的內容,信號量包括了等待隊列和一個整型數值變量,該函數只需要將該變量設置爲指定的初始值,並且將等待隊列初始化即可;
  • __up:對應到了原理課中提及到的 V 操作,表示釋放了一個該信號量對應的資源,如果有等待在了這個信號量上的進程,則將其喚醒執行;結合函數的具體實現可以看到其採用了禁用中斷的方式來保證操作的原子性,函數中操作的具體流程爲:
    • 查詢等待隊列是否爲空,如果是空的話,給整型變量加 1;
    • 如果等待隊列非空,取出其中的一個進程喚醒;
  • __down:同樣對應到了原理課中提及的P操作,表示請求一個該信號量對應的資源,同樣採用了禁用中斷的方式來保證原子性,具體流程爲:
    • 查詢整型變量來了解是否存在多餘的可分配的資源,是的話取出資源(整型變量減 1),之後當前進程便可以正常進行;
    • 如果沒有可用的資源,整型變量不是正數,當前進程的資源需求得不到滿足,因此將其狀態改爲 SLEEPING 態,然後將其掛到對應信號量的等待隊列中,調用 schedule 函數來讓出 CPU,在資源得到滿足,重新被喚醒之後,將自身從等待隊列上刪除掉;
  • up, down:對 __up, __down 函數的簡單封裝;
  • try_down:不進入等待隊列的 P 操作,即時是獲取資源失敗也不會堵塞當前進程;

請在實驗報告中給出給用戶態進程/線程提供信號量機制的設計方案,並比較說明給內核級提供信號量機制的異同。

將內核信號量機制遷移到用戶態的最大麻煩在於,用於保證操作原子性的禁用中斷機制、以及 CPU 提供的 Test and Set 指令機制都只能在用戶態下運行,而使用軟件方法的同步互斥又相當複雜,這就使得沒法在用戶態下直接實現信號量機制;於是,爲了方便起見,可以將信號量機制的實現放在 OS 中來提供,然後使用系統調用的方法統一提供出若干個管理信號量的系統調用,分別如下所示:

  • 申請創建一個信號量的系統調用,可以指定初始值,返回一個信號量描述符(類似文件描述符);
  • 將指定信號量執行 P 操作;
  • 將指定信號量執行 V 操作;
  • 將指定信號量釋放掉;

給內核級線程提供信號量機制和給用戶態進程/線程提供信號量機制的異同點在於:

  • 相同點:
    • 提供信號量機制的代碼實現邏輯是相同的;
  • 不同點:
    • 由於實現原子操作的中斷禁用、Test and Set 指令等均需要在內核態下運行,因此提供給用戶態進程的信號量機制是通過系統調用來實現的,而內核級線程只需要直接調用相應的函數就可以了;

練習2: 完成內核級條件變量和基於內核級條件變量的哲學家就餐問題(需要編碼)

首先掌握管程機制,然後基於信號量實現完成條件變量實現,然後用管程機制實現哲學家就餐問題的解決方案(基於條件變量)。

一個管程定義了一個數據結構和能爲併發進程所執行(在該數據結構上)的一組操作,這組操作能同步進程和改變管程中的數據。

管程主要由這四個部分組成:

  • 1、管程內部的共享變量;
  • 2、管程內部的條件變量;
  • 3、管程內部併發執行的進程;
  • 4、對局部於管程內部的共享數據設置初始值的語句。

管程相當於一個隔離區,它把共享變量和對它進行操作的若干個過程圍了起來,所有進程要訪問臨界資源時,都必須經過管程才能進入,而管程每次只允許一個進程進入管程,從而需要確保進程之間互斥。

但在管程中僅僅有互斥操作是不夠用的。進程可能需要等待某個條件 C 爲真才能繼續執行。

所謂條件變量,即將等待隊列和睡眠條件包裝在一起,就形成了一種新的同步機制,稱爲條件變量。一個條件變量 CV 可理解爲一個進程的等待隊列,隊列中的進程正等待某個條件C變爲真。每個條件變量關聯着一個斷言 "斷言" PC。當一個進程等待一個條件變量,該進程不算作佔用了該管程,因而其它進程可以進入該管程執行,改變管程的狀態,通知條件變量 CV 其關聯的斷言 PC 在當前狀態下爲真。

因而條件變量兩種操作如下:

  • wait_cv: 被一個進程調用,以等待斷言 PC 被滿足後該進程可恢復執行。進程掛在該條件變量上等待時,不被認爲是佔用了管程。如果條件不能滿足,就需要等待。
  • signal_cv:被一個進程調用,以指出斷言 PC 現在爲真,從而可以喚醒等待斷言 PC 被滿足的進程繼續執行。如果條件可以滿足,那麼可以運行。

在 ucore 中,管程數據結構被定義在(kern/sync/monitor.h)中:

// 管程數據結構
typedef struct monitor{
    // 二值信號量,用來互斥訪問管程,只允許一個進程進入管程,初始化爲 1
    semaphore_t mutex; // 二值信號量 用來互斥訪問管程
    //用於進程同步操作的信號量
    semaphore_t next;// 用於條件同步(進程同步操作的信號量),發出 signal 操作的進程等條件爲真之前進入睡眠
    // 睡眠的進程數量
    int next_count;// 記錄睡在 signal 操作的進程數
    // 條件變量cv
    condvar_t *cv;// 條件變量
} monitor_t;

管程中的成員變量 mutex 是一個二值信號量,是實現每次只允許一個進程進入管程的關鍵元素,確保了互斥訪問性質。

管程中的條件變量 cv 通過執行 wait_cv,會使得等待某個條件 C 爲真的進程能夠離開管程並睡眠,且讓其他進程進入管程繼續執行;而進入管程的某進程設置條件 C 爲真並執行 signal_cv 時,能夠讓等待某個條件 C 爲真的睡眠進程被喚醒,從而繼續進入管程中執行。

管程中的成員變量信號量 next 和整形變量 next_count 是配合進程對條件變量 cv 的操作而設置的,這是由於發出signal_cv 的進程 A 會喚醒睡眠進程 B,進程 B 執行會導致進程 A 睡眠,直到進程 B 離開管程,進程 A 才能繼續執行,這個同步過程是通過信號量 next 完成的;

而 next_count 表示了由於發出 singal_cv 而睡眠的進程個數。

其中,條件變量 cv 的數據結構也被定義在同一個位置下:

// 條件變量數據結構
typedef struct condvar{
    // 用於條件同步 用於發出 wait 操作的進程等待條件爲真之前進入睡眠
    semaphore_t sem;        //用於發出 wait_cv 操作的等待某個條件 C 爲真的進程睡眠
    // 記錄睡在 wait 操作的進程數(等待條件變量成真)
    int count;            //在這個條件變量上的睡眠進程的個數
    // 所屬管程
    monitor_t * owner;      //此條件變量的宿主管程
} condvar_t;

條件變量的定義中也包含了一系列的成員變量,信號量 sem 用於讓發出 wait_cv 操作的等待某個條件 C 爲真的進程睡眠,而讓發出 signal_cv 操作的進程通過這個 sem 來喚醒睡眠的進程。count 表示等在這個條件變量上的睡眠進程的個數。owner 表示此條件變量的宿主是哪個管程。

其實本來條件變量中需要有等待隊列的成員,以表示有多少線程因爲當前條件得不到滿足而等待,但這裏,直接採用了信號量替代,因爲信號量數據結構中也含有等待隊列。

我們對管程進行初始化操作:

// 初始化管程
void monitor_init (monitor_t * mtp, size_t num_cv) {
    int i;
    assert(num_cv>0);
    mtp->next_count = 0; // 睡在 signal 進程數 初始化爲 0
    mtp->cv = NULL;
    sem_init(&(mtp->mutex), 1); // 二值信號量 保護管程 使進程訪問管程操作爲互斥的
    sem_init(&(mtp->next), 0); // 條件同步信號量
    mtp->cv =(condvar_t *) kmalloc(sizeof(condvar_t)*num_cv); // 獲取一塊內核空間 放置條件變量
    assert(mtp->cv!=NULL);
    for(i=0; i<num_cv; i++){
        mtp->cv[i].count=0;
        sem_init(&(mtp->cv[i].sem),0);
        mtp->cv[i].owner=mtp;
    }
}

那麼現在開始解決哲學家就餐問題,使用管程,它的實現在(kern/sync/check_sync,199+行)

monitor_init(&mt, N);   //初始化管程
for(i=0;i<N;i++){
    state_condvar[i]=THINKING;
    int pid = kernel_thread(philosopher_using_condvar, (void *)i, 0);
    if (pid <= 0) {
        panic("create No.%d philosopher_using_condvar failed.\n");
    }
    philosopher_proc_condvar[i] = find_proc(pid);
    set_proc_name(philosopher_proc_condvar[i], "philosopher_condvar_proc");
}

我們發現,這個實現過程和使用信號量無差別,不同之處在於,各個線程所執行的函數不同,此處執行的爲 philosopher_using_condvar 函數:

philosopher_using_condvar 函數被定義在(kern/sync/check_sync,162——180行)

int philosopher_using_condvar(void * arg) { /* arg is the No. of philosopher 0~N-1*/
  
    int i, iter=0;
    i=(int)arg;
    cprintf("I am No.%d philosopher_condvar\n",i);
    while(iter++<TIMES)
    { /* iterate*/
        cprintf("Iter %d, No.%d philosopher_condvar is thinking\n",iter,i); /* thinking*/
        do_sleep(SLEEP_TIME);
        phi_take_forks_condvar(i); 
        /* need two forks, maybe blocked */
        cprintf("Iter %d, No.%d philosopher_condvar is eating\n",iter,i); /* eating*/
        do_sleep(SLEEP_TIME);
        phi_put_forks_condvar(i); 
        /* return two forks back*/
    }
    cprintf("No.%d philosopher_condvar quit\n",i);
    return 0;    
}

我們發現這裏和用信號量還是沒有本質的差別,不同之處在於,獲取筷子和放下都使用了不同的,配套管程使用的函數 phi_take_forks_condvar 和 phi_put_forks_condvar。

phi_take_forks_condvar 和 phi_put_forks_condvar 被定義在(kern/sync/check_sync,121——159行)

其中,mtp 爲一個管程,聲明於同一文件下的第 108 行,state_convader 數組記錄哲學家的狀態,聲明於第107行。

// 拿刀叉
/*
* phi_take_forks_condvar() 函數實現思路:
  1. 獲取管程的鎖
  2. 將自己設置爲飢餓狀態
  3. 判斷當前叉子是否足夠就餐,如不能,等待其他人釋放資源
  4. 釋放管程的鎖
*/ 
void phi_take_forks_condvar(int i) {
     down(&(mtp->mutex));   //保證互斥操作,P 操作進入臨界區
//--------into routine in monitor--------------
     // LAB7 EXERCISE1: YOUR CODE
     // I am hungry
     // try to get fork
      // I am hungry
      state_condvar[i]=HUNGRY; // 飢餓狀態,準備進食
      // try to get fork
      phi_test_condvar(i);      //測試哲學家是否能拿到刀叉,若不能拿,則阻塞自己,等其它進程喚醒
      if (state_condvar[i] != EATING) { //沒拿到,需要等待,調用 wait 函數
          cprintf("phi_take_forks_condvar: %d didn't get fork and will wait\n",i);
          cond_wait(&mtp->cv[i]);
      }
//--------leave routine in monitor--------------
      if(mtp->next_count>0)
         up(&(mtp->next));
      else
         up(&(mtp->mutex));
}

這個地方的意思是,如果當前管程的等待數量在喚醒了一個線程之後,還有進程在等待,那麼就會喚醒控制當前進程的信號量,讓其他進程佔有它,如果沒有等待的了,那麼直接釋放互斥鎖,這樣就可以允許新的進程進入管程了。

// 放刀叉
/*
* phi_put_forks_condvar() 函數實現思路:
  1. 獲取管程的鎖
  2. 將自己設置爲思考狀態
  3. 判斷左右鄰居的哲學家是否可以從等待就餐的狀態中恢復過來
*/ 
void phi_put_forks_condvar(int i) {
     down(&(mtp->mutex));// P 操作進入臨界區
 
//--------into routine in monitor--------------
     // LAB7 EXERCISE1: YOUR CODE
     // I ate over
     // test left and right neighbors
      // I ate over 
      state_condvar[i]=THINKING;// 思考狀態
      // test left and right neighbors
      // 試試左右兩邊能否獲得刀叉
      phi_test_condvar(LEFT);
      phi_test_condvar(RIGHT);      //喚醒左右哲學家,試試看他們能不能開始吃
//--------leave routine in monitor--------------
     if(mtp->next_count>0)// 有哲學家睡在 signal 操作,則將其喚醒
        up(&(mtp->next));
     else
        up(&(mtp->mutex));//離開臨界區
}

和信號量的實現差不多,我們在拿起筷子和放下的時候,主要都還要喚醒相鄰位置上的哲學家,但是,具體的test操作中,實現有所不同。test 函數被定義在(同文件,110——118行)

// 測試編號爲i的哲學家是否能獲得刀叉 如果能獲得 則將狀態改爲正在吃 並且 嘗試喚醒 因爲wait操作睡眠的進程
// cond_signal 還會阻塞自己 等被喚醒的進程喚醒自己
void phi_test_condvar (i) {
    if(state_condvar[i]==HUNGRY&&state_condvar[LEFT]!=EATING
            &&state_condvar[RIGHT]!=EATING) {
        cprintf("phi_test_condvar: state_condvar[%d] will eating\n",i);
        state_condvar[i] = EATING ;
        cprintf("phi_test_condvar: signal self_cv[%d] \n",i);
        cond_signal(&mtp->cv[i]);       
        //如果可以喚醒,那麼signal操作掉代表這個哲學家那個已經睡眠等待的進程。和wait是對應的。
    }
}

上述這一過程可以被描述爲如下的流程圖:

哲學家->試試拿刀叉->能拿->signal 喚醒被wait阻塞的進程->阻塞自己
                  |             |                  A
                  |             V                  |
                  ->不能拿->wait阻塞自己             |
                                                   |
哲學家->放刀叉->讓左右兩邊試試拿刀叉->有哲學家睡在signal 喚醒他

現在看來,最主要的部分在於管程的 signal 和 wait 操作,ucore 操作系統中對於 signal 和 wait 操作的實現是有專門的函數的,它們是 cond_signal 和 cond_wait(kern/sync/monitor.c,26——72行,代碼實現部分)

// 管程signal操作
/*
分支1. 因爲條件不成立而睡眠的進程計數小於等於0 時 說明 沒有進程需要喚醒 則直接返回
分支2. 因爲條件不成立而睡眠的進程計數大於0 說明有進程需要喚醒 就將其喚醒
同時設置 條件變量所屬管程的 next_count 加1 以用來告訴 wait操作 有進程睡在了 signal操作上
然後自己將自己阻塞 等待條件同步 被喚醒 被喚醒後 睡在 signal 操作上的進程應該減少 故 next_count 應減 1
*/
void cond_signal (condvar_t *cvp) {
   //LAB7 EXERCISE1: YOUR CODE
   cprintf("cond_signal begin: cvp %x, cvp->count %d, cvp->owner->next_count %d\n", cvp, cvp->count, cvp->owner->next_count);       //這是一個輸出信息的語句,可以不管
   /*
   * cond_signal() 函數實現思路:
     1. 判斷條件變量的等待隊列是否爲空
     2. 修改 next 變量上等待進程計數,跟下一個語句不能交換位置,爲了得到互斥訪問的效果,關鍵在於訪問共享變量的時候,管程中是否只有一個進程處於 RUNNABLE 的狀態
     3. 喚醒等待隊列中的某一個進程
     4. 把自己等待在 next 條件變量上
     5. 當前進程被喚醒,恢復 next 上的等待進程計數
   */ 
   if(cvp->count>0) {
       cvp->owner->next_count ++;  //管程中睡眠的數量
       up(&(cvp->sem));            //喚醒在條件變量裏睡眠的進程
       down(&(cvp->owner->next));  //將在管程中的進程睡眠
       cvp->owner->next_count --;
   }
   cprintf("cond_signal end: cvp %x, cvp->count %d, cvp->owner->next_count %d\n", cvp, cvp->count, cvp->owner->next_count);
}

首先判斷 cvp.count,如果不大於 0,則表示當前沒有睡眠在這一個條件變量上的進程,因此就沒有被喚醒的對象了,直接函數返回即可,什麼也不需要操作。

如果大於 0,這表示當前有睡眠在該條件變量上的進程,因此需要喚醒等待在cv.sem上睡眠的進程。而由於只允許一個進程在管程中執行,所以一旦進程 B 喚醒了別人(進程A),那麼自己就需要睡眠。故讓 monitor.next_count 加一,且讓自己(進程B)睡在信號量 monitor.next(宿主管程的信號量)上。如果睡醒了,這讓 monitor.next_count 減一。

這裏爲什麼最後要加一個 next_conut-- 呢?這說明上一句中的 down 的進程睡醒了,那麼睡醒,就必然是另外一個進程喚醒了它,因爲只能有一個進程在管程中被 signal,如果有進程調用了 wait,那麼必然需要 signal 另外一個進程,我們可以從下圖可以看到這一調用過程:

monitor

我們來看 wait 函數:

// 管程wait操作
/*
先將 因爲條件不成立而睡眠的進程計數加1
分支1. 當 管程的 next_count 大於 0 說明 有進程睡在了 signal 操作上 我們將其喚醒
分支2. 當 管程的 next_count 小於 0 說明 當前沒有進程睡在 signal 操作數 只需要釋放互斥體
然後 再將 自身阻塞 等待 條件變量的條件爲真 被喚醒後 將條件不成立而睡眠的進程計數減1 因爲現在成立了
*/
void cond_wait (condvar_t *cvp) {
    //LAB7 EXERCISE1: YOUR CODE
    cprintf("cond_wait begin:  cvp %x, cvp->count %d, cvp->owner->next_count %d\n", cvp, cvp->count, cvp->owner->next_count);
   /*
   * cond_wait() 函數實現思路:
     1. 修改等待在條件變量的等待隊列上的進程計數
     2. 釋放鎖
     3. 將自己等待在條件變量上
     4. 被喚醒,修正等待隊列上的進程計數
   */ 
    cvp->count++;                  //條件變量中睡眠的進程數量加 1
    if(cvp->owner->next_count > 0)
       up(&(cvp->owner->next)); //如果當前有進程正在等待,且睡在宿主管程的信號量上,此時需要喚醒,讓該調用了 wait 的睡,此時就喚醒了,對應上面討論的情況。這是一個同步問題。
    else
       up(&(cvp->owner->mutex));    //如果沒有進程睡眠,那麼當前進程無法進入管程的原因就是互斥條件的限制。因此喚醒 mutex 互斥鎖,代表現在互斥鎖被佔用,此時,再讓進程睡在宿主管程的信號量上,如果睡醒了,count--,誰喚醒的呢?就是前面的 signal 啦,這其實是一個對應關係。
    down(&(cvp->sem));      //因爲條件不滿足,所以主動調用 wait 的進程,會睡在條件變量 cvp 的信號量上,是條件不滿足的問題;而因爲調用 signal 喚醒其他進程而導致自身互斥睡眠,會睡在宿主管程 cvp->owner 的信號量上,是同步的問題。兩個有區別,不要混了,超級重要鴨!!!
 
    cvp->count --;
    cprintf("cond_wait end:  cvp %x, cvp->count %d, cvp->owner->next_count %d\n", cvp, cvp->count, cvp->owner->next_count);
}

如果進程 A 執行了 cond_wait 函數,表示此進程等待某個條件 C 不爲真,需要睡眠。因此表示等待此條件的睡眠進程個數 cv.count 要加一。接下來會出現兩種情況。

情況一:如果 monitor.next_count 如果大於 0,表示有大於等於 1 個進程執行 cond_signal 函數且睡着了,就睡在了 monitor.next 信號量上。假定這些進程形成 S 進程鏈表。因此需要喚醒 S 進程鏈表中的一個進程 B。然後進程 A 睡在 cv.sem 上,如果睡醒了,則讓 cv.count 減一,表示等待此條件的睡眠進程個數少了一個,可繼續執行。

情況二:如果 monitor.next_count 如果小於等於 0,表示目前沒有進程執行 cond_signal 函數且睡着了,那需要喚醒的是由於互斥條件限制而無法進入管程的進程,所以要喚醒睡在 monitor.mutex 上的進程。然後進程 A 睡在 cv.sem 上,如果睡醒了,則讓 cv.count 減一,表示等待此條件的睡眠進程個數少了一個,可繼續執行了!

關於條件變量機制的實現主要位於 monitor.c 文件中的 cond_signal, cond_wait 兩個函數中,這兩個函數的含義分別表示提醒等待在這個條件變量上的進程恢復執行,以及等待在這個條件變量上,直到有其他進行將其喚醒位置,因此對上述這些函數分析總結如下:

cond_signal:將指定條件變量上等待隊列中的一個線程進行喚醒,並且將控制權轉交給這個進程;具體執行流程爲:

  • 判斷當前的條件變量的等待隊列上是否有正在等待的進程,如果沒有則不需要進行任何操作;
  • 如果由正在等待的進程,則將其中的一個喚醒,這裏的等待隊列是使用了一個信號量來進行實現的,由於信號量中已經包括了對等待隊列的操作,因此要進行喚醒只需要對信號量執行 up 操作即可;
  • 接下來當前進程爲了將控制權轉交給被喚醒的進程,將自己等待到了這個條件變量所述的管程的next信號量上,這樣的話就可以切換到被喚醒的進程了;由於 next 信號量的實現,就帶來了兩個困惑:
    • 等待在 next 信號量上的進程是否能夠被喚醒?由於每一個 next 信號量上等待的進程的產生必定是因爲存在了某個它需要喚醒的進程,而這個進程在結束 cond_wait 函數之後返回到管程的函數,還會檢查 next 信號量上是否存在等待着的進程,有的話將其喚醒,因此每一個 next 信號量上等待的進程最終必定會被喚醒;
    • 在等待在 next 信號量上的時候,管程的 mutex 鎖並沒有被釋放,是否可能存在該鎖永遠都被釋放不了的情況?不會的。根據前一個問題得知所有 next 信號量上的等待進程一定會被喚醒,那麼最後一個被喚醒的 next 進程就會將鎖釋放掉;
  • 接下來,當前進程被從 next 信號量上被喚醒的時候,首先將 next count 減一,然後離開 cond_signal 函數,回到管程中的函數,檢查是否應該釋放管程的鎖(取決於現在是否還有 next 信號量上等待的進程,有的話將其喚醒,完成其在函數中的操作,並且將釋放鎖的操作延遲給這個進程來進行),根據上述描述,我們可以知道在管程中能夠運行的進程之間不會有互相有意料外的打斷的過程(由於進程的切換時機都是固定好的,由當前的進程來喚醒另外某一個進程),因此實現了對共享變量訪問的互斥性;

cond_wait:該函數的功能爲將當前進程等待在指定信號量上,其操作過程爲將等待隊列的計數加1,然後釋放管程的鎖或者喚醒一個next上的進程來釋放鎖(否則會造成管程被鎖死無法繼續訪問,同時這個操作不能和前面的等待隊列計數加1的操作互換順序,要不不能保證共享變量訪問的互斥性),然後把自己等在條件變量的等待隊列上,直到有signal信號將其喚醒,正常退出函數;

關於使用條件變量來完成哲學家就餐問題的實現中,總共有兩個關鍵函數,以及使用到了 N(哲學家數量)個條件變量,在管程中,還包括了一個限制管程訪問的鎖還有 N 個用於描述哲學家狀態的變量(總共有 EATING, THINKING, HUNGER)三種狀態;

首先分析 phi_take_forks_condvar 函數的實現,該函數表示指定的哲學家嘗試獲得自己所需要進餐的兩把叉子,如果不能獲得則阻塞,具體實現流程爲:

  • 給管程上鎖;
  • 將哲學家的狀態修改爲 HUNGER;
  • 判斷當前哲學家是否有足夠的資源進行就餐(相鄰的哲學家是否正在進餐);
  • 如果能夠進餐,將自己的狀態修改成 EATING,然後釋放鎖,離開管程即可;
  • 如果不能進餐,等待在自己對應的條件變量上,等待相鄰的哲學家釋放資源的時候將自己喚醒;

而 phi_put_forks_condvar 函數則是釋放當前哲學家佔用的叉子,並且喚醒相鄰的因爲得不到資源而進入等待的哲學家:

  • 首先獲取管程的鎖;
  • 將自己的狀態修改成 THINKING;
  • 檢查相鄰的哲學家是否在自己釋放了叉子的佔用之後滿足了進餐的條件,如果滿足,將其從等待中喚醒(使用 cond_signal);
  • 釋放鎖,離開管程;
  • 由於限制了管程中在訪問共享變量的時候處於 RUNNABLE 的進程只有一個,因此對進程的訪問是互斥的;並且由於每個哲學家只可能佔有所有需要的資源(叉子)或者乾脆不佔用資源,因此不會出現部分佔有資源的現象,從而避免了死鎖的產生;
  • 根據上述分析,可知最終必定所有哲學將都能成功就餐;

請在實驗報告中給出給用戶態進程/線程提供條件變量機制的設計方案,並比較說明給內核級 提供條件變量機制的異同。

發現在本實驗中管程的實現中互斥訪問的保證是完全基於信號量的,也就是如果按照上文中的說明使用 syscall 實現了用戶態的信號量的實現機制,那麼就完全可以按照相同的邏輯在用戶態實現管程機制和條件變量機制;

當然也可以仿照用戶態實現條件變量的方式,將對訪問管程的操作封裝成系統調用;

異同點爲:

  • 相同點:基本的實現邏輯相同;
  • 不同點:最終在用戶態下實現管程和條件變量機制,需要使用到操作系統使用系統調用提供一定的支持; 而在內核態下實現條件變量是不需要的;

請在實驗報告中回答:能否不用基於信號量機制來完成條件變量?如果不能,請給出理由, 如果能,請給出設計說明和具體實現。

能夠基於信號量來完成條件變量機制;事實上在本實驗中就是這麼完成的,只需要將使用信號量來實現條件變量和管程中使用的鎖和等待隊列即可。

最終的實驗結果如下圖所示:

make_grade

如果 make grade 無法滿分,嘗試註釋掉 tools/grade.sh 的 221 行到 233 行(在前面加上“#”)。

這裏我們選用古老的編輯器 Vim,具體操作過程如下:

  • 1、首先按 esc 進入命令行模式下,按下 :221 跳轉至 221 行;
  • 2、按下 Ctrl + v,進入列(也叫區塊)模式;
  • 3、在行首使用上下鍵選擇需要註釋的多行(221~233 行);
  • 4、按下鍵盤(大寫)“I”鍵,進入插入模式;
  • 5、然後輸入註釋符(“//”、“#”等);
  • 6、最後按下“Esc”鍵。

擴展練習

Challenge 1 :在ucore中實現簡化的死鎖和重入探測機制

待完成。。。

Challenge 2 :參考Linux的RCU機制,在ucore中實現簡化的RCU機制

待完成。。。

參考資料

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