【併發那些事】可見性問題的萬惡之源

【併發那些事】可見性問題的萬惡之源

image.png

硬件工程師爲均衡 CPU 與 緩存之間的速度差異,特意加的 CPU 緩存,竟然在多核的場景下陰差陽錯的成爲了併發可見性問題的萬惡之源!(本文過長,如果不是特別無聊,看到這裏就可以了)

前言

還記得那些年,你寫的那些多線程 BUG 嗎?明明只想得到個 1 + 1 = 2 的預期,結果他有時候得到 1,有時候得到 3,但偏偏有時候他也會返回正確的 2。明明在本地運行的好好的,一上線一堆詭異的 BUG。你一遍一遍的檢查代碼,一行一行 debug,結果無功而返。


變量爲何突然變異?代碼爲何亂序運行?條件爲何形同虛設?歡迎收看今天的《走進科學》之半夜。。。哦,不對,歡迎閱讀今天的《併發那些事》之可見性問題的萬惡之源。就像上面說的,我們在寫併發程序時,經常會出現超出我們認識與直覺的問題,而按我們的以往的經驗,很難去察覺到他的問題所在。而又因爲我們不瞭解他發生的誘因,即使我們按照書上的方案解決了,但是下次還是會出現。所以本文的主旨並不是解決問題的術,而是解決問題的道。一起來探究多線程問題的根源。


首先揭開謎底,大多數併發問題的發生都是這三個問題導致的,可見性問題、原子性問題、有序性問題。那麼又是什麼導致這三個問題的出現呢?本文將一步步解析可見性問題出現的原因。

核心矛盾

衆所周知,電腦由很多的部件組成。其中最最最重要的有三個,它們分別是 CPU 、內存、IO(硬盤)。一般來說它們三個的性能高低直接影響到了電腦的整體的性能優劣。


但是從它們誕生之初,就有一個核心矛盾,即使過了幾十年後的現在,科技的飛速發展依舊沒能解決。那麼是什麼矛盾呢?


在說矛盾之前,先說我個同事,他是個電競高手,英雄聯盟、王者榮耀什麼的意識特別歷害。每次看比賽的時候那種指點江山、揮斥方遒的英姿閃閃發光。但是呢,一上手打遊戲,一頓操作猛如虎,一看戰績0槓5,剛開始我們以爲他是個青銅,但是呢,很多時候遊戲的真的就像他說的那樣,他的預判,他的操作其實都相當的風騷。一直很疑惑,直到我們得出了一個結論,其實他的確是一個王者,因爲他滿腦子都是騷操作,但是呢?他的雙手跟不上他風騷的大腦。


問題就在這裏,核心矛盾就是速度的差異。CPU 就像是那位同事的大腦,很強很風騷,但是奈何 IO 就像那雙跟不上節奏的手,限制了發揮。而且它們之間的速度差異要遠遠超出我們的想像,CPU 就好比是火箭,那麼內存就是三輪車,IO 可能就是馬路旁一隻不起眼的小蝸牛。

各方的努力

既然有了這個問題,那就要想辦法解決,首先這個問題出在硬件層,所以首當其衝的硬件工作師想了很多方式試圖去解決。經過內存跟 IO 硬件工程師的不懈努力,這兩個組件的速度都得到了大幅提升。但是呢?CPU 的工程師也沒閒着,甚至英特爾的 CEO--高登·摩爾還宣佈了一個以自己姓名定義的摩爾定律。其內容大致如下:

集成電路上可容納的晶體管數目,約每18個月便會增加一倍


可以簡單的理解,CPU 每 18 個月性能就能翻一倍。這就讓內存跟 IO 的硬件工程師很絕望了,不怕別人比你聰明,就怕比你聰明的人還比你努力。這還是怎麼玩?


image.png


當然,獨木不成林,CPU 工程師也意識到了這個問題,我再怎麼獨領風騷,以1V5。沒有用呀?打的正嗨,一回頭,家被推了。我下了一部電影,雙擊打開,CPU 飛速運行,IO 在緩慢加載。我 CPU 運行到冒煙也沒用呀,IO 制約了。結果就是電影變成了 PPT,一秒一停。這樣下去大家都沒得玩。眼看其它隊友帶不動,CPU 工程師想出了一個辦法,我在 CPU 裏面劃一塊出來做爲緩存,這個緩存介於 CPU 與 內存之間,跟我們常用的緩存功能差不多,爲了均衡 CPU 與內存之間的速度差,在執行的時候會把數據先從 IO 加載到 內存,再把內存中的數據加載到 CPU 的緩存之中。將常用或者將用的數據緩存在 CPU 中後,CPU 每次處理時就不用老是等內存了,這極大的提高了CPU 的利用率。


到這裏,硬件工程師圓滿的完成了任務,下面輪到了我們軟件工程師登場了。


雖然說加了緩存之後,CPU 的利用率成倍上升,從當初的運行 5 分鐘,加載 2 小時。變成了,運行 2 分鐘,加載 1 小時,但是體驗還是很差。還拿電影舉例,看電影的時候不光有畫面,還得有聲音呀,你運行是快了,但是先放視頻,再放聲音。就像是先看一部默片,再聽一遍廣播,這種音畫分離的觀感沒比 PPT 強多少。


後來在軟硬工程師的天才努力後,發明了一種神奇的東西--線程。說線程之前我們先說一下進程,這個東西可是我們能看到的東西,比始你啓動的瀏覽器,比如你正在使用的微信,這些軟件啓動後,在操作系統中都是一個進程。而線程呢?它可以簡單理解成是一個進程的子集,也就是說進程其實是一堆線程組成。而且操作系統通常會把所有硬件資源,包括內存之內的全分配給進程,進程就像一個包工頭一樣再分配給底下的線程。但是唯獨有一樣資源,操作系統是直接分配給線程的,那就是 CPU 資源。


這樣的設置其實是有深意的。可能有人覺得,分給進程也可以呀,但是進程要比線程重的多,切換的開銷過大,得不嘗試。就像是你想打開一個新的網頁,是打開一個新瀏覽器快呢?還是打開一個新的 Tab 頁快呢?總之有了線程之後,我們就有了一個很酷炫的操作--線程切換。他能帶來什麼呢?接着說電影的事,我們其實還是先播視頻再放聲音。但是與上面不同的是,我們是先放一會視頻,再放一會聲音。只要單次播放的夠短,兩種操作之間的切換夠快,就會讓人感覺其實視頻與聲音是同時播的錯覺。而輕量的線程以及提供的切換能力給這種操作提供了可能。


至此,問題在無數硬件與軟件工程師的努力下,得到了比較完美的解決。

新的問題

事情到了這裏,本該皆大歡喜、功德圓滿。結果英特爾又出來搞事,但其實他這次也是被逼無奈。


還記得我們上面說的以英特爾 CEO--高登·摩爾命名的摩爾定律嗎?這個定律其實並不是根據嚴謹的科學研究得出來的,而是通過英特爾的過往表現推導出的這個結論。按理說這是極不符合科學規律的,就像我遇到的每個程序員都背個電腦包,但是我在大街上不能隨便看到一個揹着電腦包的人就說他是程序員。但是英特爾就是這麼 NB,他在的大街上全是程序員。英特爾就這樣維護着這個定律每 18 個月把 CPU 的性能翻一倍,持續了每多年。


image.png


直到第四任 CEO 的時候,摩爾定律突然不靈了,上圖就是時任英特爾 CEO--克瑞格·貝瑞特。在一次技術大會上,向與會者下跪。爲一再延期直至最終失敗放棄的 4GHz 主頻奔 4 處理器致歉。


到此,摩爾定律終結,CPU 的發展進入了瓶頸。直到有一天一個腦門閃光的硬件工程師敲響了克瑞格·貝瑞特辦公室的大門。"老闆你不用跪了,我有個辦法可以把 CPU 性能提高一倍"。

一句話讓克瑞格老淚縱橫,那一天,回想起了,受那些傢伙支配的恐怖……被囚禁在鳥籠中的屈辱……

image.png

克瑞格激動的問道:"什麼方案?"

硬件工程師:"很簡單呀,我們只要把現在兩個的 CPU 裝到一個大號的 CPU 裏面,那麼他的性能就是兩個 CPU 的性能呀!我可真是一個小機靈鬼呢"

做了一輩子 CPU 的克瑞格,氣的差點進了 ICU。"我老克就算跪一輩子,也不會做這種傻事"。

image.png

上圖爲英特爾發佈的 28 核 CPU。嗯?


image.png


當然上面其實有些戲謔的成分,但是 CPU 的發展結果也的確是往更多的核心數去發展。從單核到雙核再 6 核、8核不停的增長核心數,CPU 的性能也的確跟着增長。這其實跟我們軟件工程師常用的分佈式架構一樣,當單機的性能達到了瓶頸,不可能再通過縱向的增加服務器的性能提高系統負載,只能通過把單機系統,拆成多個分佈式服務來進行橫向的擴展。


通過增加 CPU 的核心數,硬件工程師看似圓滿的完成時代交給他的任務。結果一口大鍋甩在了咱們軟件工程師的頭上。


來,我們回顧一下,上面我們說 CPU、內存、IO 他們有一個核心矛盾,這個矛盾就是速度的差異。而且這個差異仍然沒有解決。但是我們變相的解決了。解決方案是什麼?硬件工程師在 CPU 的核心裏劃了一塊地方做爲緩存,通過這個緩存均衡他們之間的差異。而軟件工程師呢,爲了最大的提高 CPU 的利用率,搞了一個叫線程的東西,通過多線程之間的切換圓滿解決問題。


嗯,這個方案很完美,沒有問題。但是,前提是運行在單核的 CPU 下。


剛纔我們說了 CPU 的核心,會有一塊地方緩存從內存里加載的數據,這樣就不用每次從內存里加載了,提高了效率。但是呢,單核有一個緩存,多核就會出現多個緩存,再加上我們多線程的運行,會出現什麼情況呢?下面我們以真實代碼爲例子:

public class TestCount {
    private int count = 0;

    public static void main(String[] args) throws InterruptedException {
        TestCount testCount = new TestCount();
        Thread threadOne = new Thread(() -> testCount.add());
        Thread threadTwo = new Thread(() -> testCount.add());
        threadOne.start();
        threadTwo.start();

        threadOne.join();
        threadTwo.join();

        System.out.println(testCount.count);
    }

    public void add() {
        for (int i = 0; i < 100000; i++) {
            count++;
        }
    }
}


代碼很簡單,兩個線程都調用一個 add 方法,而這個 add 方法的操作是循環 10 w 次,每次都把這兩個線程共享的 count 變量加 1 。按照我們的直覺來說,count 開始是 0,每個線程加 10 w,總共兩個線程,所以 10 w * 2 = 20 w。


可是呢?結果並不是我們想的那樣,我運行的結果是:113595。而且每次運行的結果都不一樣,你可以試試。結果基本上都在 10w ~ 20w 之間,而且無限趨向於 10w。


這是什麼鬼?還記得前面說的 CPU 緩存嗎?沒錯,他就是這隻鬼。爲了便於說明問題,我畫了幾張圖。


image.png
上圖是在單核的情況下,首先這個 count 會被加載到內存中。這時他是初始值 0。然後如圖所示,第 1 步他被加載到了 CPU 的緩存中,CPU 處理器把他從緩存中取出來,然後進行 add 操作,加完之後再放入緩存中,緩存再把 count 寫入內存中,最終我們就得到了結果。可見單核情況下,因爲共享緩存與內存,沒有任何問題,我們接着看多核的情況下。


image.png
如上是多核場景下的運算過程,具體步驟如下:

  1. 首先 count 被加載到內存,緊接着線程1被 CPU 1調用,把內存的 count = 0 加載到了緩存中
  2. 然後 CPU 1把緩存中 count = 0 加載到處理器中,一個時間片處理後 13595 
  3. CPU 把 count = 13595 存入到緩存,準備下次接着算
  4. 緩存 把 count  = 13595 刷新加內存,等下個時間片再加載
  5. 線程 2 得到了 CPU2 時間片,從內存中把剛剛線程 1 算了一半的 count  = 13595 加載到了緩存
  6. CPU 2 把 count  = 13595 加載到了處理器,開始運算。與些同時 CPU 1把時間片又分配給了線程1,線程接着剛纔的 count = 13595 運算,很快算完得到 10 w ,並把結果最終刷進了內存,現在內存中的數據爲 count = 10w。
  7. 線程2也很快運行完了 10w 次,現在他得到的結果 13595 + 10w = 113595。然後同樣把結果最終的刷新進了內存,現在內存中的數據爲 count = 113595。

看到問題了嗎?可以理解緩存中的 count 是內存中的 count 的一份拷貝。在緩存中修改時並不會變更內存中的值,而是過一段時間後刷新回內存,而線程1把計算了一半的值,刷新進內存後,線程2把這個新值加載到了 CPU2中,然後計算。與些同時 CPU 1完成了計算,並把值刷新進了內存,CPU2仍在計算,因爲他不知道 CPU1把值改變了,計算完了,把自己計算的值也刷新進了內存中,這樣就把剛剛 CPU1 忙乎半天的結果覆蓋了。


出現這個問題的根本原因就是,CPU 1與 CPU 2各自的操作對於雙方不可見。在這種情況下,運行期間其實總共有 3 個 count 變量,一個是內存中的 count,一個是 CPU1中的 count拷貝,最後一個是 CPU2中的 count 拷貝。

結論

硬件工程師爲均衡 CPU 與 緩存之間的速度差異,而特意加的 CPU 緩存,竟然在多核的場景下陰差陽錯的成爲了併發問題中可見性的根源!

其它

本文是《併發那些事》的第三篇,前兩篇如下:

  1. 【併發那些事】創建線程的三種方式
  2. 【併發那些事】生產者消費者問題






















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