作業系統和併發的愛恨糾葛

程式設計師cxuan發表於2020-08-07

我一直沒有急於寫併發的原因是我參不透作業系統,如今,我已經把作業系統刷了一遍,這次試著寫一些併發,看看能不能寫清楚,卑微小編線上求鼓勵...... 我打算採取作業系統和併發同時結合講起來的方式。

併發歷史

在計算機最早期的時候,沒有作業系統,執行程式只需要一個過程,那就是從頭到尾依次執行。任何資源都會為這個程式服務,這必然就會存在 浪費資源 的情況。

這裡說的浪費資源指的是資源空閒,沒有充分使用的情況。

作業系統為我們的程式帶來了 併發性,作業系統使我們的程式同時執行多個程式,一個程式就是一個程式,也就相當於同時執行了多個程式。

作業系統是一個併發系統,併發性是作業系統非常重要的特徵,作業系統具有同時處理和排程多個程式的能力,比如多個 I/O 裝置同時在輸入輸出;裝置 I/O 和 CPU 計算同時進行;記憶體中同時有多個系統和使用者程式被啟動交替、穿插地執行。作業系統在協調和分配程式的同時,作業系統也會為不同程式分配不同的資源。

作業系統實現多個程式同時執行解決了單個程式無法做到的問題,主要有下面三點

  • 資源利用率,我們上面說到,單個程式存在資源浪費的情況,舉個例子,當你在為某個資料夾賦予許可權的時候,輸入程式無法接受外部的輸入字元,只能等到許可權賦予完畢後才能接受外部輸入。綜合來講,就是在等待程式時無法執行其他工作。如果在等待程式的同時可以執行另一個程式,那麼將會大大提高資源的利用率。(資源並不會覺得累)因為它不會划水~
  • 公平性,不同的使用者和程式對於計算機上的資源有著同樣的使用權。一種高效的執行方式是為不同的程式劃分時間片使用資源,但是有一點需要注意,作業系統可以決定不同程式的優先順序,雖然每個程式都有能夠公平享有資源的權利,但是每次前一個程式釋放資源後的同時有一個優先順序更高的程式搶奪資源,就會造成優先順序低的程式無法獲得資源,久而久之會導致程式飢餓。
  • 便利性,單個程式是無法通訊的,通訊這一點我認為其實是一種避雷針策略,通訊的本質就是資訊交換,及時進行資訊交換能夠避免資訊孤島,做重複性的工作;任何併發能做的事情,順序程式設計也能夠實現,只不過這種方式效率很低,它是一種 阻塞式 的。

但是,順序程式設計(也稱為序列程式設計)也不是一無是處的,序列程式設計的優勢在於其直觀性和簡單性,客觀來講,序列程式設計更適合我們人腦的思考方式,但是我們並不會滿足於順序程式設計,we want it more!!! 。資源利用率、公平性和便利性促使著程式出現的同時也促使著執行緒的出現。

如果你還不是很理解程式和執行緒的區別的話,那麼我就以我多年作業系統的經驗(吹牛逼,實則半年)來為你解釋一下:程式是一個應用程式,而執行緒是應用程式中的一條順序流

或者阮一峰老師也給出了你通俗易懂的解釋

摘自 https://www.ruanyifeng.com/blog/2013/04/processes_and_threads.html

執行緒會共享程式範圍內的資源,例如記憶體和檔案控制程式碼,但是每個執行緒也有自己私有的內容,比如程式計數器、棧以及區域性變數。下面彙總了程式和執行緒共享資源的區別

執行緒被描述為一種輕量級的程式,輕量級體現線上程的建立和銷燬要比程式的開銷小很多。

注意:任何比較都是相對的。

在大多數現代作業系統中,都以執行緒為基本的排程單位,所以我們的視角著重放在對執行緒的探究。

執行緒

優勢和劣勢

合理使用執行緒是一門藝術,合理編寫一道準確無誤的多執行緒程式更是一門藝術,如果執行緒使用得當,能夠有效的降低程式的開發和維護成本。

在 GUI 中,執行緒可以提高使用者介面的響應靈敏度,在伺服器應用程式中,併發可以提高資源利用率以及系統吞吐率。

Java 很好的在使用者空間實現了開發工具包,並在核心空間提供系統呼叫來支援多執行緒程式設計,Java 支援了豐富的類庫 java.util.concurrent 和跨平臺的記憶體模型,同時也提高了開發人員的門檻,併發一直以來是一個高階的主題,但是現在,併發也成為了主流開發人員的必備素質。

雖然執行緒帶來的好處很多,但是編寫正確的多執行緒(併發)程式是一件極困難的事情,併發程式的 Bug 往往會詭異地出現又詭異的消失,在當你認為沒有問題的時候它就出現了,難以定位 是併發程式的一個特徵,所以在此基礎上你需要有紮實的併發基本功。那麼,併發為什麼會出現呢?

為什麼是併發

計算機世界的快速發展離不開 CPU、記憶體和 I/O 裝置的高速發展,但是這三者一直存在速度差異性問題,我們可以從儲存器的層次結構可以看出

CPU 內部是暫存器的構造,暫存器的訪問速度要高於快取記憶體,快取記憶體的訪問速度要高於記憶體,最慢的是磁碟訪問。

程式是在記憶體中執行的,程式裡大部分語句都要訪問記憶體,有些還需要訪問 I/O 裝置,根據漏桶理論來說,程式整體的效能取決於最慢的操作也就是磁碟訪問速度。

因為 CPU 速度太快了,所以為了發揮 CPU 的速度優勢,平衡這三者的速度差異,計算機體系機構、作業系統、編譯程式都做出了貢獻,主要體現為:

  • CPU 使用快取來中和和記憶體的訪問速度差異
  • 作業系統提供程式和執行緒排程,讓 CPU 在執行指令的同時分時複用執行緒,讓記憶體和磁碟不斷互動,不同的 CPU 時間片 能夠執行不同的任務,從而均衡這三者的差異
  • 編譯程式提供優化指令的執行順序,讓快取能夠合理的使用

我們在享受這些便利的同時,多執行緒也為我們帶來了挑戰,下面我們就來探討一下併發問題為什麼會出現以及多執行緒的源頭是什麼

執行緒帶來的安全性問題

執行緒安全性是非常複雜的,在沒有采用同步機制的情況下,多個執行緒中的執行操作往往是不可預測的,這也是多執行緒帶來的挑戰之一,下面我們給出一段程式碼,來看看安全性問題體現在哪

public class TSynchronized implements Runnable{

    static int i = 0;

    public void increase(){
        i++;
    }


    @Override
    public void run() {
        for(int i = 0;i < 1000;i++) {
            increase();
        }
    }

    public static void main(String[] args) throws InterruptedException {

        TSynchronized tSynchronized = new TSynchronized();
        Thread aThread = new Thread(tSynchronized);
        Thread bThread = new Thread(tSynchronized);
        aThread.start();
        bThread.start();
        System.out.println("i = " + i);
    }
}

這段程式輸出後會發現,i 的值每次都不一樣,這不符合我們的預測,那麼為什麼會出現這種情況呢?我們先來分析一下程式的執行過程。

TSynchronized 實現了 Runnable 介面,並定義了一個靜態變數 i,然後在 increase 方法中每次都增加 i 的值,在其實現的 run 方法中進行迴圈呼叫,共執行 1000 次。

可見性問題

在單核 CPU 時代,所有的執行緒共用一個 CPU,CPU 快取和記憶體的一致性問題容易解決,CPU 和 記憶體之間

如果用圖來表示的話我想會是下面這樣

在多核時代,因為有多核的存在,每個核都能夠獨立的執行一個執行緒,每顆 CPU 都有自己的快取,這時 CPU 快取與記憶體的資料一致性就沒那麼容易解決了,當多個執行緒在不同的 CPU 上執行時,這些執行緒操作的是不同的 CPU 快取

因為 i 是靜態變數,沒有經過任何執行緒安全措施的保護,多個執行緒會併發修改 i 的值,所以我們認為 i 不是執行緒安全的,導致這種結果的出現是由於 aThread 和 bThread 中讀取的 i 值彼此不可見,所以這是由於 可見性 導致的執行緒安全問題。

原子性問題

看起來很普通的一段程式卻因為兩個執行緒 aThreadbThread 交替執行產生了不同的結果。但是根源不是因為建立了兩個執行緒導致的,多執行緒只是產生執行緒安全性的必要條件,最終的根源出現在 i++ 這個操作上。

這個操作怎麼了?這不就是一個給 i 遞增的操作嗎?也就是 i++ => i = i + 1,這怎麼就會產生問題了?

因為 i++ 不是一個 原子性 操作,仔細想一下,i++ 其實有三個步驟,讀取 i 的值,執行 i + 1 操作,然後把 i + 1 得出的值重新賦給 i(將結果寫入記憶體)。

當兩個執行緒開始執行後,每個執行緒都會把 i 的值讀入到 CPU 快取中,然後執行 + 1 操作,再把 + 1 之後的值寫入記憶體。因為執行緒間都有各自的虛擬機器棧和程式計數器,他們彼此之間沒有資料交換,所以當 aThread 執行 + 1 操作後,會把資料寫入到記憶體,同時 bThread 執行 + 1 操作後,也會把資料寫入到記憶體,因為 CPU 時間片的執行週期是不確定的,所以會出現當 aThread 還沒有把資料寫入記憶體時,bThread 就會讀取記憶體中的資料,然後執行 + 1操作,再寫回記憶體,從而覆蓋 i 的值,導致 aThread 所做的努力白費。

為什麼上面的執行緒切換會出現問題呢?

我們先來考慮一下正常情況下(即不會出現執行緒安全性問題的情況下)兩條執行緒的執行順序

可以看到,當 aThread 在執行完整個 i++ 的操作後,作業系統對執行緒進行切換,由 aThread -> bThread,這是最理想的操作,一旦作業系統在任意 讀取/增加/寫入 階段產生執行緒切換,都會產生執行緒安全問題。例如如下圖所示

最開始的時候,記憶體中 i = 0,aThread 讀取記憶體中的值並把它讀取到自己的暫存器中,執行 +1 操作,此時發生執行緒切換,bThread 開始執行,讀取記憶體中的值並把它讀取到自己的暫存器中,此時發生執行緒切換,執行緒切換至 aThread 開始執行,aThread 把自己暫存器的值寫回到記憶體中,此時又發生執行緒切換,由 aThread -> bThread,執行緒 bThread 把自己暫存器的值 +1 然後寫回記憶體,寫完後記憶體中的值不是 2 ,而是 1, 記憶體中的 i 值被覆蓋了。

我們上面提到 原子性 這個概念,那麼什麼是原子性呢?

併發程式設計的原子性操作是完全獨立於任何其他程式執行的操作,原子操作多用於現代作業系統和並行處理系統中。

原子操作通常在核心中使用,因為核心是作業系統的主要元件。但是,大多數計算機硬體,編譯器和庫也提供原子性操作。

在載入和儲存中,計算機硬體對儲存器字進行讀取和寫入。為了對值進行匹配、增加或者減小操作,一般通過原子操作進行。在原子操作期間,處理器可以在同一資料傳輸期間完成讀取和寫入。 這樣,其他輸入/輸出機制或處理器無法執行儲存器讀取或寫入任務,直到原子操作完成為止。

簡單來講,就是原子操作要麼全部執行,要麼全部不執行。資料庫事務的原子性也是基於這個概念演進的。

有序性問題

在併發程式設計中還有帶來讓人非常頭疼的 有序性 問題,有序性顧名思義就是順序性,在計算機中指的就是指令的先後執行順序。一個非常顯而易見的例子就是 JVM 中的類載入

這是一個 JVM 載入類的過程圖,也稱為類的生命週期,類從載入到 JVM 到解除安裝一共會經歷五個階段 載入、連線、初始化、使用、解除安裝。這五個過程的執行順序是一定的,但是在連線階段,也會分為三個過程,即 驗證、準備、解析 階段,這三個階段的執行順序不是確定的,通常交叉進行,在一個階段的執行過程中會啟用另一個階段。

有序性問題一般是編譯器帶來的,編譯器有的時候確實是 好心辦壞事,它為了優化系統效能,往往更換指令的執行順序。

活躍性問題

多執行緒還會帶來活躍性問題,如何定義活躍性問題呢?活躍性問題關注的是 某件事情是否會發生

如果一組執行緒中的每個執行緒都在等待一個事件,而這個事件只能由該組中的另一個執行緒觸發,這種情況會導致死鎖

簡單一點來表述一下,就是每個執行緒都在等待其他執行緒釋放資源,而其他資源也在等待每個執行緒釋放資源,這樣沒有執行緒搶先釋放自己的資源,這種情況會產生死鎖,所有執行緒都會無限的等待下去。

換句話說,死鎖執行緒集合中的每個執行緒都在等待另一個死鎖執行緒佔有的資源。但是由於所有執行緒都不能執行,它們之中任何一個資源都無法釋放資源,所以沒有一個執行緒可以被喚醒。

如果說死鎖很痴情的話,那麼活鎖用一則成語來表示就是 弄巧成拙

某些情況下,當執行緒意識到它不能獲取所需要的下一個鎖時,就會嘗試禮貌的釋放已經獲得的鎖,然後等待非常短的時間再次嘗試獲取。可以想像一下這個場景:當兩個人在狹路相逢的時候,都想給對方讓路,相同的步調會導致雙方都無法前進。

現在假想有一對並行的執行緒用到了兩個資源。它們分別嘗試獲取另一個鎖失敗後,兩個執行緒都會釋放自己持有的鎖,再次進行嘗試,這個過程會一直進行重複。很明顯,這個過程中沒有執行緒阻塞,但是執行緒仍然不會向下執行,這種狀況我們稱之為 活鎖(livelock)

如果我們期望的事情一直不會發生,就會產生活躍性問題,比如單執行緒中的無限迴圈

while(true){...}

for(;;){}

在多執行緒中,比如 aThread 和 bThread 都需要某種資源,aThread 一直佔用資源不釋放,bThread 一直得不到執行,就會造成活躍性問題,bThread 執行緒會產生飢餓,我們後面會說。

效能問題

與活躍性問題密切相關的是 效能 問題,如果說活躍性問題關注的是最終的結果,那麼效能問題關注的就是造成結果的過程,效能問題有很多方面:比如服務時間過長,吞吐率過低,資源消耗過高,在多執行緒中這樣的問題同樣存在。

在多執行緒中,有一個非常重要的效能因素那就是我們上面提到的 執行緒切換,也稱為 上下文切換(Context Switch),這種操作開銷很大。

在計算機世界中,老外都喜歡用 context 上下文這個詞,這個詞涵蓋的內容很多,包括上下文切換的資源,暫存器的狀態、程式計數器等。context switch 一般指的就是這些上下文切換的資源、暫存器狀態、程式計數器的變化等。

在上下文切換中,會儲存和恢復上下文,丟失區域性性,把大量的時間消耗線上程切換上而不是執行緒執行上。

為什麼執行緒切換會開銷如此之大呢?執行緒間的切換會涉及到以下幾個步驟

將 CPU 從一個執行緒切換到另一執行緒涉及掛起當前執行緒,儲存其狀態,例如暫存器,然後恢復到要切換的執行緒的狀態,載入新的程式計數器,此時執行緒切換實際上就已經完成了;此時,CPU 不在執行執行緒切換程式碼,進而執行新的和執行緒關聯的程式碼。

引起執行緒切換的幾種方式

執行緒間的切換一般是作業系統層面需要考慮的問題,那麼引起執行緒上下文切換有哪幾種方式呢?或者說執行緒切換有哪幾種誘因呢?主要有下面幾種引起上下文切換的方式

  • 當前正在執行的任務完成,系統的 CPU 正常排程下一個需要執行的執行緒
  • 當前正在執行的任務遇到 I/O 等阻塞操作,執行緒排程器掛起此任務,繼續排程下一個任務。
  • 多個任務併發搶佔鎖資源,當前任務沒有獲得鎖資源,被執行緒排程器掛起,繼續排程下一個任務。
  • 使用者的程式碼掛起當前任務,比如執行緒執行 sleep 方法,讓出CPU。
  • 使用硬體中斷的方式引起上下文切換

執行緒安全性

在 Java 中,要實現執行緒安全性,必須要正確的使用執行緒和鎖,但是這些只是滿足執行緒安全的一種方式,要編寫正確無誤的執行緒安全的程式碼,其核心就是對狀態訪問操作進行管理。最重要的就是最 共享(Shared)的 和 可變(Mutable)的狀態。只有共享和可變的變數才會出現問題,私有變數不會出現問題,參考程式計數器

物件的狀態可以理解為儲存在例項變數或者靜態變數中的資料,共享意味著某個變數可以被多個執行緒同時訪問、可變意味著變數在生命週期內會發生變化。一個變數是否是執行緒安全的,取決於它是否被多個執行緒訪問。要使變數能夠被安全訪問,必須通過同步機制來對變數進行修飾。

如果不採用同步機制的話,那麼就要避免多執行緒對共享變數的訪問,主要有下面兩種方式

  • 不要在多執行緒之間共享變數
  • 將共享變數置為不可變的

我們說了這麼多次執行緒安全性,那麼什麼是執行緒安全性呢?

什麼是執行緒安全性

根據上面的探討,我們可以得出一個簡單的定義:當多個執行緒訪問某個類時,這個類始終都能表現出正確的行為,那麼就稱這個類是執行緒安全的

單執行緒就是一個執行緒數量為 1 的多執行緒,單執行緒一定是執行緒安全的。讀取某個變數的值不會產生安全性問題,因為不管讀取多少次,這個變數的值都不會被修改。

原子性

我們上面提到了原子性的概念,你可以把原子性操作想象成為一個不可分割 的整體,它的結果只有兩種,要麼全部執行,要麼全部回滾。你可以把原子性認為是 婚姻關係 的一種,男人和女人只會產生兩種結果,好好的說散就散,一般男人的一生都可以把他看成是原子性的一種,當然我們不排除時間管理(執行緒切換)的個例,我們知道執行緒切換必然會伴隨著安全性問題,男人要出去浪也會造成兩種結果,這兩種結果分別對應安全性的兩個結果:執行緒安全(好好的)和執行緒不安全(說散就散)。

競態條件

有了上面的執行緒切換的功底,那麼競態條件也就好定義了,它指的就是兩個或多個執行緒同時對一共享資料進行修改,從而影響程式執行的正確性時,這種就被稱為競態條件(race condition) ,執行緒切換是導致競態條件出現的誘導因素,我們通過一個示例來說明,來看一段程式碼

public class RaceCondition {
  
  private Signleton single = null;
  public Signleton newSingleton(){
    if(single == null){
      single = new Signleton();
    }
    return single;
  }
  
}

在上面的程式碼中,涉及到一個競態條件,那就是判斷 single 的時候,如果 single 判斷為空,此時發生了執行緒切換,另外一個執行緒執行,判斷 single 的時候,也是空,執行 new 操作,然後執行緒切換回之前的執行緒,再執行 new 操作,那麼記憶體中就會有兩個 Singleton 物件。

加鎖機制

在 Java 中,有很多種方式來對共享和可變的資源進行加鎖和保護。Java 提供一種內建的機制對資源進行保護:synchronized 關鍵字,它有三種保護機制

  • 對方法進行加鎖,確保多個執行緒中只有一個執行緒執行方法;
  • 對某個物件例項(在我們上面的探討中,變數可以使用物件來替換)進行加鎖,確保多個執行緒中只有一個執行緒對物件例項進行訪問;
  • 對類物件進行加鎖,確保多個執行緒只有一個執行緒能夠訪問類中的資源。

synchronized 關鍵字對資源進行保護的程式碼塊俗稱 同步程式碼塊(Synchronized Block),例如

synchronized(lock){
  // 執行緒安全的程式碼
}

每個 Java 物件都可以用做一個實現同步的鎖,這些鎖被稱為 內建鎖(Instrinsic Lock)或者 監視器鎖(Monitor Lock)。執行緒在進入同步程式碼之前會自動獲得鎖,並且在退出同步程式碼時自動釋放鎖,而無論是通過正常執行路徑退出還是通過異常路徑退出,獲得內建鎖的唯一途徑就是進入這個由鎖保護的同步程式碼塊或方法。

synchronized 的另一種隱含的語義就是 互斥,互斥意味著獨佔,最多隻有一個執行緒持有鎖,當執行緒 A 嘗試獲得一個由執行緒 B 持有的鎖時,執行緒 A 必須等待或者阻塞,直到執行緒 B 釋放這個鎖,如果執行緒 B 不釋放鎖的話,那麼執行緒 A 將會一直等待下去。

執行緒 A 獲得執行緒 B 持有的鎖時,執行緒 A 必須等待或者阻塞,但是獲取鎖的執行緒 B 可以重入,重入的意思可以用一段程式碼表示

public class Retreent {
  
  public synchronized void doSomething(){
    doSomethingElse();
    System.out.println("doSomething......");
  }
  
  public synchronized void doSomethingElse(){
    System.out.println("doSomethingElse......");
}

獲取 doSomething() 方法鎖的執行緒可以執行 doSomethingElse() 方法,執行完畢後可以重新執行 doSomething() 方法中的內容。鎖重入也支援子類和父類之間的重入,具體的我們後面會進行介紹。

volatile 是一種輕量級的 synchronized,也就是一種輕量級的加鎖方式,volatile 通過保證共享變數的可見性來從側面對物件進行加鎖。可見性的意思就是當一個執行緒修改一個共享變數時,另外一個執行緒能夠 看見 這個修改的值。volatile 的執行成本要比 synchronized 低很多,因為 volatile 不會引起執行緒的上下文切換。

關於 volatile 的具體實現,我們後面會說。

我們還可以使用原子類 來保證執行緒安全,原子類其實就是 rt.jar 下面以 atomic 開頭的類

除此之外,我們還可以使用 java.util.concurrent 工具包下的執行緒安全的集合類來確保執行緒安全,具體的實現類和其原理我們後面會說。

相關文章