1. 程式人生 > >【自制作業系統13】鎖

【自制作業系統13】鎖

沒錯,就是你們這群高階程式設計師(其實我也是)所耳熟能詳但又講不明白的 鎖,只是本章不是如何用,也不是講它是什麼原理,而是在實現我們作業系統的過程中所自然而然地產生的一個需求,並且我們從零開始來實現 鎖

本章需要和上一章 【自制作業系統12】熟悉而陌生的多執行緒 連起來看,因為正是上一章我們多執行緒輸出字串時,發現了一些問題,導致我們需要想個辦法來解決,用你們高階程式設計師的牛逼的話來講,就是 為了解決執行緒不安全的問題,提出了鎖這種技術手段。

一、到目前為止的程式流程圖

為了讓大家清楚目前的程式進度,畫了到目前為止的程式流程圖,如下。(紅色是我們要實現的)

二、上一篇文章的多執行緒問題

上篇文章我們建立了兩個執行緒,加上主執行緒,一共三個執行緒迴圈列印字串,最終的輸出是這樣的

  先忽略上面那個異常,看下面話框的地方,argA 還沒有列印完,就從中間斷開了,開始列印了 argB

  其實很好解釋,因為 列印一個字串 put_str 是通過一次次呼叫 put_char 來實現的,假如任務切換剛好發生在列印字串 "argA" 剛剛列印到 "ar” 的時候切換了(實際上這概率很大),就會出現上面的問題。再往細了說,單單一個 put_char 函式,也是分成 獲取游標、列印字元、更新游標值 等多個步驟實現的,假如在中間某處發生了任務切換,不但字串被分割,還會出現少字元的情況,大家可以想想為什麼。至於最上面的異常,當然也是由於類似的原因造成的。

  上面的種種問題,歸納起來就是,雖然我們的任務切換可以發生在任何一個指令和下一條指令之間,但有的時候我們希望多條指令是具有 原子性 的,也就是要麼不執行,要執行就全部執行完,這中間不允許發生任務切換。考慮到這點,我們可以通過簡單的開關中斷來實現,就像這樣。

void k_thread_a(void* arg) { 
    char* para = arg; 
    while(1) { 
        intr_disable(); // 關中斷
        put_str(para); 
        intr_enable(); // 開中斷
    } 
}        

  我們再執行程式,就會發現上述問題被完美解決了。可別瞧不起這粗暴的方法,關中斷是實現互斥最簡單的方法,沒有之一。我們今後實現的各種互斥手段也將以它為基礎。

三、問題抽象(公共資源、臨界區、互斥、競爭條件)

 剛剛提到的問題只是特例,我們把它歸納總結為一般描述,就是:

  • 公共資源:可以是公共記憶體、公共檔案、公共硬體等,總之是被所有任務共享的一套資源
  • 臨界區:麼各任務中訪問公共資源的指令程式碼組成的區域,注意是 指令 哦
  • 互斥:某一時刻公共資源只能被 1 個任務獨享,即不允許多個任務同時出現在自己的臨界區中
  • 競爭條件:多個任務以非互斥的方式同時進入臨界區,對公共資源的訪問是以競爭的方式並行進行的,因此公共資源的最終狀態依賴於這些任務的臨界區中的微操作執行次序。

 在我們這個例子中,對應關係就是

  • 公共資源:游標暫存器、視訊記憶體
  • 臨界區:put_char 函式,因為該函式都對公共資源游標暫存器進行了訪問
  • 互斥:暫時通過開關中斷,實現 put_str 之間的互斥
  • 競爭條件:“少字元”問題是對視訊記憶體未實現互斥訪問造成的,“GP”異常是對游標暫存器未實現互斥訪問造成的

  總結起來,多執行緒的問題就是,多個任務同時出現在臨界區,也就是產生了競爭條件。那解決問題的辦法就只有一個,那就是 不要讓多個任務同時出現在臨界區。怎麼做到這一點呢?剛剛簡單粗暴的 開關中斷 是一種方法,下面要說的更靈活的 鎖 也是一種方法,再後面把多條指令重新用 一條原子指令 實現,如 CAS,也是一種方法。千萬不要被再後面各種各樣五花八門的各種技術繞暈,多執行緒解決的問題都是,不要讓多個任務同時出現在臨界區,僅此而已。

四、訊號量與鎖

我們的鎖是用 訊號量 來實現的,訊號量就是一個計數器,它包括了 P(down)和 V(up)操作 V(up):可以理解為釋放鎖
  1. 將訊號量的值加 1
  2. 喚醒在此訊號量上等待的執行緒
P(down):可以理解為獲取鎖
  1. 判斷訊號量是否大於 0
  2. 若訊號量大於 0,則將訊號量減 1
  3. 若訊號量等於 0,當前執行緒將自己阻塞,以在此訊號量上等待

有了這兩個操作,兩個執行緒在進入臨界區時,便可以這樣操作

  1. 執行緒 A 進入臨界區前先通過 down 操作 獲得鎖,此時訊號量的值便為 0
  2. 執行緒 B 再進入臨界區時也通過 down 操作獲得鎖,由於訊號量為 0,執行緒 B 便在此訊號量上等待,也就是相當於執行緒 B 進入了 阻塞
  3. 當執行緒 A 從臨界區出來後執行 up 操作 釋放鎖,此時訊號量的值重新變成 1,之後執行緒 A 將執行緒 B 喚醒
  4. 執行緒 B 醒來後獲得了鎖,進入臨界區

五、程式碼實現

鎖的底層實現

sync.h

 1 // 訊號量結構
 2 struct semaphore {
 3     uint8_t value;
 4     struct list waiters;
 5 };
 6 
 7 // 鎖結構
 8 struct lock {
 9     struct task_struct* holder; // 持有者
10     struct semaphore semaphore; // 二元訊號量
11     uint32_t holder_repeat_nr; // 持有者重複申請鎖的次數
12 };

sync.c

 1 #include "sync.h"
 2 #include "list.h"
 3 #include "global.h"
 4 #include "interrupt.h"
 5 
 6 // 初始化訊號量
 7 void sema_init(struct semaphore* psema, uint8_t value) {
 8     psema->value = value; // 為訊號量賦初值
 9     list_init(&psema->waiters); // 初始化訊號量的等待佇列
10 }
11 
12 // 初始化鎖 plock
13 void lock_init(struct lock* plock) {
14     plock->holder = NULL;
15     plock->holder_repeat_nr = 0;
16     sema_init(&plock->semaphore, 1); // 訊號量初值為1
17 }
18 
19 // 訊號量 down 操作
20 void sema_down(struct semaphore* psema) {
21     // 關閉中斷保證原子操作
22     enum intr_status old_status = intr_disable();
23     while(psema->value == 0) {
24         // 表示已經被別人持有,當前執行緒把自己加入該鎖的等待佇列,然後阻塞自己
25         list_append(&psema->waiters, &running_thread()->general_tag);
26         thread_block(TASK_BLOCKED);
27     }
28     // value不為0,則可以獲得鎖
29     psema->value--;
30     intr_set_status(old_status);
31 }
32     
33 // 訊號量的 up 操作
34 void sema_up(struct semaphore* psema) {
35     // 關閉中斷保證原子操作
36     enum intr_status old_status = intr_disable();
37     
38     if (!list_empty(&psema->waiters)) {
39         struct task_struct* thread_blocked = elem2entry(struct task_struct, general_tag, list_pop(&psema->waiters));
40         thread_unblock(thread_blocked);
41     }
42     
43     psema->value++;
44     intr_set_status(old_status);
45 }
46 
47 // 獲取鎖 plock
48 void lock_acquire(struct lock* plock) {
49     if (plock->holder != running_thread()) {
50         sema_down(&plock->semaphore);
51         plock->holder = running_thread();
52         plock->holder_repeat_nr = 1;
53     } else {
54         plock->holder_repeat_nr++;
55     }
56 }
57 
58 // 釋放鎖 plock
59 void lock_release(struct lock* plock) {
60     if (plock->holder_repeat_nr > 1) {
61         plock->holder_repeat_nr--;
62         return;
63     }
64     plock->holder = NULL;
65     plock->holder_repeat_nr = 0;
66     sema_up(&plock->semaphore);
67 }

thread.c

 1 ...
 2 
 3 // 當前執行緒將自己阻塞,標誌其狀態為 stat(取值必須為 BLOCKED WAITING HANGING 之一)
 4 void thread_block(enum task_status stat) {
 5     enum intr_status old_status = intr_disable();
 6     struct task_struct* cur_thread = running_thread();
 7     cur_thread->status = stat;
 8     schedule();
 9     intr_set_status(old_status);
10 }
11 
12 // 解除阻塞
13 void thread_unblock(struct task_struct* pthread) {
14     enum intr_status old_status = intr_disable();
15     if (pthread->status != TASK_READY) {
16         if (elem_find(&thread_ready_list, &pthread->general_tag)) {
17             // 錯誤!blocked thread in ready_list
18         }
19         // 放到佇列的最前面,使其儘快得到排程
20         list_push(&thread_ready_list, &pthread->general_tag);
21         pthread->status = TASK_READY;
22     }
23     intr_set_status(old_status);
24 }

畫黃線是重點要看的部分,也就是我們的目的,實現 獲取鎖 和 釋放鎖 兩個函式。看整體邏輯

  • 獲取鎖:如果鎖的持有者不是當前執行緒,則 sema_down 訊號量減一 ,鎖的持有者變為當前執行緒。如果鎖的持有者就是當前執行緒,則變數 holder_repeat_nr 遞增,可以理解為可重入的次數
  • 釋放鎖:變數 holder_repeat_nr 遞減少,鎖的持有者置空,執行 sema_up 訊號量遞增

上述兩個函式中有兩個子函式,是對訊號量操作的,我們看一下

  • sema_down(訊號量遞減):while 判斷訊號量值 value 是否為 0,若不為 0 則可以獲取鎖,直接將其減一;若為 0 表示鎖被別的執行緒持有,則該執行緒加入訊號量的等待佇列 waiters,並阻塞該執行緒 thread_block。
  • sema_up(訊號量遞增):訊號量值 value++,同時若訊號量等待佇列 waiters 不為空,則表示有需要喚醒的執行緒,pop 出一個,喚醒該執行緒 thread_unblock

上述函式中又有兩個子函式,我們繼續拆解

  • thread_block(阻塞):將當前執行緒的狀態,改為阻塞態的一種(BLOCKED WAITING HANGING),並執行任務切換函式 schedule,由該函式真正將其換下 CPU
  • thread_unblock(喚醒):喚醒一個指定執行緒,也就是上面由 sema_up 函式裡從 waiters 中 pop 出來的執行緒。如果該執行緒不是 READY 狀態(應該說不出錯的話就不應該是 READY 狀態),則將其放到 thread_ready_list 中,等待下次被排程

忘記了 schedule 函式的,可以看下面回顧一下

 1 // 實現任務排程
 2 void schedule() {
 3     struct task_struct* cur = running_thread();
 4     if (cur->status == TASK_RUNNING) {
 5         // 只是時間片到了,加入就緒佇列隊尾
 6         list_append(&thread_ready_list, &cur->general_tag);
 7         cur->ticks = cur->priority;
 8         cur->status = TASK_READY;
 9     } else {
10         // 需要等某事件發生後才能繼續上 cpu,不加入就緒佇列
11     }
12     
13     thread_tag = NULL;
14     // 就緒佇列取第一個,準備上cpu
15     thread_tag = list_pop(&thread_ready_list);
16     struct task_struct* next = elem2entry(struct task_struct, general_tag, thread_tag);
17     next->status = TASK_RUNNING;
18     switch_to(cur, next);
19 }
schedule

將所有這些都串起來,我畫了個圖,表示在各種情況下,各個變數是如何變化的(藍色代表增加,綠色代表減少)

 

使用鎖實現 console 輸出

上一步我們只是實現了鎖(其實就是實現了 獲取鎖 和 釋放鎖 兩個函式),但我們還沒有任何地方用它,接下來我們就重新封裝一個原來多執行緒呼叫會出錯的 put_str 函式的升級版(原子化) console_put_str

 1 static struct lock console_lock;
 2 
 3 void console_init() {
 4     lock_init(&console_lock);
 5 }
 6 
 7 void console_acquire() {
 8     lock_acquire(&console_lock);
 9 }
10 
11 void console_release() {
12     lock_release(&console_lock);
13 }
14 
15 void console_put_str(char* str) {
16     console_acquire();
17     put_str(str);
18     console_release();
19 }

可以看到,其實就是把 put_str 函式加了鎖,又封裝了一層而已。接下來我們 main 函式呼叫一下新輸出函式的試試

 1 int main(void){
 2     put_str("I am kernel\n");
 3     init_all();
 4     thread_start("k_thread_a", 31, k_thread_a, "argA ");
 5     thread_start("k_thread_b", 8, k_thread_b, "argB ");
 6     intr_enable();
 7     
 8     while(1) {
 9         put_str("Main ");
10         console_put_str("Main ");
11     }
12     return 0;
13 }
14 
15 void k_thread_a(void* arg) {
16     char* para = arg;
17     while(1) {
18         console_put_str(para);
19     }
20 }
21 
22 void k_thread_b(void* arg) {
23     char* para = arg;
24     while(1) {
25         console_put_str(para);
26     }
27 }

可以看到畫黃線的部分,我們只是把原來的 put_str 函式,更換成了 console_put_str 函數了而已,這樣在輸出的時候就有了鎖的保護,多執行緒不再有上一章出現的問題了。簡單吧!

執行

這回終於沒有報錯,且字元都整齊無誤地輸出在了螢幕上,不再有覆蓋字元的現象了

 

寫在最後:開源專案和課程規劃

如果你對自制一個作業系統感興趣,不妨跟隨這個系列課程看下去,甚至加入我們(下方有公眾號和小助手微信),一起來開發。

參考書籍

《作業系統真相還原》這本書真的贊!強烈推薦

專案開源

專案開源地址:https://gitee.com/sunym1993/flashos

當你看到該文章時,程式碼可能已經比文章中的又多寫了一些部分了。你可以通過提交記錄歷史來檢視歷史的程式碼,我會慢慢梳理提交歷史以及專案說明文件,爭取給每一課都準備一個可執行的程式碼。當然文章中的程式碼也是全的,採用複製貼上的方式也是完全可以的。

如果你有興趣加入這個自制作業系統的大軍,也可以在留言區留下您的聯絡方式,或者在 gitee 私信我您的聯絡方式。

課程規劃

本課程打算出系列課程,我寫到哪覺得可以寫成一篇文章了就寫出來分享給大家,最終會完成一個功能全面的作業系統,我覺得這是最好的學習作業系統的方式了。所以中間遇到的各種坎也會寫進去,如果你能持續跟進,跟著我一塊寫,必然會有很好的收貨。即使沒有,交個朋友也是好的哈哈。

目前的系列包括

  • 【自制作業系統01】硬核講解計算機的啟動過程
  • 【自制作業系統02】環境準備與啟動區實現
  • 【自制作業系統03】讀取硬碟中的資料
  • 【自制作業系統04】從真實模式到保護模式
  • 【自制作業系統05】開啟記憶體分頁機制
  • 【自制作業系統06】終於開始用 C 語言了,第一行核心程式碼!
  • 【自制作業系統07】深入淺出特權級
  • 【自制作業系統08】中斷
  • 【自制作業系統09】中斷的程式碼實現
  • 【自制作業系統10】記憶體管理系統
  • 【自制作業系統11】中場休息之細節是魔鬼
  • 【自制作業系統12】熟悉而陌生的多執行緒

 微信公眾號

  我要去阿里(woyaoquali)

 小助手微訊號

  Angel(angel19980323)

whi