<br>java
> 硬件工程師爲均衡 CPU 與 緩存之間的速度差別,特地加的 CPU 緩存,居然在多核的場景下陰差陽錯的成爲了併發可見性問題的萬惡之源!(本文過長,若是不是特別無聊,看到這裏就能夠了)程序員
還記得那些年,你寫的那些多線程 BUG 嗎?明明只想獲得個 1 + 1 = 2 的預期,結果他有時候獲得 1,有時候獲得 3,但恰恰有時候他也會返回正確的 2。明明在本地運行的好好的,一上線一堆詭異的 BUG。你一遍一遍的檢查代碼,一行一行 debug,結果無功而返。<br> <br>變量爲什麼忽然變異?代碼爲什麼亂序運行?條件爲什麼形同虛設?歡迎收看今天的《走進科學》之半夜。。。哦,不對,歡迎閱讀今天的《併發那些事》之可見性問題的萬惡之源。就像上面說的,咱們在寫併發程序時,常常會出現超出咱們認識與直覺的問題,而按咱們的以往的經驗,很難去察覺到他的問題所在。而又由於咱們不瞭解他發生的誘因,即便咱們按照書上的方案解決了,可是下次仍是會出現。因此本文的主旨並非解決問題的術,而是解決問題的道。一塊兒來探究多線程問題的根源。<br> <br>首先揭開謎底,大多數併發問題的發生都是這三個問題致使的,可見性問題、原子性問題、有序性問題。那麼又是什麼致使這三個問題的出現呢?本文將一步步解析可見性問題出現的緣由。<br>瀏覽器
衆所周知,電腦由不少的部件組成。其中最最最重要的有三個,它們分別是 CPU 、內存、IO(硬盤)。通常來講它們三個的性能高低直接影響到了電腦的總體的性能優劣。<br> <br>可是從它們誕生之初,就有一個核心矛盾,即便過了幾十年後的如今,科技的飛速發展依舊沒能解決。那麼是什麼矛盾呢?<br> <br>在說矛盾以前,先說我個同事,他是個電競高手,英雄聯盟、王者榮耀什麼的意識特別歷害。每次看比賽的時候那種指點江山、揮斥方遒的英姿閃閃發光。可是呢,一上手打遊戲,一頓操做猛如虎,一看戰績0槓5,剛開始咱們覺得他是個青銅,可是呢,不少時候遊戲的真的就像他說的那樣,他的預判,他的操做其實都至關的風騷。一直很疑惑,直到咱們得出了一個結論,其實他的確是一個王者,由於他滿腦子都是騷操做,可是呢?他的雙手跟不上他風騷的大腦。<br> <br>問題就在這裏,核心矛盾就是速度的差別。CPU 就像是那位同事的大腦,很強很風騷,可是奈何 IO 就像那雙跟不上節奏的手,限制了發揮。並且它們之間的速度差別要遠遠超出咱們的想像,CPU 就比如是火箭,那麼內存就是三輪車,IO 可能就是馬路旁一隻不起眼的小蝸牛。緩存
既然有了這個問題,那就要想辦法解決,首先這個問題出在硬件層,因此首當其衝的硬件工做師想了不少方式試圖去解決。通過內存跟 IO 硬件工程師的不懈努力,這兩個組件的速度都獲得了大幅提高。可是呢?CPU 的工程師也沒閒着,甚至英特爾的 CEO--高登·摩爾還宣佈了一個以本身姓名定義的摩爾定律。其內容大體以下:<br>服務器
> 集成電路上可容納的晶體管數目,約每18個月便會增長一倍微信
<br>能夠簡單的理解,CPU 每 18 個月性能就能翻一倍。這就讓內存跟 IO 的硬件工程師很絕望了,不怕別人比你聰明,就怕比你聰明的人還比你努力。這仍是怎麼玩?<br> <br><br> <br>固然,獨木不成林,CPU 工程師也意識到了這個問題,我再怎麼獨領風騷,以1V5。沒有用呀?打的正嗨,一回頭,家被推了。我下了一部電影,雙擊打開,CPU 飛速運行,IO 在緩慢加載。我 CPU 運行到冒煙也沒用呀,IO 制約了。結果就是電影變成了 PPT,一秒一停。這樣下去你們都沒得玩。眼看其它隊友帶不動,CPU 工程師想出了一個辦法,我在 CPU 裏面劃一塊出來作爲緩存,這個緩存介於 CPU 與 內存之間,跟咱們經常使用的緩存功能差很少,爲了均衡 CPU 與內存之間的速度差,在執行的時候會把數據先從 IO 加載到 內存,再把內存中的數據加載到 CPU 的緩存之中。將經常使用或者將用的數據緩存在 CPU 中後,CPU 每次處理時就不用總是等內存了,這極大的提升了CPU 的利用率。<br> <br>到這裏,硬件工程師圓滿的完成了任務,下面輪到了咱們軟件工程師登場了。<br> <br>雖說加了緩存以後,CPU 的利用率成倍上升,從當初的運行 5 分鐘,加載 2 小時。變成了,運行 2 分鐘,加載 1 小時,可是體驗仍是不好。還拿電影舉例,看電影的時候不光有畫面,還得有聲音呀,你運行是快了,可是先放視頻,再放聲音。就像是先看一部默片,再聽一遍廣播,這種音畫分離的觀感沒比 PPT 強多少。<br> <br>後來在軟硬工程師的天才努力後,發明了一種神奇的東西--線程。說線程以前咱們先說一下進程,這個東西但是咱們能看到的東西,比始你啓動的瀏覽器,好比你正在使用的微信,這些軟件啓動後,在操做系統中都是一個進程。而線程呢?它能夠簡單理解成是一個進程的子集,也就是說進程實際上是一堆線程組成。並且操做系統一般會把全部硬件資源,包括內存以內的全分配給進程,進程就像一個包工頭同樣再分配給底下的線程。可是惟獨有同樣資源,操做系統是直接分配給線程的,那就是 CPU 資源。<br> <br>這樣的設置實際上是有深意的。可能有人以爲,分給進程也能夠呀,可是進程要比線程重的多,切換的開銷過大,得不嘗試。就像是你想打開一個新的網頁,是打開一個新瀏覽器快呢?仍是打開一個新的 Tab 頁快呢?總之有了線程以後,咱們就有了一個很酷炫的操做--線程切換。他能帶來什麼呢?接着說電影的事,咱們其實仍是先播視頻再放聲音。可是與上面不一樣的是,咱們是先放一會視頻,再放一會聲音。只要單次播放的夠短,兩種操做之間的切換夠快,就會讓人感受其實視頻與聲音是同時播的錯覺。而輕量的線程以及提供的切換能力給這種操做提供了可能。<br> <br>至此,問題在無數硬件與軟件工程師的努力下,獲得了比較完美的解決。<br>多線程
事情到了這裏,本該皆大歡喜、功德圓滿。結果英特爾又出來搞事,但其實他此次也是被逼無奈。<br> <br>還記得咱們上面說的以英特爾 CEO--高登·摩爾命名的摩爾定律嗎?這個定律其實並非根據嚴謹的科學研究得出來的,而是經過英特爾的過往表現推導出的這個結論。按理說這是極不符合科學規律的,就像我遇到的每一個程序員都背個電腦包,可是我在大街上不能隨便看到一個揹着電腦包的人就說他是程序員。可是英特爾就是這麼 NB,他在的大街上全是程序員。英特爾就這樣維護着這個定律每 18 個月把 CPU 的性能翻一倍,持續了每多年。<br> <br><br> <br>直到第四任 CEO 的時候,摩爾定律忽然不靈了,上圖就是時任英特爾 CEO--克瑞格·貝瑞特。在一次技術大會上,向與會者下跪。爲一再延期直至最終失敗放棄的 4GHz 主頻奔 4 處理器致歉。<br> <br>到此,摩爾定律終結,CPU 的發展進入了瓶頸。直到有一天一個腦門閃光的硬件工程師敲響了克瑞格·貝瑞特辦公室的大門。"老闆你不用跪了,我有個辦法能夠把 CPU 性能提升一倍"。架構
一句話讓克瑞格老淚縱橫,那一天,回想起了,受那些傢伙支配的恐怖……被囚禁在鳥籠中的屈辱……併發
克瑞格激動的問道:"什麼方案?"分佈式
硬件工程師:"很簡單呀,咱們只要把如今兩個的 CPU 裝到一個大號的 CPU 裏面,那麼他的性能就是兩個 CPU 的性能呀!我可真是一個小機靈鬼呢"
作了一生 CPU 的克瑞格,氣的差點進了 ICU。"我老克就算跪一生,也不會作這種傻事"。
上圖爲英特爾發佈的 28 核 CPU。嗯?<br> <br><br> <br>固然上面其實有些戲謔的成分,可是 CPU 的發展結果也的確是往更多的核心數去發展。從單核到雙核再 6 核、8核不停的增加核心數,CPU 的性能也的確跟着增加。這其實跟咱們軟件工程師經常使用的分佈式架構同樣,當單機的性能達到了瓶頸,不可能再經過縱向的增長服務器的性能提升系統負載,只能經過把單機系統,拆成多個分佈式服務來進行橫向的擴展。<br> <br>經過增長 CPU 的核心數,硬件工程師看似圓滿的完成時代交給他的任務。結果一口大鍋甩在了我們軟件工程師的頭上。<br> <br>來,咱們回顧一下,上面咱們說 CPU、內存、IO 他們有一個核心矛盾,這個矛盾就是速度的差別。並且這個差別仍然沒有解決。可是咱們變相的解決了。解決方案是什麼?硬件工程師在 CPU 的核內心劃了一塊地方作爲緩存,經過這個緩存均衡他們之間的差別。而軟件工程師呢,爲了最大的提升 CPU 的利用率,搞了一個叫線程的東西,經過多線程之間的切換圓滿解決問題。<br> <br>嗯,這個方案很完美,沒有問題。可是,前提是運行在單核的 CPU 下。<br> <br>剛纔咱們說了 CPU 的核心,會有一塊地方緩存從內存里加載的數據,這樣就不用每次從內存里加載了,提升了效率。可是呢,單核有一個緩存,多核就會出現多個緩存,再加上咱們多線程的運行,會出現什麼狀況呢?下面咱們以真實代碼爲例子:<br>
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++; } } }
<br>代碼很簡單,兩個線程都調用一個 add 方法,而這個 add 方法的操做是循環 10 w 次,每次都把這兩個線程共享的 count 變量加 1 。按照咱們的直覺來講,count 開始是 0,每一個線程加 10 w,總共兩個線程,因此 10 w * 2 = 20 w。<br> <br>但是呢?結果並非咱們想的那樣,我運行的結果是:113595。並且每次運行的結果都不同,你能夠試試。結果基本上都在 10w ~ 20w 之間,並且無限趨向於 10w。<br> <br>這是什麼鬼?還記得前面說的 CPU 緩存嗎?沒錯,他就是這隻鬼。爲了便於說明問題,我畫了幾張圖。<br> <br><br>上圖是在單核的狀況下,首先這個 count 會被加載到內存中。這時他是初始值 0。而後如圖所示,第 1 步他被加載到了 CPU 的緩存中,CPU 處理器把他從緩存中取出來,而後進行 add 操做,加完以後再放入緩存中,緩存再把 count 寫入內存中,最終咱們就獲得告終果。可見單核狀況下,由於共享緩存與內存,沒有任何問題,咱們接着看多核的狀況下。<br> <br>
<br>如上是多核場景下的運算過程,具體步驟以下:<br>
看到問題了嗎?能夠理解緩存中的 count 是內存中的 count 的一份拷貝。在緩存中修改時並不會變動內存中的值,而是過一段時間後刷新回內存,而線程1把計算了一半的值,刷新進內存後,線程2把這個新值加載到了 CPU2中,而後計算。與些同時 CPU 1完成了計算,並把值刷新進了內存,CPU2仍在計算,由於他不知道 CPU1把值改變了,計算完了,把本身計算的值也刷新進了內存中,這樣就把剛剛 CPU1 忙乎半天的結果覆蓋了。<br> <br>出現這個問題的根本緣由就是,CPU 1與 CPU 2各自的操做對於雙方不可見。在這種狀況下,運行期間其實總共有 3 個 count 變量,一個是內存中的 count,一個是 CPU1中的 count拷貝,最後一個是 CPU2中的 count 拷貝。<br>
硬件工程師爲均衡 CPU 與 緩存之間的速度差別,而特地加的 CPU 緩存,居然在多核的場景下陰差陽錯的成爲了併發問題中可見性的根源!<br>
本文是《併發那些事》的第三篇,前兩篇以下:
<br> <br> <br> <br> <br> <br> <br> <br> <br> <br> <br>