你的線程很可能出現了:安全性、活躍性以及性能問題 安全性問題 活躍性問題 性能問題 總結

併發編程中我們需要注意的問題有很多,很慶幸前人已經幫我們總結過了,主要有三個方面,分別是:安全性問題、活躍性問題和性能問題。下面我就來一一介紹這些問題。

安全性問題

相信你一定聽說過類似這樣的描述:這個方法不是線程安全的,這個類不是線程安全的,等等。

那什麼是線程安全呢?其實本質上就是正確性,而正確性的含義就是程序按照我們期望的執行,不要讓我們感到意外。在上一篇《深入底層探究併發編程Bug罪魁禍首——可見性、原子性、有序性 》中,我們已經見識過很多詭異的 Bug,都是出乎我們預料的,它們都沒有按照我們期望的執行。

那如何才能寫出線程安全的程序呢?在上一篇中已經介紹了併發 Bug 的三個主要源頭:原子性問題、可見性問題和有序性問題。也就是說,理論上線程安全的程序,就要避免出現原子性問題、可見性問題和有序性問題。

那是不是所有的代碼都需要認真分析一遍是否存在這三個問題呢?當然不是,其實只有一種情況需要:存在共享數據並且該數據會發生變化,通俗地講就是有多個線程會同時讀寫同一數據。那如果能夠做到不共享數據或者數據狀態不發生變化,不就能夠保證線程的安全性了嘛。有不少技術方案都是基於這個理論的,例如線程本地存儲(Thread Local Storage,TLS)、不變模式等等,後面我會詳細介紹相關的技術方案是如何在 Java 語言中實現的。

但是,現實生活中,必須共享會發生變化的數據,這樣的應用場景還是很多的。

當多個線程同時訪問同一數據,並且至少有一個線程會寫這個數據的時候,如果我們不採取防護措施,那麼就會導致併發 Bug,對此還有一個專業的術語,叫做數據競爭(DataRace)。比如,前面這篇文章裏有個 add10K() 的方法,當多個線程調用時候就會發生數據競爭,如下所示。

public class Test {
    private long count = 0;
    void add10K()
    {
        int idx = 0;
        while ( idx++ < 10000 )
        {
            count += 1;
        }
    }
}

那是不是在訪問數據的地方,我們加個鎖保護一下就能解決所有的併發問題了呢?顯然沒有這麼簡單。例如,對於上面示例,我們稍作修改,增加兩個被 synchronized 修飾的 get()和 set() 方法, add10K() 方法裏面通過 get() 和 set() 方法來訪問 value 變量,修改後的代碼如下所示。對於修改後的代碼,所有訪問共享變量 value 的地方,我們都增加了互斥鎖,此時是不存在數據競爭的。但很顯然修改後的 add10K() 方法並不是線程安全的。

public class Test {
    private long count = 0;
    synchronized long get()
    {
        return count ; 5
    }

    synchronized void set( long v )
    {
        count = v;
    }

    void add10K()
    {
        int idx = 0;
        while ( idx++ < 10000 )
        {
            set( get() + 1 )
        }
    }
}

假設 count=0,當兩個線程同時執行 get() 方法時,get() 方法會返回相同的值 0,兩個線程執行 get()+1 操作,結果都是 1,之後兩個線程再將結果 1 寫入了內存。你本來期望的是 2,而結果卻是 1。

這種問題,有個官方的稱呼,叫競態條件(Race Condition)。所謂競態條件,指的是程序的執行結果依賴線程執行的順序。例如上面的例子,如果兩個線程完全同時執行,那麼結果是 1;如果兩個線程是前後執行,那麼結果就是 2。在併發環境裏,線程的執行順序是不確定的,如果程序存在競態條件問題,那就意味着程序執行的結果是不確定的,而執行結果不確定這可是個大 Bug。

下面再結合一個例子來說明下競態條件,就是前面文章中提到的轉賬操作。轉賬操作裏面有個判斷條件——轉出金額不能大於賬戶餘額,但在併發環境裏面,如果不加控制,當多個線程同時對一個賬號執行轉出操作時,就有可能出現超額轉出問題。假設賬戶 A 有餘額200,線程 1 和線程 2 都要從賬戶 A 轉出 150,在下面的代碼裏,有可能線程 1 和線程 2同時執行到第 6 行,這樣線程 1 和線程 2 都會發現轉出金額 150 小於賬戶餘額 200,於是就會發生超額轉出的情況。

class Account {
    private int balance;
    /* 轉賬 */
    void transfer(
        Account target, int amt )
    {
        if ( this.balance > amt )
        {
            this.balance    -= amt;
            target.balance  += amt;
        }
    }
}

所以你也可以按照下面這樣來理解競態條件。在併發場景中,程序的執行依賴於某個狀態變量,也就是類似於下面這樣:

if (狀態變量 滿足 執行條件) {
  執行操作
 }

當某個線程發現狀態變量滿足執行條件後,開始執行操作;可是就在這個線程執行操作的時候,其他線程同時修改了狀態變量,導致狀態變量不滿足執行條件了。當然很多場景下,這個條件不是顯式的,例如前面 addOne 的例子中,set(get()+1) 這個複合操作,其實就隱式依賴 get() 的結果。

那面對數據競爭和競態條件問題,又該如何保證線程的安全性呢?其實這兩類問題,都可以用互斥這個技術方案,而實現互斥的方案有很多,CPU 提供了相關的互斥指令,操作系統、編程語言也會提供相關的 API。從邏輯上來看,我們可以統一歸爲:。前面幾章我們也粗略地介紹瞭如何使用鎖,相信你已經胸中有丘壑了,這裏就不再贅述了,你可以結合前面的文章溫故知新。

活躍性問題

所謂活躍性問題,指的是某個操作無法執行下去。我們常見的“死鎖”就是一種典型的活躍性問題,當然除了死鎖外,還有兩種情況,分別是“活鎖”和“飢餓”。通過前面的學習你已經知道,發生“死鎖”後線程會互相等待,而且會一直等待下去,在技術上的表現形式是線程永久地“阻塞”了。

有時線程雖然沒有發生阻塞,但仍然會存在執行不下去的情況,這就是所謂的“活鎖”。可以類比現實世界裏的例子,路人甲從左手邊出門,路人乙從右手邊進門,兩人爲了不相撞,互相謙讓,路人甲讓路走右手邊,路人乙也讓路走左手邊,結果是兩人又相撞了。這種情況,基本上謙讓幾次就解決了,因爲人會交流啊。可是如果這種情況發生在編程世界了,就有可能會一直沒完沒了地“謙讓”下去,成爲沒有發生阻塞但依然執行不下去的“活鎖”。

解決“活鎖”的方案很簡單,謙讓時,嘗試等待一個隨機的時間就可以了。例如上面的那個例子,路人甲走左手邊發現前面有人,並不是立刻換到右手邊,而是等待一個隨機的時間後,再換到右手邊;同樣,路人乙也不是立刻切換路線,也是等待一個隨機的時間再切換。由於路人甲和路人乙等待的時間是隨機的,所以同時相撞後再次相撞的概率就很低了。“等待一個隨機時間”的方案雖然很簡單,卻非常有效,Raft 這樣知名的分佈式一致性算法中也用到了它。

那“飢餓”該怎麼去理解呢?所謂“飢餓”指的是線程因無法訪問所需資源而無法執行下去的情況。“不患寡,而患不均”,如果線程優先級“不均”,在 CPU 繁忙的情況下,優先級低的線程得到執行的機會很小,就可能發生線程“飢餓”;持有鎖的線程,如果執行的時間過長,也可能導致“飢餓”問題。

解決“飢餓”問題的方案很簡單,有三種方案:一是保證資源充足,二是公平地分配資源,三就是避免持有鎖的線程長時間執行。這三個方案中,方案一和方案三的適用場景比較有限,因爲很多場景下,資源的稀缺性是沒辦法解決的,持有鎖的線程執行的時間也很難縮短。倒是方案二的適用場景相對來說更多一些。

那如何公平地分配資源呢?在併發編程裏,主要是使用公平鎖。所謂公平鎖,是一種先來後到的方案,線程的等待是有順序的,排在等待隊列前面的線程會優先獲得資源。

性能問題

使用“鎖”要非常小心,但是如果小心過度,也可能出“性能問題”。“鎖”的過度使用可能導致串行化的範圍過大,這樣就不能夠發揮多線程的優勢了,而我們之所以使用多線程搞併發程序,爲的就是提升性能。

所以我們要儘量減少串行,那串行對性能的影響是怎麼樣的呢?假設串行百分比是 5%,我們用多核多線程相比單核單線程能提速多少呢?

有個阿姆達爾(Amdahl)定律,代表了處理器並行運算之後效率提升的能力,它正好可以解決這個問題,具體公式如下:

公式裏的 n 可以理解爲 CPU 的核數,p 可以理解爲並行百分比,那(1-p)就是串行百分比了,也就是我們假設的 5%。我們再假設 CPU 的核數(也就是 n)無窮大,那加速比 S的極限就是 20。也就是說,如果我們的串行率是 5%,那麼我們無論採用什麼技術,最高也就只能提高 20 倍的性能。

所以使用鎖的時候一定要關注對性能的影響。 那怎麼才能避免鎖帶來的性能問題呢?這個問題很複雜,Java SDK 併發包裏之所以有那麼多東西,有很大一部分原因就是要提升在某個特定領域的性能

不過從方案層面,我們可以這樣來解決這個問題。

第一,既然使用鎖會帶來性能問題,那最好的方案自然就是使用無鎖的算法和數據結構了。在這方面有很多相關的技術,例如線程本地存儲 (Thread Local Storage, TLS)、寫入時複製 (Copy-on-write)、樂觀鎖等;Java 併發包裏面的原子類也是一種無鎖的數據結構;Disruptor 則是一個無鎖的內存隊列,性能都非常好……

第二,減少鎖持有的時間。互斥鎖本質上是將並行的程序串行化,所以要增加並行度,一定要減少持有鎖的時間。這個方案具體的實現技術也有很多,例如使用細粒度的鎖,一個典型的例子就是 Java 併發包裏的 ConcurrentHashMap,它使用了所謂分段鎖的技術(這個技術後面我們會詳細介紹);還可以使用讀寫鎖,也就是讀是無鎖的,只有寫的時候纔會互斥。

性能方面的度量指標有很多,我覺得有三個指標非常重要,就是:吞吐量、延遲和併發量。

  1. 吞吐量:指的是單位時間內能處理的請求數量。吞吐量越高,說明性能越好。
  2. 延遲:指的是從發出請求到收到響應的時間。延遲越小,說明性能越好。
  3. 併發量:指的是能同時處理的請求數量,一般來說隨着併發量的增加、延遲也會增加。所以延遲這個指標,一般都會是基於併發量來說的。例如併發量是 1000 的時候,延遲是 50 毫秒。

總結

併發編程是一個複雜的技術領域,微觀上涉及到原子性問題、可見性問題和有序性問題,宏觀則表現爲安全性、活躍性以及性能問題。

我們在設計併發程序的時候,主要是從宏觀出發,也就是要重點關注它的安全性、活躍性以及性能。安全性方面要注意數據競爭和競態條件,活躍性方面需要注意死鎖、活鎖、飢餓等問題,性能方面我們雖然介紹了兩個方案,但是遇到具體問題,你還是要具體分析,根據特定的場景選擇合適的數據結構和算法。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章