1. 程式人生 > >作業系統同步原語

作業系統同步原語

競態條件

在一般的作業系統中,不同的程序可能會分享一塊公共的儲存區域,例如記憶體或者是硬碟上的檔案,這些程序都允許在這些區域上進行讀寫。作業系統有一些職責,來協調這些使用公共區域的程序之間以正確的方式進行想要的操作。這些程序之間需要通訊,需要互相溝通,有商有量,才能保證一個程序的動作不會影響到另外一個程序正常的動作,進而導致程序執行後得不到期望的結果。在作業系統概念中,通常用 IPC(Inter Process Communication,即程序間通訊)這個名詞來代表多個程序之間的通訊。為了解釋什麼是競態條件(race condition),我們引入一個簡單的例子來說明:一個檔案中儲存了一個數字 n,程序 A 和程序 B 都想要去讀取這個檔案的數字,並把這個數字加 1 後,儲存迴文件。假設 n 的初始值是 0,在我們理想的情況下,程序 A 和程序 B 執行後,檔案中 n 的值應該為 2,但實際上可能會發生 n 的值為 1。我們可以考量一下,每個程序做這件事時,需要經過什麼步驟:

  1. 讀取檔案裡 n 的值
  2. 令 n = n + 1
  3. 把新的 n 值儲存迴文件。

在進一步解釋競態條件之前,必須先回顧作業系統概念中的幾個知識點:

  1. 程序是可以併發執行的,即使只有一個 CPU 的時候)
  2. 作業系統的時鐘中斷會引起程序執行的重新排程,
  3. 除了時鐘中斷,來自其它裝置的中斷也會引起程序執行的重新排程

假設程序 A 在執行完步驟 1 和 2,但還沒開始執行步驟 3 時,發生了一個時鐘中斷,這個時候作業系統通過排程,讓程序 B 開始執行,程序 B 執行步驟 1 時,發現 n 的值為 0,於是它執行步驟 2 和 3,最終會把 n = 1 儲存到檔案中。之後程序 A 繼續執行時,由於它並不知道在它執行步驟 3 之前,程序 B 已經修改了檔案裡的值,所以程序 A 也會把 n = 1 寫回到檔案中。這就是問題所在,程序 A 在執行的過程中,會有別的程序去操作它所操作的資料。

唯一能讓 n = 2 的方法,只能期望程序 A 和程序 B 按順序分別完整地執行完所有步驟。我們可以給競態條件下一個定義了

兩個或者多個程序讀寫某些共享資料,而最後的結果取決於程序執行的準確時序,稱為競態條件。

在上述的文字中,我們使用程序作為物件來討論競態條件,實際上對於執行緒也同樣適用,這裡的執行緒包含但不限於核心執行緒、使用者執行緒。因為在作業系統中,程序其實是依靠執行緒來執行程式的。更甚至,在 Java 語言的執行緒安全中,競態條件這個概念也同樣適用。(參考《Java 併發程式設計實戰》第二章)

互斥與臨界區

如何避免 race condition,我們需要以某種手段,確保當一個程序在使用一個共享變數或者檔案時,其它的程序不能做同樣的操作,換言之,我們需要“互斥”。以上述例子作為例子,我們可以把步驟 1 - 3 這段程式片段定義為臨界區,臨界區意味著這個區域是敏感的,因為一旦程序執行到這個區域,那麼意味著會對公共資料區域或者檔案進行操作,也意味著有可能有其它程序也正執行到了臨界區。如果能夠採用適當的方式,使得這兩個程序不會同時處於臨界區,那麼就能避免競態條件。也就是,我們需要想想怎麼樣做到“互斥”。

互斥的方案

互斥的本質就是阻止多個程序同時進入臨界區

遮蔽中斷

之前提到的例子中,程序 B 之所以能夠進入臨界區,是因為程序 A 在臨界區中受到了中斷。如果我們讓程序 A 在進入臨界區後,立即對所有中斷進行遮蔽,離開臨界區後,才響應中斷,那麼即使發生中斷,那麼 CPU 也不會切換到其它程序,因此此時程序 A 可以放心地修改檔案內容,不用擔心其它的程序會干擾它的工作。然而,這個設想是美好,實際上它並不可行。第一個,如果有多個CPU,那麼程序 A 無法對其它 CPU 遮蔽中斷,它只能遮蔽正在排程它的 CPU,因此由其它 CPU 排程的程序,依然可以進入臨界區;第二,關於權力的問題,是否可以把遮蔽中斷的權力交給使用者程序?如果這個程序遮蔽中斷後再也不響應中斷了, 那麼一個程序有可能掛住整個作業系統。

鎖變數

也許可以通過設定一個鎖標誌位,將其初始值設定為 0 ,當一個程序想進入臨界區時,先檢查鎖的值是否為 0,如果為 0,則設定為 1,然後進入臨界區,退出臨界區後把鎖的值改為0;若檢查時鎖的值已經為1,那麼代表其他程序已經在臨界區中了,於是程序進行迴圈等待,並不斷檢測鎖的值,直到它變為0。但這種方式也存在著競態條件,原因是,當一個程序讀出鎖的值為0時,在它將其值設定為1之前,另一個程序被排程起來,它也讀到鎖的值為0,這樣就出現了兩個程序同時都在臨界區的情況。

嚴格輪換法

鎖變數之所以出問題,其實是因為將鎖變數由0改為1這個動作,是由想要獲取鎖的程序去執行的。如果我們把這個動作改為由已經獲得鎖的程序去執行,那麼就不存在競態條件了。先設定一個變數 turn,代表當前允許誰獲得鎖,假設有兩個程序,程序 A 的邏輯如下所示:

        while (turn != 0){// 如果還沒輪到自己獲取鎖,則進入迴圈等待
        }
        do_critical_region();// 執行臨界區的程式
        turn = 1;// 由獲得鎖的一方將鎖變數修改為其它值,允許其它程序獲得鎖
        do_non_critical_region();// 執行非臨界區的程式

程序 B 的邏輯如下所示:

        while (turn != 1) {// 如果還沒輪到自己獲取鎖,則進入迴圈等待
        }
        do_critical_region();// 執行臨界區的程式
        turn = 0;// 由獲得鎖的一方將鎖變數修改為其它值,允許其它程序獲得鎖
        do_non_critical_region();// 執行非臨界區的程式

但這裡需要考慮到一個事情,假設程序 A 的 do_non_critical_region() 需要執行很長時間,即程序 A 的非臨界區的邏輯需要執行較長時間,而程序 B 的非臨界區的邏輯很快就執行完,顯然,程序 A 進入臨界區的頻率會比程序 B 小一點,理想的情況下,程序 B 應該多進入臨界區幾次。但是由於程序 B 在執行非臨界區邏輯前會把 turn 設定為 0,等它很快地把非臨界區的邏輯執行完後,回來檢查 turn 的值時,發現 turn 的值一直都不是 1,turn 的值需要程序 A 把它設定為 1,而程序 A 此時卻正在進行著漫長的非臨界區邏輯程式碼,所以導致程序 B 無法進入臨界區。這就說明,在一個程序比另一個程序慢很多的情況下,嚴格輪換法並不是一個好辦法。

Peterson 演算法

嚴格輪換法的問題就出在嚴格兩個字上,即多個程序之間是輪流進入臨界區的,根本原因是想要獲得鎖的程序需要依賴其它程序對於鎖變數的修改,而其它程序都要先經過非臨界區邏輯的執行才會去修改鎖變數。嚴格輪換法中的 turn 變數不僅用來表示當前該輪到誰獲取鎖,而且它的值未改變之前,都意味著它依然阻止了其它程序進入臨界區,恰恰好,一個程序總是要經過非臨界區的邏輯後才會去改變turn的值。因此,我們可以用兩個變數來表示,一個變量表示當前應該輪到誰獲得鎖,另一個變量表示當前程序已經離開了臨界區,這種方法實際上叫做 Peterson 演算法,是由荷蘭數學家 T.Dekker 提出來的。

    static final int N = 2;
    int turn = 0;
    boolean[] interested = new boolean[N];

    void enter_region(int process) {
        int other = 1 - process;
        interested[process] = true;
        turn = process;
        while(turn == process && !interested[other]) {
        }
    }

    void exit_region(int process) {
        interested[process] = false;
    }

程序在需要進入臨界區時,先呼叫 enter_region,離開臨界區後,呼叫 exit_region。Peterson 演算法使得程序在離開臨界區後,立馬消除了自己對於臨界區的“興趣”,因此其它程序完全可以根據 turn 的值,來判斷自己是否可以合法進入臨界區。

TSL 指令

回顧一下我們之前提到的“鎖變數”這種方法,它的一個致命的缺點是對狀態變數進行改變的時候,如從 0 改為 1 或者從 1 改為 0 時,是可以被中斷打斷的,因此存在競態條件。之後我們在鎖變數的基礎上,提出如果鎖變數的修改不是由想要獲取進入臨界區的程序來修改,而是由已經進入臨界區後想要離開臨界區的程序來修改,就可以避免競態條件,繼而引發出嚴格輪換法,以及從嚴格輪換法基礎上改進的 Peterson 演算法。這些方法都是從軟體的方式去考慮的。實際上,可以在硬體 CPU 的支援下,保證鎖變數的改變不被打斷,使鎖變數成為一種很好的解決程序互斥的方法。目前大多數的計算機的 CPU,都支援 TSL 指令,其全稱為 Test and Set Lock,它將一個記憶體的變數(字)讀取暫存器 RX 中,然後再該記憶體地址上存一個非零值,讀取操作和寫入操作從硬體層面上保證是不可打斷的,也就是說是原子性的。它採用的方式是在執行 TSL 指令時鎖住記憶體匯流排,禁止其它 CPU 在 TSL 指令結束之前訪問記憶體。這也是我們常說的 CAS (Compare And Set)。當需要把鎖變數從 0 改為 1 時,先把記憶體的值複製到暫存器,並將記憶體的值設定為 1,然後檢查暫存器的值是否為 0,如果為 0,則操作成功,如果非 0 ,則重複檢測,知道暫存器的值變為 0,如果暫存器的值變為 0 ,意味著另一個程序離開了臨界區。程序離開臨界區時,需要把記憶體中該變數的值設定為 0。

忙等待

上述提到的 Peterson 演算法,以及 TSL 方法,實際上它們都有一個特點,那就是在等待進入臨界區的時候,它們採用的是忙等待的方式,我們也常常稱之為自旋。它的缺點是浪費 CPU 的時間片,並且會導致優先順序反轉的問題。

考慮一臺計算機有兩個程序, H 優先順序較高,L 優先順序較低。排程規則規定,只要 H 處於就緒狀態,就可以執行。在某一時刻,L 處於臨界區內,此時 H 處於就緒態,準備執行。但 H 需要進行忙等待,而 L 由於優先順序低,沒法得到排程,因此也無法離開臨界區,所以 H 將會永遠忙等待,而 L 總無法離開臨界區。這種情況稱之為優先順序反轉問題(priority inversion problem)

程序的阻塞與喚醒

作業系統提供了一些原語,sleep 和 wakeup。

核心提供給核外呼叫的過程或者函式成為原語(primitive),原語在執行過程中不允許中斷。

sleep 是一個將呼叫程序阻塞的系統呼叫,直到另外一個程序呼叫 wakeup 方法,將被阻塞的程序作為引數,將其喚醒。阻塞與忙等待最大的區別在於,程序被阻塞後CPU將不會分配時間片給它,而忙等待則是一直在空轉,消耗 CPU 的時間片。

訊號量

首先需要明白的一點是,訊號量的出現是為了解決什麼問題,由於一個程序的阻塞和喚醒是在不同的程序中造成的,比如說程序 A 呼叫了 sleep() 會進入阻塞,而程序 B 呼叫 wakeup(A)會把程序 A 喚醒。因為是在不同的程序中進行的,所以也存在著被中斷的問題。加入程序 A 根據邏輯,需要呼叫 sleep() 進入阻塞狀態,然而,就在它呼叫 sleep 方法之前,由於時鐘中斷,程序 B 開始運行了,根據邏輯,它呼叫了 wakeup() 方法喚醒程序 A,可是由於程序 A 還未進入阻塞狀態,因此這個 wakeup 訊號就丟失了,等待程序 A 從之前中斷的位置開始繼續執行時並進入阻塞後,可能再也沒有程序去喚醒它了。因此,程序的阻塞和喚醒,應該需要額外引進一個變數來記錄,這個變數記錄了喚醒的次數,每次被喚醒,變數的值加1。有了這個變數,即使wakeup操作先於sleep操作,但wakeup操作會被記錄到變數中,當程序進行sleep時,因為已經有其它程序喚醒過了,此時認為這個程序不需要進入阻塞狀態。這個變數,在作業系統概念中,則被稱為訊號量(semaphore),由 Dijkstra 在 1965 年提出的一種方法。對訊號量有兩種操作, down 和 up。down 操作實際上對應著 sleep,它會先檢測訊號量的值是否大於0,若大於0,則減1,程序此時無須阻塞,相當於消耗掉了一次 wakeup;若訊號量為0,程序則會進入阻塞狀態。而 up 操作對應著 wakeup,進行 up 操作後,如果發現有程序阻塞在這個訊號量上,那麼系統會選擇其中一個程序將其喚醒,此時訊號量的值不需要變化,但被阻塞的程序已經少了一個;如果 up 操作時沒有程序阻塞在訊號量上,那麼它會將訊號量的值加1。有些地方會把 down 和 up 操作稱之為 PV 操作,這是因為在 Dijkstra 的論文中,使用的是 P 和 V 分別來代表 down 和 up。訊號量的 down 和 up 操作,是作業系統支援的原語,它們是具有原子性的操作,不會出現被中斷的情況。

互斥量

互斥量(mutex)其實是訊號量的一種特例,它的值只有 0 和 1,當我們不需要用到訊號量的計數能力時,我們可以使用互斥量,實際上也意味著臨界區值同一時間只允許一個程序進入,而訊號量是允許多個程序同時進入臨界區的。