操作系統和併發的愛恨糾葛(轉載)

2020-08-08 14:09:40

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

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

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

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

操作系統是一個併發系統,併發性是操作系統非常重要的特徵,操作系統具有同時處理和排程多個程式的能力,比如多個 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);
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
這段程式輸出後會發現,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(;