1. ucore lab7介紹
ucore在前面的實驗中實現了程式/執行緒機制,並在lab6中實現了搶佔式的執行緒排程機制。基於中斷的搶佔式執行緒排程機制使得執行緒在執行的過程中隨時可能被作業系統打斷,被阻塞掛起而令其它的執行緒獲得CPU。多個執行緒併發的執行,大大提升了非cpu密集型應用程式的cpu吞吐量,使得計算機系統中寶貴的cpu硬體資源得到了充分利用。
作業系統提供的核心執行緒併發機制的優點是明顯的,但同時也帶來了一些問題,其中首當其衝的便是執行緒安全問題。
併發帶來的執行緒安全問題
執行緒安全指的是在擁有共享資料的多條執行緒並行執行的程式中,執行緒安全的程式碼會通過同步機制保證各個執行緒都可以正常且正確的執行,不會出現資料汙染等意外情況。
舉一個經典的例子:在高階語言中對於某一共享整型變數i(假設i=5)進行的i++操作,在最終的機器程式碼中會被分解為幾個更細緻的機器指令:
1. 從記憶體的對應地址中讀取出變數i的值(高階語言的變數在機器層面表現為一個記憶體地址),寫入cpu的暫存器中(假設是edx)
2. 對暫存器edx進行+1運算(運算後edx暫存器中的值為5+1=6)
3. 將edx的值寫入變數i對應的記憶體空間中(在高階語言層面看,寫入edx中的新值後i變成了6)
通過之前lab5/lab6的學習,我們知道在i++具體的機器指令序列執行的每一步過程中,作業系統都可能通過時鐘中斷打斷對應執行緒的執行,進行執行緒的上下文切換。機器指令是原子性的,但高階語言中的一條指令底層可能對應多個機器指令,在執行的過程中可能被中斷介入,無法保證執行的連貫性。
執行緒安全問題舉例
例如,存在兩個併發執行的執行緒a、執行緒b,都對執行緒間的共享變數i(i=5)進行了i++操作。
兩個執行緒的執行i++時的機器指令流按照時間順序依次為:
1. 執行緒a讀取記憶體中變數i的值,寫入暫存器edx(此時記憶體中i的值為5,edx的值為5)。
2. 執行緒a令edx進行+1運算,此時暫存器edx的值為6。
3. 作業系統處理時鐘中斷,發現執行緒a的時間片已經用完,將其掛起,儲存執行緒a的上下文(此時執行緒a的暫存器上下文中edx=6);並排程執行緒b開始獲取cpu執行。
4. 執行緒b讀取記憶體中變數i的值,寫入暫存器edx(此時記憶體中i的值為5,edx的值為5)。
5. 執行緒b令edx進行+1運算,此時暫存器edx的值為6。
6. 作業系統處理時鐘中斷,發現執行緒b的時間片已經用完,將其掛起,儲存執行緒b的上下文(此時執行緒b的暫存器上下文中edx=6);並排程執行緒a開始獲取cpu執行。
7. 執行緒a恢復現場繼續往下執行,將現場恢復後edx的值寫回記憶體中變數i對應的記憶體地址中,寫回後變數i=6。
8. 作業系統處理時鐘中斷,發現執行緒a的時間片已經用完,將其掛起;並排程執行緒a開始獲取cpu執行。
9. 執行緒b恢復現場繼續往下執行,將現場恢復後edx的值寫會記憶體中變數i對應的記憶體地址中,寫回後變數i=6。
上述的例子中,由於作業系統的搶佔式排程以及高階語言中i++操作的非原子性,使得原本初始值為5的變數i,在執行兩次i++之後得到的並不是預期的7,而是錯誤的6。這還僅僅是兩個併發執行緒對於一個共享變數的操作問題,實際的程式中會涉及到更多的併發執行緒和共享變數,使得所編寫的多執行緒併發程式正確性無法得到保證。
作業系統解決執行緒安全問題的手段
在絕大多數情況下,程式的正確性都比效能重要的多。作業系統在引入搶佔式排程的執行緒併發機制的同時,也需要提供相應的手段來解決執行緒安全問題。
解決執行緒安全問題,主要有兩個思路:一是消除程式的併發性;二是阻止多個執行緒併發的訪問共享資源(共享記憶體、共享檔案、共享外設等等)的訪問,即互斥:使得一個執行緒在訪問某一共享資源時,其它的執行緒不能進行同樣的操作。
第一種思路被一些I/O密集型的應用程式所使用,即整個程式(程式)中只有一個執行緒在工作,通過作業系統底層提供的i/o多路複用機制進行工作,早期的redis以及nodeJS就是工作在單執行緒模型下的。單執行緒工作的應用程式由於不存在多個執行緒併發執行的場景,消除了執行緒的併發性,自然也不需要處理執行緒安全問題了。
而作業系統解決執行緒安全問題的方式採用的是第二種思路(通用作業系統是用於同時為大量程式、執行緒服務的,因此不能再回過頭來禁止併發),通過一些機制限制併發執行緒同時訪問會引起執行緒安全問題的共享變數,保證訪問的互斥性。
在關於作業系統原理的理論書籍中介紹了很多用於實現互斥機制的辦法,而ucore在lab7中主要實現了“訊號量”和“條件變數”這兩種效率較高的、主流的、基於休眠/喚醒機制的同步互斥機制。lab7中也以哲學家就餐問題為例,通過訊號量Semaphore以及管程Monitor解決併發領域中很經典的執行緒同步問題。
通過lab7的學習,將能夠深入學習作業系統底層實現執行緒同步、互斥機制,理解訊號量、條件變數、管程等同步互斥機制的工作原理;也可以對更上層的如java中的synchronized、AQS悲觀鎖、管程monitor、notify/wait等執行緒併發同步機制有更深的理解。
lab7是建立在之前實驗的基礎之上的,需要先理解之前的實驗才能更好的理解lab7中的內容。
可以參考一下我關於前面實驗的部落格:
1. ucore作業系統學習(一) ucore lab1系統啟動流程分析
2. ucore作業系統學習(二) ucore lab2實體記憶體管理分析
3. ucore作業系統學習(三) ucore lab3虛擬記憶體管理分析
4. ucore作業系統學習(四) ucore lab4核心執行緒管理
5. ucore作業系統學習(五) ucore lab5使用者程式管理
6. ucore作業系統學習(六) ucore lab6執行緒排程器
2. ucore lab7實驗細節分析
ucore在lab7中的內容大致分為以下幾個部分:
1. 實現等待佇列
2. 實現訊號量
3. 使用訊號量解決哲學家就餐問題
4. 基於訊號量實現條件變數
5. 基於訊號量和條件變數實現管程
6. 使用管程解決哲學家就餐問題
2.1 實現等待佇列
等待佇列介紹
前面提到,ucore在lab7中實現的同步機制是基於休眠/喚醒機制的。為了保證執行緒對於臨界區訪問的互斥性,在前一個執行緒已經進入了臨界區後,後續要訪問臨界區的執行緒會被阻塞以等待前一個執行緒離開臨界區,在之前進入臨界區的執行緒離開臨界區後被阻塞的執行緒會被再次喚醒獲得進入臨界區的資格。
在有許多執行緒併發時,可能會有不止一個執行緒被阻塞在對應的臨界區,為此抽象出了等待佇列結構(wait_queue)用於維護這一被阻塞執行緒的集合。當執行緒由於互斥而被阻塞在臨界區時,將其加入等待佇列並放棄cpu進入阻塞態;當之前獲得臨界區訪問許可權的執行緒離開後,再從對應的等待佇列中選擇一個被阻塞、處於等待狀態的執行緒喚醒,被喚醒的執行緒能接著進入臨界區。
利用等待佇列,使得自始至終都只有最多一個執行緒在臨界區中,保證了互斥性;而執行緒在等待佇列中的休眠(阻塞)/喚醒動作,則實現了執行緒之間對於臨界區訪問的同步。
等待佇列當然並不只適用於執行緒併發同步,當執行緒進入等待狀態以等待某一特定完成事件時(定時休眠一段時間、等待阻塞IO讀寫完成等等事件),底層都可以使用等待佇列來實現。
等待佇列實現
ucore在/kern/sync目錄下的wait.c、wait.h中實現了等待佇列wait_queue、等待佇列節點項wait_t以及相關的函式。
ucore的等待佇列底層是通過雙向連結串列結構實現的。和前面的實驗類似的,提供了一個巨集定義le2wait用於訪問wait_link節點項對應的wait_t結構。
等待佇列結構:
/** * 等待佇列 * */ typedef struct { // 等待佇列的頭結點(哨兵節點) list_entry_t wait_head; } wait_queue_t; struct proc_struct; /** * 等待佇列節點項 * */ typedef struct { // 關聯的執行緒 struct proc_struct *proc; // 喚醒標識 uint32_t wakeup_flags; // 該節點所屬的等待佇列 wait_queue_t *wait_queue; // 等待佇列節點 list_entry_t wait_link; } wait_t; #define le2wait(le, member) \ to_struct((le), wait_t, member)
等待佇列結構底層操作:
// 初始化wait_t等待佇列項 void wait_init(wait_t *wait, struct proc_struct *proc); // 初始化等待佇列 void wait_queue_init(wait_queue_t *queue); // 將wait節點項插入等待佇列 void wait_queue_add(wait_queue_t *queue, wait_t *wait); // 將wait項從等待佇列中移除 void wait_queue_del(wait_queue_t *queue, wait_t *wait); // 獲取等待佇列中wait節點的下一項 wait_t *wait_queue_next(wait_queue_t *queue, wait_t *wait); // 獲取等待佇列中wait節點的前一項 wait_t *wait_queue_prev(wait_queue_t *queue, wait_t *wait); // 獲取等待佇列的第一項 wait_t *wait_queue_first(wait_queue_t *queue); // 獲取等待佇列的最後一項 wait_t *wait_queue_last(wait_queue_t *queue); // 等待佇列是否為空 bool wait_queue_empty(wait_queue_t *queue); // wait項是否在等待佇列中 bool wait_in_queue(wait_t *wait);
// 將wait項從等待佇列中刪除(如果存在的話) #define wait_current_del(queue, wait) \ do { \ if (wait_in_queue(wait)) { \ wait_queue_del(queue, wait); \ } \ } while (0) #endif /* !__KERN_SYNC_WAIT_H__ */
/** * 初始化wait_t等待佇列項 * */ void wait_init(wait_t *wait, struct proc_struct *proc) { // wait項與proc建立關聯 wait->proc = proc; // 等待的狀態 wait->wakeup_flags = WT_INTERRUPTED; // 加入等待佇列 list_init(&(wait->wait_link)); } /** * 初始化等待佇列 * */ void wait_queue_init(wait_queue_t *queue) { // 等待佇列頭結點初始化 list_init(&(queue->wait_head)); } /** * 將wait節點項插入等待佇列 * */ void wait_queue_add(wait_queue_t *queue, wait_t *wait) { assert(list_empty(&(wait->wait_link)) && wait->proc != NULL); // wait項與等待佇列建立關聯 wait->wait_queue = queue; // 將wait項插入頭結點前 list_add_before(&(queue->wait_head), &(wait->wait_link)); } /** * 將wait項從等待佇列中移除 * */ void wait_queue_del(wait_queue_t *queue, wait_t *wait) { assert(!list_empty(&(wait->wait_link)) && wait->wait_queue == queue); list_del_init(&(wait->wait_link)); } /** * 獲取等待佇列中wait節點的下一項 * */ wait_t * wait_queue_next(wait_queue_t *queue, wait_t *wait) { assert(!list_empty(&(wait->wait_link)) && wait->wait_queue == queue); list_entry_t *le = list_next(&(wait->wait_link)); if (le != &(queue->wait_head)) { // *wait的下一項不是頭結點,將其返回 return le2wait(le, wait_link); } return NULL; } /** * 獲取等待佇列中wait節點的前一項 * */ wait_t * wait_queue_prev(wait_queue_t *queue, wait_t *wait) { assert(!list_empty(&(wait->wait_link)) && wait->wait_queue == queue); list_entry_t *le = list_prev(&(wait->wait_link)); if (le != &(queue->wait_head)) { // *wait的前一項不是頭結點,將其返回 return le2wait(le, wait_link); } return NULL; } /** * 獲取等待佇列的第一項 * */ wait_t * wait_queue_first(wait_queue_t *queue) { // 獲取頭結點的下一項 list_entry_t *le = list_next(&(queue->wait_head)); if (le != &(queue->wait_head)) { // 頭結點的下一項不是頭結點,將其返回 return le2wait(le, wait_link); } // 頭結點的下一項還是頭結點,說明等待佇列為空(只有一個wait_head哨兵節點) return NULL; } /** * 獲取等待佇列的最後一項 * */ wait_t * wait_queue_last(wait_queue_t *queue) { // 獲取頭結點的前一項 list_entry_t *le = list_prev(&(queue->wait_head)); if (le != &(queue->wait_head)) { // 頭結點的前一項不是頭結點,將其返回 return le2wait(le, wait_link); } // 頭結點的前一項還是頭結點,說明等待佇列為空(只有一個wait_head哨兵節點) return NULL; } /** * 等待佇列是否為空 * */ bool wait_queue_empty(wait_queue_t *queue) { return list_empty(&(queue->wait_head)); } /** * wait項是否在等待佇列中 * */ bool wait_in_queue(wait_t *wait) { return !list_empty(&(wait->wait_link)); }
等待佇列休眠/喚醒等高層操作:
等待佇列對於執行緒的休眠、喚醒對應的高階操作依賴於上面介紹的、底層的等待佇列增刪改查操作。
// 將等待佇列中的wait項對應的執行緒喚醒 void wakeup_wait(wait_queue_t *queue, wait_t *wait, uint32_t wakeup_flags, bool del); // 將等待佇列中的第一項對應的執行緒喚醒 void wakeup_first(wait_queue_t *queue, uint32_t wakeup_flags, bool del); // 將等待佇列中的所有項對應的執行緒全部喚醒 void wakeup_queue(wait_queue_t *queue, uint32_t wakeup_flags, bool del); // 令對應wait項加入當前等待佇列;令當前執行緒阻塞休眠,掛載在該等待佇列中 void wait_current_set(wait_queue_t *queue, wait_t *wait, uint32_t wait_state);
/** * 將等待佇列中的wait項對應的執行緒喚醒 * */ void wakeup_wait(wait_queue_t *queue, wait_t *wait, uint32_t wakeup_flags, bool del) { if (del) { // 將wait項從等待佇列中刪除 wait_queue_del(queue, wait); } // 設定喚醒的原因標識 wait->wakeup_flags = wakeup_flags; // 喚醒對應執行緒 wakeup_proc(wait->proc); } /** * 將等待佇列中的第一項對應的執行緒喚醒 * */ void wakeup_first(wait_queue_t *queue, uint32_t wakeup_flags, bool del) { wait_t *wait; if ((wait = wait_queue_first(queue)) != NULL) { wakeup_wait(queue, wait, wakeup_flags, del); } } /** * 將等待佇列中的所有項對應的執行緒全部喚醒 * */ void wakeup_queue(wait_queue_t *queue, uint32_t wakeup_flags, bool del) { wait_t *wait; if ((wait = wait_queue_first(queue)) != NULL) { if (del) { do { wakeup_wait(queue, wait, wakeup_flags, 1); } while ((wait = wait_queue_first(queue)) != NULL); } else { do { wakeup_wait(queue, wait, wakeup_flags, 0); } while ((wait = wait_queue_next(queue, wait)) != NULL); } } } /** * 令對應wait項加入當前等待佇列;令當前執行緒阻塞休眠,掛載在該等待佇列中 * */ void wait_current_set(wait_queue_t *queue, wait_t *wait, uint32_t wait_state) { assert(current != NULL); wait_init(wait, current); current->state = PROC_SLEEPING; current->wait_state = wait_state; wait_queue_add(queue, wait); }
2.2 實現訊號量
訊號量是一種同步互斥機制的實現,普遍存在於現在的各種作業系統核心裡,最早是由著名電腦科學家Dijkstra提出。
ucore訊號量定義:
訊號量的定義和使用非常簡單和基礎,包含了一個訊號量的值value以及用於執行緒同步的等待佇列。
/** * 訊號量 * */ typedef struct { // 訊號量值 int value; // 訊號量對應的等待佇列 wait_queue_t wait_queue; } semaphore_t; /** * 初始化訊號量 * */ void sem_init(semaphore_t *sem, int value) { sem->value = value; // 初始化等待佇列 wait_queue_init(&(sem->wait_queue)); }
訊號量的主要操作分別是down和up,對應於Dijkstra提出訊號量時提出的P/V操作。
訊號量作為同步互斥的基本結構,其down/up操作必須是原子性的,無法被打斷髮生上下文切換。令軟體程式表現出原子性的方法有很多,由於ucore是執行在單核的80386cpu上的,簡單起見便直接使用關閉中斷的方式來實現訊號量操作的原子性(多核cpu的情況下,關閉單核的中斷是不夠的,而關閉所有核心的中斷則效能損失太大,需要採取鎖匯流排等其它手段來實現軟體原子性)。
訊號量的down操作:
訊號量的down操作,是請求獲取一個訊號量。
當訊號量的value值大於0時,說明還能容納當前執行緒進入臨界區。
當訊號量的value值等於0時,說明已經無法容納更多的執行緒了,此時需要將當前執行緒阻塞在訊號量的等待佇列上,等待訊號量的up操作將其喚醒。
/** * 訊號量down操作 扣減訊號量 * 當訊號量value不足時將當前執行緒阻塞在訊號量上,等待其它執行緒up操作時將其喚醒 * */ static __noinline uint32_t __down(semaphore_t *sem, uint32_t wait_state) { bool intr_flag; // 暫時關閉中斷,保證訊號量的down操作是原子操作 local_intr_save(intr_flag); if (sem->value > 0) { // 訊號量對應的value大於0,還有權使用 sem->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(); local_intr_save(intr_flag); // 喚醒後,原子操作將當前項從訊號量的等待佇列中刪除 wait_current_del(&(sem->wait_queue), wait); local_intr_restore(intr_flag); if (wait->wakeup_flags != wait_state) { // 如果等待執行緒喚醒的標識與之前設定的引數wait_state不一致,將其狀態返回給呼叫方做進一步判斷 return wait->wakeup_flags; } return 0; }
訊號量的up操作:
訊號量的up操作,是增加一個訊號量中的值。
當增加訊號量值時發現當前訊號量的等待佇列為空時,則說明當前沒有執行緒被阻塞、需要進入訊號量管制的臨界區中,簡單的將訊號量值加1。
當增加訊號量時發現等待佇列不為空,則說明存線上程想要進入臨界區中,卻由於沒有滿足訊號量的條件,被阻塞在了臨界區外。此時便從訊號量的等待佇列中挑選出最早被阻塞的執行緒,將其喚醒,使得其得以進入臨界區。
/** * 訊號量up操作 增加訊號量或喚醒被阻塞在訊號量上的一個執行緒(如果有的話) * */ static __noinline void __up(semaphore_t *sem, uint32_t wait_state) { bool intr_flag; // 暫時關閉中斷,保證訊號量的up操作是原子操作 local_intr_save(intr_flag); { wait_t *wait; if ((wait = wait_queue_first(&(sem->wait_queue))) == NULL) { // 訊號量的等待佇列為空,說明沒有執行緒等待在該訊號量上 // 訊號量value加1 sem->value ++; } else { assert(wait->proc->wait_state == wait_state); // 將等待佇列中的對應等待執行緒喚醒 wakeup_wait(&(sem->wait_queue), wait, wait_state, 1); } } local_intr_restore(intr_flag); }
訊號量的down與up操作關係十分緊密,互相對照著看可以更好的理解其工作原理。
互斥訊號量:
value值被初始化為1的訊號量比較特殊,稱為二元訊號量,也被叫做互斥訊號量。互斥訊號量能夠作為mutex互斥鎖,用於保證臨界區中資料不會被執行緒併發的訪問。
2.3 使用訊號量解決哲學家就餐問題
哲學家就餐問題介紹
哲學家就餐問題是Dijkstra提出的一個經典的多執行緒同步問題。大概場景是在一個環形的圓桌上,坐著五個哲學家,而桌上有五把叉子和五個碗。一個哲學家平時進行思考,飢餓時便試圖取用其左右最靠近他的叉子,只有在他拿到兩隻叉子時才能進餐。進餐完畢,放下叉子繼續思考。
解決哲學家就餐問題的基本思路是使用執行緒模擬哲學家,每個執行緒對應一個活動著的哲學家。但是由於5個併發活動的哲學家執行緒爭搶僅有的5把叉子,且哲學家只有在同時拿到兩根叉子時才能進餐,如果沒有良好的同步機制對這5個哲學家執行緒進行協調,那麼哲學家執行緒互相之間容易發生死鎖(例如,五個哲學家執行緒同時拿起了自己左手邊的叉子,都無法拿起自己右邊的叉子,互相等待著。哲學家之間將永遠無法進餐,紛紛餓死)。
使用Dijkstra提出的訊號量機制可以很好的解決哲學家就餐問題,下面看看ucore中是如何使用訊號量解決哲學家就餐問題的。
哲學家執行緒主體執行邏輯:
ucore的lab7中的check_sync函式是整個lab7實驗的總控函式。在check_sync的前半部分使用kern_thread函式建立了N(N=5)個哲學家核心執行緒,用於執行philosopher_using_semaphore,模擬哲學家就餐問題。
philosopher_using_semaphore中哲學家迴圈往復的進行如下操作:
1. 哲學家進行思考(通過do_sleep系統呼叫進行休眠阻塞,模擬哲學家思考)
2. 通過phi_take_forks_sema函式嘗試著同時拿起左右兩個叉子(如果無法拿到左右叉子,則會陷入阻塞狀態)
3. 哲學家進行就餐(通過do_sleep系統呼叫進行休眠阻塞,模擬哲學家就餐)
4. 通過phi_put_forks_sema函式同時放下左右兩個叉子
拿起叉子的phi_take_forks_sema函式和放下叉子phi_put_forks_sema的函式內部都是通過訊號量進行同步的,在下面進行更進一步的分析。
#define N 5 /* 哲學家數目 */ #define LEFT (i-1+N)%N /* i的左鄰號碼 */ #define RIGHT (i+1)%N /* i的右鄰號碼 */ #define THINKING 0 /* 哲學家正在思考 */ #define HUNGRY 1 /* 哲學家想取得叉子 */ #define EATING 2 /* 哲學家正在吃麵 */ #define TIMES 4 /* 吃4次飯 */ #define SLEEP_TIME 10 void check_sync(void){ int i; //check semaphore 訊號量解決哲學家就餐問題 sem_init(&mutex, 1); for(i=0;i<N;i++){ sem_init(&s[i], 0); int pid = kernel_thread(philosopher_using_semaphore, (void *)i, 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"); } 。。。 條件變數(管程)解決哲學家就餐問題(暫時忽略) } //---------- philosophers problem using semaphore ---------------------- int state_sema[N]; /* 記錄每個人狀態的陣列 */ /* 訊號量是一個特殊的整型變數 */ semaphore_t mutex; /* 臨界區互斥 */ semaphore_t s[N]; /* 每個哲學家一個訊號量 */ /** * 哲學家執行緒主體執行邏輯 * */ int philosopher_using_semaphore(void * arg) /* i:哲學家號碼,從0到N-1 */ { int i, iter=0; i=(int)arg; cprintf("I am No.%d philosopher_sema\n",i); while(iter++<TIMES) { /* 無限迴圈 */ cprintf("Iter %d, No.%d philosopher_sema is thinking\n",iter,i); /* 哲學家正在思考 */ // 使用休眠阻塞來模擬思考(哲學家執行緒阻塞N秒) do_sleep(SLEEP_TIME); // 哲學家嘗試著去拿左右兩邊的叉子(如果沒拿到會阻塞) phi_take_forks_sema(i); /* 需要兩隻叉子,或者阻塞 */ cprintf("Iter %d, No.%d philosopher_sema is eating\n",iter,i); /* 進餐 */ // 使用休眠阻塞來模擬進餐(哲學家執行緒阻塞N秒) do_sleep(SLEEP_TIME); // 哲學家就餐結束,將叉子放回桌子。 // 當發現之前有臨近的哲學家嘗試著拿左右叉子就餐時卻沒有成功拿到,嘗試著喚醒對應的哲學家 phi_put_forks_sema(i); /* 把兩把叉子同時放回桌子 */ } cprintf("No.%d philosopher_sema quit\n",i); return 0; }
拿起叉子/放下叉子函式分析:
phi_take_forks_sema函式表示哲學家嘗試著拿起左右叉子想要就餐;而phi_put_forks_sema函式表示哲學家進餐結束後放下左右叉子。
兩者都是通過全域性的互斥訊號量mutex的down操作進行全域性的互斥,保證在同一時刻只有一個哲學家執行緒能夠進入臨界區,對臨界區的資源叉子進行拿起/放下操作。對不同的哲學家執行緒進行互斥,保證檢視左右叉子的狀態時不會出現併發問題。在後面對mutex的up操作用於釋放mutex互斥訊號量,以離開臨界區,喚醒可能阻塞在mutex訊號量中的其它哲學家執行緒,讓阻塞在mutex訊號量中的另一個執行緒得以進入臨界區。
phi_take_forks_sema函式分析:
在執行phi_take_forks_sema拿叉子時,通過關鍵的phi_test_sema函式進行條件的判斷,判斷當前哲學家執行緒i的左右哲學家執行緒是否都未就餐。
如果條件滿足(在拿叉子時,phi_test_sema前哲學家i已經被預先設定為HUNGRY飢餓狀態了),則代表當前哲學家i可以進餐(其拿起了左右叉子,也代表著其相鄰的左右哲學家無法就餐)。
而如果條件不滿足則會在phi_take_forks_sema的最後,被down(&s[i])阻塞在訊號量s[i]上,等待其被左右兩旁就餐完畢的哲學家將其喚醒。
phi_put_forks_sema函式分析:
在執行phi_put_forks_sema放下叉子時,首先通過設定state_sema[i]的狀態為Thinking,代表哲學家i已經就餐完畢重新進入思考狀態。同時哲學家i在放下叉子後,通過phi_test_sema(LEFT)和phi_test_sema(RIGHT)來判斷相鄰的哲學家在自己就餐的這段時間是否也陷入了飢餓狀態,卻由於暫時拿不到叉子而被阻塞了(LEFT和RIGHT巨集利用取模,解決下標迴環計算的問題)。如果確實存在這種情況,通過phi_test_sema函式嘗試著令相鄰的哲學家進行就餐(也許被阻塞哲學家的隔壁另一邊的哲學家依然在就餐,那麼此時依然無法將其喚醒就餐;而需要等到另一邊的哲學家就餐完畢來嘗試將其喚醒)。
通過互斥訊號量mutex實現哲學家執行緒就餐對臨界區資源-叉子訪問的互斥性,避免了併發時對叉子狀態判斷不準確的情況產生;同時利用訊號量陣列semaphore_t s[N]對哲學家拿取、放下叉子的操作進行同步,使得哲學家們在叉子資源有限、衝突的情況下有序的就餐,不會出死鎖、飢餓等現象。
/** * 哲學家i拿起左右叉子 */ void phi_take_forks_sema(int i) /* i:哲學家號碼從0到N-1 */ { // 拿叉子時需要通過mutex訊號量進行互斥,防止併發問題(進入臨界區) down(&mutex); // 記錄下哲學家i飢餓的事實(執行phi_take_forks_sema嘗試拿叉子,說明哲學家i進入了HUNGRY飢餓狀態) state_sema[i]=HUNGRY; // 試圖同時得到左右兩隻叉子 phi_test_sema(i); // 離開臨界區(喚醒可能阻塞在mutex上的其它執行緒) up(&mutex); // phi_test_sema中如果成功拿到叉子進入了就餐狀態,會先執行up(&s[i]),再執行down(&s[i])時便不會阻塞 // 反之,如果phi_test_sema中沒有拿到叉子,則down(&s[i])將會令哲學家i阻塞在訊號量s[i]上 down(&s[i]); } /** * 哲學家i放下左右叉子 */ void phi_put_forks_sema(int i) /* i:哲學家號碼從0到N-1 */ { // 放叉子時需要通過mutex訊號量進行互斥,防止併發問題(進入臨界區) down(&mutex); /* 進入臨界區 */ // 哲學家進餐結束(執行phi_put_forks_sema放下叉子,說明哲學家已經就餐完畢,重新進入THINKING思考狀態) state_sema[i]=THINKING; // 當哲學家i就餐結束,放下叉子時。需要判斷左、右臨近的哲學家在自己就餐的這段時間內是否也進入了飢餓狀態,卻因為自己就餐拿走了叉子而無法同時獲得左右兩個叉子。 // 為此哲學家i在放下叉子後需要嘗試著判斷在自己放下叉子後,左/右臨近的、處於飢餓的哲學家能否進行就餐,如果可以就喚醒阻塞的哲學家執行緒,並令其進入就餐狀態(EATING) phi_test_sema(LEFT); /* 看一下左鄰居現在是否能進餐 */ phi_test_sema(RIGHT); /* 看一下右鄰居現在是否能進餐 */ up(&mutex); /* 離開臨界區q(喚醒可能阻塞在mutex上的其它執行緒) */ } /** * 判斷哲學家i是否可以拿起左右叉子 */ void phi_test_sema(i) /* i:哲學家號碼從0到N-1 */ { // 當哲學家i處於飢餓狀態(HUNGRY),且其左右臨近的哲學家都沒有在就餐狀態(EATING) if(state_sema[i]==HUNGRY&&state_sema[LEFT]!=EATING &&state_sema[RIGHT]!=EATING) { // 哲學家i餓了(HUNGRY),且左右兩邊的叉子都沒人用。 // 令哲學家進入就餐狀態(EATING) state_sema[i]=EATING; // 喚醒阻塞在對應訊號量上的哲學家執行緒(當是哲學家執行緒i自己執行phi_test_sema(i)時,則訊號量直接加1,抵消掉phi_take_forks_sema中的down操作,代表直接拿起叉子就餐成功而不用進入阻塞態) up(&s[i]); } }
2.4 實現條件變數和管程
條件變數和訊號量的功能很相似,條件變數也提供了類似的執行緒同步機制,和訊號量的down/up操作對應的是wait和signal操作。在原始的定義中條件變數可以用訊號量作為基礎實現;反過來訊號量也能用已經實現的條件變數來實現。
ucore中的條件變數是基於訊號量實現的,同時條件變數也作為管程Monitor結構的重要組成部分。
條件變數condvar結構定義:
/** * 條件變數 * */ typedef struct condvar{ // 條件變數相關的訊號量,用於阻塞/喚醒執行緒 semaphore_t sem; // the sem semaphore is used to down the waiting proc, and the signaling proc should up the waiting proc // 等待在條件變數之上的執行緒數 int count; // the number of waiters on condvar // 擁有該條件變數的monitor管程 monitor_t * owner; // the owner(monitor) of this condvar } condvar_t;
管程monitor結構定義:
/** * 管程 * */ typedef struct monitor{ // 管程控制併發的互斥鎖(應該被初始化為1的互斥訊號量) semaphore_t mutex; // the mutex lock for going into the routines in monitor, should be initialized to 1 // 管程內部協調各併發執行緒的訊號量(執行緒可以通過該訊號量掛起自己,其它併發執行緒或者被喚醒的執行緒可以反過來喚醒它) semaphore_t next; // the next semaphore is used to down the signaling proc itself, and the other OR wakeuped waiting proc should wake up the sleeped signaling proc. // 休眠在next訊號量中的執行緒個數 int next_count; // the number of of sleeped signaling proc // 管程所屬的條件變數(可以是陣列,對應n個條件變數) condvar_t *cv; // the condvars in monitor } monitor_t;
/** * 初始化管程 * */ void monitor_init (monitor_t * mtp, size_t num_cv) { int i; assert(num_cv>0); mtp->next_count = 0; mtp->cv = NULL; // 管程的互斥訊號量值設為1(初始化時未被鎖住) sem_init(&(mtp->mutex), 1); //unlocked // 管程的協調訊號量設為0,當任何一個執行緒發現不滿足條件時,立即阻塞在該訊號量上 sem_init(&(mtp->next), 0); // 為條件變數分配記憶體空間(引數num_cv指定管程所擁有的條件變數的個數) 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; // 條件變數訊號量初始化時設定為0,當任何一個執行緒發現不滿足條件時,立即阻塞在該訊號量上 sem_init(&(mtp->cv[i].sem),0); mtp->cv[i].owner=mtp; } }
條件變數的等待操作實現:
cond_wait函式實現條件變數的wait操作。條件變數的wait操作和訊號量的down功能類似。當條件變數對應的條件不滿足時,通過訊號量的down操作,令當前執行緒阻塞、等待在條件變數所屬的訊號量上。
// Suspend calling thread on a condition variable waiting for condition Atomically unlocks // mutex and suspends calling thread on conditional variable after waking up locks mutex. Notice: mp is mutex semaphore for monitor's procedures /** * 條件變數阻塞等待操作 * 令當前執行緒阻塞在該條件變數上,等待其它執行緒將其通過cond_signal將其喚醒。 * */ 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); /* * cv.count ++; * if(mt.next_count>0) * signal(mt.next) * else * signal(mt.mutex); * wait(cv.sem); * cv.count --; */ // 阻塞在當前條件變數上的執行緒數加1 cvp->count++; if(cvp->owner->next_count > 0) // 對應管程中存在被阻塞的其它執行緒 // 喚醒阻塞在對應管程協調訊號量next中的執行緒 up(&(cvp->owner->next)); else // 如果對應管程中不存在被阻塞的其它執行緒 // 釋放對應管程的mutex二元訊號量 up(&(cvp->owner->mutex)); // 令當前執行緒阻塞在條件變數上 down(&(cvp->sem)); // down返回,說明已經被再次喚醒,條件變數count減1 cvp->count --; cprintf("cond_wait end: cvp %x, cvp->count %d, cvp->owner->next_count %d\n", cvp, cvp->count, cvp->owner->next_count); }
條件變數的喚醒操作實現:
cond_signal函式用於實現條件變數的signal操作。條件變數的signal操作和訊號量的up功能類似。當條件變數對應的條件滿足時,通過訊號量的up操作,喚醒阻塞在對應條件變數中的執行緒。
// Unlock one of threads waiting on the condition variable. /** * 條件變數喚醒操作 * 解鎖(喚醒)一個等待在當前條件變數上的執行緒 * */ 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(cv) { * if(cv.count>0) { * mt.next_count ++; * signal(cv.sem); * wait(mt.next); * mt.next_count--; * } * } */ // 如果等待在條件變數上的執行緒數大於0 if(cvp->count>0) { // 需要將當前執行緒阻塞在管程的協調訊號量next上,next_count加1 cvp->owner->next_count ++; // 令阻塞在條件變數上的執行緒進行up操作,喚醒執行緒 up(&(cvp->sem)); // 令當前執行緒阻塞在管程的協調訊號量next上 // 保證管程臨界區中只有一個活動執行緒,先令自己阻塞在next訊號量上;等待被喚醒的執行緒在離開臨界區後來反過來將自己從next訊號量上喚醒 down(&(cvp->owner->next)); // 當前執行緒被其它執行緒喚醒從down函式中返回,next_count減1 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); }
條件變數與管程的互動:
仔細比對條件變數與訊號量的實現,會發現大致的實現思路是一致的。但ucore中實現的條件變數是作為管程的一部分工作的,因此在wait和signal操作中都額外耦合了與對應管程owner互動的地方。
在管程中進入臨界區的執行緒發現條件不滿足而進行條件變數的wait操作時,需要釋放管程中臨界區的鎖,在wait操作掛起自身時令其它想要進入管程內的執行緒獲得臨界區的訪問許可權。
在管程中臨界區的執行緒發現某一條件得到滿足時,將執行對應條件變數的signal操作以喚醒等待在其上的某一個執行緒。但是由於管程臨界區的互斥性,不能允許臨界區內有超過一個的執行緒在其中執行,因此執行signal操作的執行緒需要首先將自己阻塞掛起在管程的next訊號量上,使得被喚醒的那一個執行緒獨佔臨界區資源。當被喚醒的執行緒離開臨界區時,也會及時的喚醒掛起在管程next訊號量上的對應執行緒。
2.5 使用管程解決哲學家就餐問題
由於併發環境下多個執行緒通過條件變數等同步機制交替的休眠/喚醒,邏輯執行流並不是連貫的,因此條件變數和管程的實現顯得比較繞,令人費解。通過學習如何用管程解決哲學家就餐問題,看看使用管程/條件變數是如何進行執行緒同步互斥的,加深對條件變數、管程工作機制的理解。
在checkSync函式的後半部分,是關於如何使用管程解決哲學家就餐問題。在check_sync的後半部分建立了N(N=5)個哲學家核心執行緒,用於執行philosopher_using_condvar函式,模擬哲學家就餐問題。
philosopher_using_condvar中哲學家迴圈往復的進行如下操作(整體流程和訊號量的實現大體一致):
1. 哲學家進行思考(通過do_sleep系統呼叫進行休眠阻塞,模擬哲學家思考)
2. 通過phi_take_forks_condvar函式嘗試著同時拿起左右兩個叉子(如果沒有拿到左右叉子,陷入阻塞)
3. 哲學家進行就餐(通過do_sleep系統呼叫進行休眠阻塞,模擬哲學家就餐)
4. 通過phi_put_forks_condvar函式同時放下左右兩個叉子,回到思考狀態
拿起叉子的phi_take_forks_condvar函式和放下叉子phi_put_forks_condvar的函式內部都是通過條件變數進行同步的,在下面進行更進一步的分析。
checkSync函式:
void check_sync(void){ int i; //check semaphore sem_init(&mutex, 1); for(i=0;i<N;i++){ sem_init(&s[i], 0); int pid = kernel_thread(philosopher_using_semaphore, (void *)i, 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"); } }
管程實現中拿起叉子/放下叉子函式分析:
phi_take_forks_condvar函式表達哲學家嘗試著拿起左右叉子想要就餐;而phi_put_forks_condvar函式表達哲學家進餐結束後放下左右叉子。
兩者通過管程中的互斥訊號量mutex的down操作進行全域性的互斥,保證在同一時刻只有一個哲學家執行緒能夠進入臨界區,對臨界區的資源叉子進行拿起/放下操作,對不同的哲學家執行緒進行互斥,保證檢視左右叉子的狀態時不會出現併發問題。
在離開管程的臨界區時(註釋into routine in monitor和leave routine in monitor之間為管程的臨界區程式碼),當前執行緒會根據管程內是否存在其它執行緒(mtp->next_count>0)而有不同的操作。當發現管程中的next訊號量上存在其它執行緒阻塞在上面時,優先喚醒next訊號量上的執行緒(阻塞在next上的執行緒是由於要喚醒等待在某一條件變數上的執行緒,為了保證臨界區互斥自願被阻塞的,因此被喚醒的執行緒在離開臨界區後需要第一時間將其喚醒);而如果next訊號量中不存在休眠的執行緒,那麼就和訊號量的實現類似,釋放mutex互斥鎖,喚醒可能等待在其上的某一執行緒。
上述ucore實現的管程其執行緒互動的邏輯是基於Hoare語義的,此外還存在MESA語義的管程和Hansen語義的管程(MESA管程和Hansen管程實現類似前面的訊號量實現哲學家就餐)。
Hoare管程在signal喚醒其它執行緒時會令自己陷入休眠,嚴格的保證了臨界區執行緒的互斥,理論上更加可靠,常見於教科書的理論中。由於Hoare語義的管程需要額外引入一個等待佇列(next訊號量),因此其效能並不如其它兩種語義的管程,現實中被使用的地方很少。
phi_take_forks_condvar函式(同時拿起左右叉子):
void phi_take_forks_condvar(int i) { // 拿叉子時需要通過mutex訊號量進行互斥,防止併發問題(進入臨界區) down(&(mtp->mutex)); //--------into routine in monitor-------------- // LAB7 EXERCISE1: YOUR CODE // I am hungry // try to get fork // I am hungry // 記錄下哲學家i飢餓的事實(執行phi_take_forks_condvar嘗試拿叉子,說明哲學家i進入了HUNGRY飢餓狀態) state_condvar[i]=HUNGRY; // 試圖同時得到左右兩隻叉子 phi_test_condvar(i); if (state_condvar[i] != EATING) { // state_condvar[i]狀態不為EATING,說明phi_test_condvar嘗試拿左右叉子進餐失敗 cprintf("phi_take_forks_condvar: %d didn't get fork and will wait\n",i); // 等待阻塞在管程的條件變數cv[i]上 cond_wait(&mtp->cv[i]); } //--------leave routine in monitor-------------- if(mtp->next_count>0){ // 當離開管程臨界區時,如果發現存線上程等待在mtp->next上 // 在當前實驗中,執行到這裡的當前執行緒可能是阻塞在cond_wait中被其它執行緒喚醒的,對應執行緒是通過phi_test_condvar的cond_signal操作喚醒當前執行緒的 // 執行cond_signal時為了保證管程臨界區內不存在併發的執行緒訪問,在喚醒其它執行緒時,會把自己阻塞在管程的next訊號量上,等待此時離開臨界區的執行緒將其喚醒 up(&(mtp->next)); }else{ // 當離開管程臨界區時,沒有其它執行緒等待在mtp->next上,直接釋放管程的互斥鎖mutex即可(喚醒可能阻塞在mutex上的其它執行緒) up(&(mtp->mutex)); } }
phi_put_forks_condvar函式(同時放下左右叉子):
void phi_put_forks_condvar(int i) { // 放叉子時需要通過mutex訊號量進行互斥,防止併發問題(進入臨界區) down(&(mtp->mutex)); //--------into routine in monitor-------------- // LAB7 EXERCISE1: YOUR CODE // I ate over // test left and right neighbors // I ate over // 哲學家進餐結束(執行phi_put_forks_condvar放下叉子,說明哲學家已經就餐完畢,重新進入THINKING思考狀態) state_condvar[i]=THINKING; // test left and right neighbors // 當哲學家i就餐結束,放下叉子時。需要判斷左、右臨近的哲學家在自己就餐的這段時間內是否也進入了飢餓狀態,卻因為自己就餐拿走了叉子而無法同時獲得左右兩個叉子。 // 為此哲學家i在放下叉子後需要嘗試著判斷在自己放下叉子後,左/右臨近的、處於飢餓的哲學家能否進行就餐,如果可以就喚醒阻塞的哲學家執行緒,並令其進入就餐狀態(EATING) phi_test_condvar(LEFT); // 看一下左鄰居現在是否能進餐 phi_test_condvar(RIGHT); // 看一下右鄰居現在是否能進餐 //--------leave routine in monitor-------------- // lab7的參考答案 if(mtp->next_count>0){ cprintf("execute here mtp->next_count>0 \n\n\n\n\n\n"); up(&(mtp->next)); }else{ cprintf("execute here mtp->next_count=0 \n\n\n\n\n"); up(&(mtp->mutex)); } // 個人認為放叉子和取叉子的情況並不一樣,不會出現mtp->next_count>0的情況,這裡只需要釋放互斥鎖即可(如果這裡理解的有問題,還請指正) // 當放叉子的執行緒在phi_put_forks_condvar中離開管程臨界區時,只有兩種情況 // 1. 沒有發現鄰居可以進餐,自身不會被阻塞 // 2. 發現有鄰居之前被拿不到叉子阻塞了,現在可以進餐了,phi_test_condvar中的cond_signal會暫時令自己阻塞在next訊號量上 // 但是很快被自己叫醒的相鄰的哲學家執行緒在被喚醒後一離開臨界區就會將自己喚醒,在cond_signal被喚醒後的操作中mtp->next_count會自減,而變為0 // // 以上兩種情況下,由於管程本身最外面有一個mutex互斥訊號量,所以不會出現兩個執行緒同時阻塞在next訊號量中,因此也就不會出現參考答案中mtp->next_count>0的情況 // up(&(mtp->mutex)); }
phi_test_condvar函式(判斷哲學家i是否能拿起左右叉子開始就餐):
void phi_test_condvar (i) { // 當哲學家i處於飢餓狀態(HUNGRY),且其左右臨近的哲學家都沒有在就餐狀態(EATING) if(state_condvar[i]==HUNGRY&&state_condvar[LEFT]!=EATING &&state_condvar[RIGHT]!=EATING) { cprintf("phi_test_condvar: state_condvar[%d] will eating\n",i); // 哲學家i餓了(HUNGRY),且左右兩邊的叉子都沒人用。 // 令哲學家進入就餐狀態(EATING) state_condvar[i] = EATING ; cprintf("phi_test_condvar: signal self_cv[%d] \n",i); // 喚醒阻塞在對應訊號量上的哲學家執行緒 cond_signal(&mtp->cv[i]) ; } }
2.6 訊號量和管程的區別
訊號量是一個簡單、高效的同步互斥機制,但也正是由於其過於底層,所以在編寫執行緒同步程式碼時需要十分小心謹慎,對每一處訊號量的使用仔細斟酌才能保證程式的正確性,對開發人員的心智是一個巨大的負擔。
而將管程作為一個整體的結構來看的話,會發現管程雖然將控制同步的程式碼邏輯抽象為了一個固定的模板變得容易使用,但卻與要保護的臨界區業務邏輯程式碼耦合的很嚴重,作業系統的開發者很難將管程控制同步的程式碼植入進對應的應用程式內部。
因此作業系統通常只提供了訊號量以及條件變數這種偏底層、耦合性低的同步互斥機制;而管程機制則更多的由高階語言的編譯器在語言層面實現,以簡化程式設計師開發複雜併發同步程式的複雜度。高階語言編譯器在編譯本地機器程式碼時,可以在需要進行同步的程式碼邏輯塊中利用作業系統底層提供的訊號量或是條件變數機制來實現管程。
例如java中如果在方法定義時簡單的加上synchronized關鍵字就能控制多執行緒環境下不會併發的執行該方法。這是因為在編譯成位元組碼時,相比於普通方法額外插入了一些管程Monitor相關的同步控制程式碼(管程底層依賴的訊號量、條件變數機制還是取決於對應的作業系統平臺,只不過被jvm遮蔽掉了差異,讓java程式設計師感知不到)。
3. 總結
通過ucore的lab7的學習,讓我理解了等待佇列、訊號量、條件變數以及管程的大致工作原理,也對平常會接觸到的java中的synchronized、AQS、Reentrantlock其底層機制有了進一步的認識。
lab7的學習使我收穫頗豐,但這對於執行緒同步相關領域的學習還是遠遠不夠。同屬於程式間通訊IPC領域的經典問題除了哲學家就餐問題外,還有讀者/寫者問題等;對於執行緒安全問題,除了使用休眠/喚醒進行執行緒上下文切換的阻塞的方式之外,還有使用CAS等重試的方法;除了訊號量、條件變數等基於單機系統內的執行緒同步方式外,還有基於分散式系統,通過網路進行多機器執行緒同步的機制等等。雖然不夠了解的知識還有很多,但通過ucore作業系統的學習,為我學習相關的領域知識打下了基礎,也給了我相信最終能融會貫通這些知識的信心。
這篇部落格的完整程式碼註釋在我的github上:https://github.com/1399852153/ucore_os_lab (fork自官方倉庫)中的lab7_answer。
希望我的部落格能幫助到對作業系統、ucore os感興趣的人。存在許多不足之處,還請多多指教。