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

程序員cxuan 發佈 2020-08-07T23:41:43+00:00

我一直沒有急於寫並發的原因是我參不透作業系統,如今,我已經把作業系統刷了一遍,這次試著寫一些並發,看看能不能寫清楚,卑微小編在線求鼓勵...

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

並發歷史

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

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

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

作業系統是一個並發系統,並發性是作業系統非常重要的特徵,作業系統具有同時處理和調度多個程序的能力,比如多個 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 值彼此不可見,所以這是由於 可見性 導致的線程安全問題。

原子性問題

看起來很普通的一段程序卻因為兩個線程 aThread 和 bThread 交替執行產生了不同的結果。但是根源不是因為創建了兩個線程導致的,多線程只是產生線程安全性的必要條件,最終的根源出現在 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 工具包下的線程安全的集合類來確保線程安全,具體的實現類和其原理我們後面會說。


大家好,我是 cxuan,我自己手寫了四本 PDF,分別是 Java基礎總結、HTTP 核心總結、計算機基礎知識,作業系統核心總結,我已經整理成為 PDF,可以關注公眾號 Java建設者 回復 PDF 領取優質資料。


關鍵字: