10 張圖告訴你多線程那些破事

頭髮很多的程序員:『師父,這個批量處理接口太慢了,有什麼辦法可以優化?』

架構師:『試試使用多線程優化』

第二天

頭髮很多的程序員:『師父,我已經使用了多線程,爲什麼接口還變慢了?』

架構師:『去給我買杯咖啡,我寫篇文章告訴你』

…… 吭哧吭哧買咖啡去了

在實際工作中,錯誤使用多線程非但不能提高效率還可能使程序崩潰。以在路上開車爲例:

在一個單向行駛的道路上,每輛汽車都遵守交通規則,這時候整體通行是正常的。『單向車道』意味着『一個線程』,『多輛車』意味着『多個 job 任務』。

單線程順利同行

如果需要提升車輛的同行效率,一般的做法就是擴展車道,對應程序來說就是『加線程池』,增加線程數。這樣在同一時間內,通行的車輛數遠遠大於單車道。

多線程順利同行

然而成年人的世界沒有那麼完美,車道一旦多起來『加塞』的場景就會越來越多,出現碰撞後也會影響整條馬路的通行效率。這麼一對比下來『多車道』確實可能比『單車道』要慢。

多線程故障

防止汽車頻繁變道加塞可以採取在車道間增加『護欄』,那在程序的世界該怎麼做呢?

程序世界中多線程遇到的問題歸納起來就是三類:『線程安全問題』『活躍性問題』『性能問題』,接下來會講解這些問題,以及問題對應的解決手段。

線程安全問題

有時候我們會發現,明明在單線程環境中正常運行的代碼,在多線程環境中可能會出現意料之外的結果,其實這就是大家常說的『線程不安全』。那到底什麼是線程不安全呢?往下看。

原子性

舉一個銀行轉賬的例子,比如從賬戶 A 向賬戶 B 轉 1000 元,那麼必然包括 2 個操作:從賬戶 A 減去 1000 元,往賬戶 B 加上 1000 元,兩個操作都成功才意味着一次轉賬最終成功。

試想一下,如果這兩個操作不具備原子性,從 A 的賬戶扣減了 1000 元之後,操作突然終止了,賬戶 B 沒有增加 1000 元,那問題就大了。

銀行轉賬這個例子有兩個步驟,出現了意外後導致轉賬失敗,說明沒有原子性。

原子性:即一個操作或者多個操作 要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。

原子操作:即不會被線程調度機制打斷的操作,沒有上下文切換。

在併發編程中很多操作都不是原子操作,出個小題目:

i = 0; // 操作1
i++;   // 操作2
i = j; // 操作3
i = i + 1; // 操作4

上面這四個操作中有哪些是原子操作,哪些不是的?不熟悉的人可能認爲這些都是原子操作,其實只有操作 1 是原子操作。

在單線程環境下上述四個操作都不會出現問題,但是在多線程環境下,如果不通過加鎖操作,往往可能得到意料之外的值。

在 Java 語言中通過可以使用 synchronize 或者 lock 來保證原子性。

可見性

talk is cheap,先 show 一段代碼:

class Test {
  int i = 50;
  int j = 0;
  
  public void update() {
    // 線程1執行
    i = 100;
  }
  
  public int get() {
    // 線程2執行
    j = i;
    return j;
  }
}

線程 1 執行 update 方法將 i 賦值爲 100,一般情況下線程 1 會在自己的工作內存中完成賦值操作,卻沒有及時將新值刷新到主內存中。

這個時候線程 2 執行 get 方法,首先會從主內存中讀取 i 的值,然後加載到自己的工作內存中,這個時候讀取到 i 的值是 50,再將 50 賦值給 j,最後返回 j 的值就是 50 了。原本期望返回 100,結果返回 50,這就是可見性問題,線程 1 對變量 i 進行了修改,線程 2 沒有立即看到 i 的新值。

可見性:指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。

如上圖每個線程都有屬於自己的工作內存,工作內存和主內存間需要通過 store 和 load 等進行交互。

爲了解決多線程可見性問題,Java 語言提供了volatile這個關鍵字。當一個共享變量被 volatile 修飾時,它會保證修改的值會立即被更新到主存,當有其他線程需要讀取時,它會去內存中讀取新值。而普通共享變量不能保證可見性,因爲變量被修改後什麼時候刷回到主存是不確定的,另外一個線程讀的可能就是舊值。

當然 Java 的鎖機制如 synchronize 和 lock 也是可以保證可見性的,加鎖可以保證在同一時刻只有一個線程在執行同步代碼塊,釋放鎖之前會將變量刷回至主存,這樣也就保證了可見性。

關於線程不安全的表現還有『有序性』,這個問題會在後面的文章中深入講解。

活躍性問題

上面講到爲了解決可見性問題,我們可以採取加鎖方式解決,但是如果加鎖使用不當也容易引入其他問題,比如『死鎖』。

在說『死鎖』前我們先引入另外一個概念:活躍性問題

活躍性是指某件正確的事情最終會發生,當某個操作無法繼續下去的時候,就會發生活躍性問題。

概念是不是有點拗口,如果看不懂也沒關係,你可以記住活躍性問題一般有這樣幾類:死鎖活鎖飢餓問題

(1)死鎖

死鎖是指多個線程因爲環形的等待鎖的關係而永遠的阻塞下去。一圖勝千語,不多解釋。

(2)活鎖

死鎖是兩個線程都在等待對方釋放鎖導致阻塞。而活鎖的意思是線程沒有阻塞,還活着呢。

當多個線程都在運行並且修改各自的狀態,而其他線程彼此依賴這個狀態,導致任何一個線程都無法繼續執行,只能重複着自身的動作和修改自身的狀態,這種場景就是發生了活鎖。

![](/Users/ray/Library/Application Support/typora-user-images/image-20210408232019843.png)

如果大家還有疑惑,那我再舉一個生活中的例子,大家平時在走路的時候,迎面走來一個人,兩個人互相讓路,但是又同時走到了一個方向,如果一直這樣重複着避讓,這倆人就是發生了活鎖,學到了吧,嘿嘿。

(3)飢餓

如果一個線程無其他異常卻遲遲不能繼續運行,那基本是處於飢餓狀態了。

常見有幾種場景:

有一個非常經典的飢餓問題就是哲學家用餐問題,如下圖所示,有五個哲學家在用餐,每個人必須要同時拿兩把叉子纔可以開始就餐,如果哲學家 1 和哲學家 3 同時開始就餐,那哲學家 2、4、5 就得餓肚子等待了。

性能問題

前面講到了線程安全和死鎖、活鎖這些問題會影響多線程執行過程,如果這些都沒有發生,多線程併發一定比單線程串行執行快嗎,答案是不一定,因爲多線程有創建線程線程上下文切換的開銷。

創建線程是直接向系統申請資源的,對操作系統來說創建一個線程的代價是十分昂貴的,需要給它分配內存、列入調度等。

線程創建完之後,還會遇到線程上下文切換

CPU 是很寶貴的資源速度也非常快,爲了保證雨露均霑,通常爲給不同的線程分配時間片,當 CPU 從執行一個線程切換到執行另一個線程時,CPU 需要保存當前線程的本地數據,程序指針等狀態,並加載下一個要執行的線程的本地數據,程序指針等,這個開關被稱爲『上下文切換』。

一般減少上下文切換的方法有:無鎖併發編程CAS 算法使用協程等。

有態度的總結

多線程用好了可以讓程序的效率成倍提升,用不好可能比單線程還要慢。

用一張圖總結一下上面講的:

雖然講了多線程併發會遇到的問題,你可能也發現了,文章中並沒有給出具體的解決方案,因爲這些問題在 Java 語言設計過程中大神都已經爲你考慮過了。

Java 併發編程學起來有一定難度,但這也是從初級程序員邁向中高級程序員的必經道路,接下來的文章會帶領大家逐個擊破!

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/3lZa0DUWtTiUaph57a7_ng