【并發(fā)那些事】可見性問題的萬惡之源

【并發(fā)那些事】可見性問題的萬惡之源

image.png

<br />

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

前言

還記得那些年,你寫的那些多線程 BUG 嗎?明明只想得到個(gè) 1 + 1 = 2 的預(yù)期,結(jié)果他有時(shí)候得到 1,有時(shí)候得到 3,但偏偏有時(shí)候他也會(huì)返回正確的 2。明明在本地運(yùn)行的好好的,一上線一堆詭異的 BUG。你一遍一遍的檢查代碼,一行一行 debug,結(jié)果無功而返。<br />
<br />變量為何突然變異?代碼為何亂序運(yùn)行?條件為何形同虛設(shè)?歡迎收看今天的《走進(jìn)科學(xué)》之半夜。。。哦,不對(duì),歡迎閱讀今天的《并發(fā)那些事》之可見性問題的萬惡之源。就像上面說的,我們?cè)趯懖l(fā)程序時(shí),經(jīng)常會(huì)出現(xiàn)超出我們認(rèn)識(shí)與直覺的問題,而按我們的以往的經(jīng)驗(yàn),很難去察覺到他的問題所在。而又因?yàn)槲覀儾涣私馑l(fā)生的誘因,即使我們按照書上的方案解決了,但是下次還是會(huì)出現(xiàn)。所以本文的主旨并不是解決問題的術(shù),而是解決問題的道。一起來探究多線程問題的根源。<br />
<br />首先揭開謎底,大多數(shù)并發(fā)問題的發(fā)生都是這三個(gè)問題導(dǎo)致的,可見性問題、原子性問題、有序性問題。那么又是什么導(dǎo)致這三個(gè)問題的出現(xiàn)呢?本文將一步步解析可見性問題出現(xiàn)的原因。<br />

核心矛盾

眾所周知,電腦由很多的部件組成。其中最最最重要的有三個(gè),它們分別是 CPU 、內(nèi)存、IO(硬盤)。一般來說它們?nèi)齻€(gè)的性能高低直接影響到了電腦的整體的性能優(yōu)劣。<br />
<br />但是從它們誕生之初,就有一個(gè)核心矛盾,即使過了幾十年后的現(xiàn)在,科技的飛速發(fā)展依舊沒能解決。那么是什么矛盾呢?<br />
<br />在說矛盾之前,先說我個(gè)同事,他是個(gè)電競(jìng)高手,英雄聯(lián)盟、王者榮耀什么的意識(shí)特別歷害。每次看比賽的時(shí)候那種指點(diǎn)江山、揮斥方遒的英姿閃閃發(fā)光。但是呢,一上手打游戲,一頓操作猛如虎,一看戰(zhàn)績(jī)0杠5,剛開始我們以為他是個(gè)青銅,但是呢,很多時(shí)候游戲的真的就像他說的那樣,他的預(yù)判,他的操作其實(shí)都相當(dāng)?shù)娘L(fēng)騷。一直很疑惑,直到我們得出了一個(gè)結(jié)論,其實(shí)他的確是一個(gè)王者,因?yàn)樗麧M腦子都是騷操作,但是呢?他的雙手跟不上他風(fēng)騷的大腦。<br />
<br />問題就在這里,核心矛盾就是速度的差異。CPU 就像是那位同事的大腦,很強(qiáng)很風(fēng)騷,但是奈何 IO 就像那雙跟不上節(jié)奏的手,限制了發(fā)揮。而且它們之間的速度差異要遠(yuǎn)遠(yuǎn)超出我們的想像,CPU 就好比是火箭,那么內(nèi)存就是三輪車,IO 可能就是馬路旁一只不起眼的小蝸牛。

各方的努力

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

集成電路上可容納的晶體管數(shù)目,約每18個(gè)月便會(huì)增加一倍

<br />可以簡(jiǎn)單的理解,CPU 每 18 個(gè)月性能就能翻一倍。這就讓內(nèi)存跟 IO 的硬件工程師很絕望了,不怕別人比你聰明,就怕比你聰明的人還比你努力。這還是怎么玩?<br />

<br />
image.png
<br />

<br />當(dāng)然,獨(dú)木不成林,CPU 工程師也意識(shí)到了這個(gè)問題,我再怎么獨(dú)領(lǐng)風(fēng)騷,以1V5。沒有用呀?打的正嗨,一回頭,家被推了。我下了一部電影,雙擊打開,CPU 飛速運(yùn)行,IO 在緩慢加載。我 CPU 運(yùn)行到冒煙也沒用呀,IO 制約了。結(jié)果就是電影變成了 PPT,一秒一停。這樣下去大家都沒得玩。眼看其它隊(duì)友帶不動(dòng),CPU 工程師想出了一個(gè)辦法,我在 CPU 里面劃一塊出來做為緩存,這個(gè)緩存介于 CPU 與 內(nèi)存之間,跟我們常用的緩存功能差不多,為了均衡 CPU 與內(nèi)存之間的速度差,在執(zhí)行的時(shí)候會(huì)把數(shù)據(jù)先從 IO 加載到 內(nèi)存,再把內(nèi)存中的數(shù)據(jù)加載到 CPU 的緩存之中。將常用或者將用的數(shù)據(jù)緩存在 CPU 中后,CPU 每次處理時(shí)就不用老是等內(nèi)存了,這極大的提高了CPU 的利用率。<br />
<br />到這里,硬件工程師圓滿的完成了任務(wù),下面輪到了我們軟件工程師登場(chǎng)了。<br />
<br />雖然說加了緩存之后,CPU 的利用率成倍上升,從當(dāng)初的運(yùn)行 5 分鐘,加載 2 小時(shí)。變成了,運(yùn)行 2 分鐘,加載 1 小時(shí),但是體驗(yàn)還是很差。還拿電影舉例,看電影的時(shí)候不光有畫面,還得有聲音呀,你運(yùn)行是快了,但是先放視頻,再放聲音。就像是先看一部默片,再聽一遍廣播,這種音畫分離的觀感沒比 PPT 強(qiáng)多少。<br />
<br />后來在軟硬工程師的天才努力后,發(fā)明了一種神奇的東西--線程。說線程之前我們先說一下進(jìn)程,這個(gè)東西可是我們能看到的東西,比始你啟動(dòng)的瀏覽器,比如你正在使用的微信,這些軟件啟動(dòng)后,在操作系統(tǒng)中都是一個(gè)進(jìn)程。而線程呢?它可以簡(jiǎn)單理解成是一個(gè)進(jìn)程的子集,也就是說進(jìn)程其實(shí)是一堆線程組成。而且操作系統(tǒng)通常會(huì)把所有硬件資源,包括內(nèi)存之內(nèi)的全分配給進(jìn)程,進(jìn)程就像一個(gè)包工頭一樣再分配給底下的線程。但是唯獨(dú)有一樣資源,操作系統(tǒng)是直接分配給線程的,那就是 CPU 資源。<br />
<br />這樣的設(shè)置其實(shí)是有深意的??赡苡腥擞X得,分給進(jìn)程也可以呀,但是進(jìn)程要比線程重的多,切換的開銷過大,得不嘗試。就像是你想打開一個(gè)新的網(wǎng)頁,是打開一個(gè)新瀏覽器快呢?還是打開一個(gè)新的 Tab 頁快呢?總之有了線程之后,我們就有了一個(gè)很酷炫的操作--線程切換。他能帶來什么呢?接著說電影的事,我們其實(shí)還是先播視頻再放聲音。但是與上面不同的是,我們是先放一會(huì)視頻,再放一會(huì)聲音。只要單次播放的夠短,兩種操作之間的切換夠快,就會(huì)讓人感覺其實(shí)視頻與聲音是同時(shí)播的錯(cuò)覺。而輕量的線程以及提供的切換能力給這種操作提供了可能。<br />
<br />至此,問題在無數(shù)硬件與軟件工程師的努力下,得到了比較完美的解決。<br />

新的問題

事情到了這里,本該皆大歡喜、功德圓滿。結(jié)果英特爾又出來搞事,但其實(shí)他這次也是被逼無奈。<br />
<br />還記得我們上面說的以英特爾 CEO--高登·摩爾命名的摩爾定律嗎?這個(gè)定律其實(shí)并不是根據(jù)嚴(yán)謹(jǐn)?shù)目茖W(xué)研究得出來的,而是通過英特爾的過往表現(xiàn)推導(dǎo)出的這個(gè)結(jié)論。按理說這是極不符合科學(xué)規(guī)律的,就像我遇到的每個(gè)程序員都背個(gè)電腦包,但是我在大街上不能隨便看到一個(gè)背著電腦包的人就說他是程序員。但是英特爾就是這么 NB,他在的大街上全是程序員。英特爾就這樣維護(hù)著這個(gè)定律每 18 個(gè)月把 CPU 的性能翻一倍,持續(xù)了每多年。<br />

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

<br />到此,摩爾定律終結(jié),CPU 的發(fā)展進(jìn)入了瓶頸。直到有一天一個(gè)腦門閃光的硬件工程師敲響了克瑞格·貝瑞特辦公室的大門。"老板你不用跪了,我有個(gè)辦法可以把 CPU 性能提高一倍"。

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

image.png

克瑞格激動(dòng)的問道:"什么方案?"

硬件工程師:"很簡(jiǎn)單呀,我們只要把現(xiàn)在兩個(gè)的 CPU 裝到一個(gè)大號(hào)的 CPU 里面,那么他的性能就是兩個(gè) CPU 的性能呀!我可真是一個(gè)小機(jī)靈鬼呢"

做了一輩子 CPU 的克瑞格,氣的差點(diǎn)進(jìn)了 ICU。"我老克就算跪一輩子,也不會(huì)做這種傻事"。

image.png

上圖為英特爾發(fā)布的 28 核 CPU。嗯?<br />

<br />
image.png
<br />

<br />當(dāng)然上面其實(shí)有些戲謔的成分,但是 CPU 的發(fā)展結(jié)果也的確是往更多的核心數(shù)去發(fā)展。從單核到雙核再 6 核、8核不停的增長核心數(shù),CPU 的性能也的確跟著增長。這其實(shí)跟我們軟件工程師常用的分布式架構(gòu)一樣,當(dāng)單機(jī)的性能達(dá)到了瓶頸,不可能再通過縱向的增加服務(wù)器的性能提高系統(tǒng)負(fù)載,只能通過把單機(jī)系統(tǒng),拆成多個(gè)分布式服務(wù)來進(jìn)行橫向的擴(kuò)展。<br />
<br />通過增加 CPU 的核心數(shù),硬件工程師看似圓滿的完成時(shí)代交給他的任務(wù)。結(jié)果一口大鍋甩在了咱們軟件工程師的頭上。<br />
<br />來,我們回顧一下,上面我們說 CPU、內(nèi)存、IO 他們有一個(gè)核心矛盾,這個(gè)矛盾就是速度的差異。而且這個(gè)差異仍然沒有解決。但是我們變相的解決了。解決方案是什么?硬件工程師在 CPU 的核心里劃了一塊地方做為緩存,通過這個(gè)緩存均衡他們之間的差異。而軟件工程師呢,為了最大的提高 CPU 的利用率,搞了一個(gè)叫線程的東西,通過多線程之間的切換圓滿解決問題。<br />
<br />嗯,這個(gè)方案很完美,沒有問題。但是,前提是運(yùn)行在單核的 CPU 下。<br />
<br />剛才我們說了 CPU 的核心,會(huì)有一塊地方緩存從內(nèi)存里加載的數(shù)據(jù),這樣就不用每次從內(nèi)存里加載了,提高了效率。但是呢,單核有一個(gè)緩存,多核就會(huì)出現(xiàn)多個(gè)緩存,再加上我們多線程的運(yùn)行,會(huì)出現(xiàn)什么情況呢?下面我們以真實(shí)代碼為例子:<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 />代碼很簡(jiǎn)單,兩個(gè)線程都調(diào)用一個(gè) add 方法,而這個(gè) add 方法的操作是循環(huán) 10 w 次,每次都把這兩個(gè)線程共享的 count 變量加 1 。按照我們的直覺來說,count 開始是 0,每個(gè)線程加 10 w,總共兩個(gè)線程,所以 10 w * 2 = 20 w。<br />
<br />可是呢?結(jié)果并不是我們想的那樣,我運(yùn)行的結(jié)果是:113595。而且每次運(yùn)行的結(jié)果都不一樣,你可以試試。結(jié)果基本上都在 10w ~ 20w 之間,而且無限趨向于 10w。<br />
<br />這是什么鬼?還記得前面說的 CPU 緩存嗎?沒錯(cuò),他就是這只鬼。為了便于說明問題,我畫了幾張圖。<br />

<br />
image.png
<br />上圖是在單核的情況下,首先這個(gè) count 會(huì)被加載到內(nèi)存中。這時(shí)他是初始值 0。然后如圖所示,第 1 步他被加載到了 CPU 的緩存中,CPU 處理器把他從緩存中取出來,然后進(jìn)行 add 操作,加完之后再放入緩存中,緩存再把 count 寫入內(nèi)存中,最終我們就得到了結(jié)果。可見單核情況下,因?yàn)楣蚕砭彺媾c內(nèi)存,沒有任何問題,我們接著看多核的情況下。<br />
<br />
image.png
<br />如上是多核場(chǎng)景下的運(yùn)算過程,具體步驟如下:<br />
  1. 首先 count 被加載到內(nèi)存,緊接著線程1被 CPU 1調(diào)用,把內(nèi)存的 count = 0 加載到了緩存中
  2. 然后 CPU 1把緩存中 count = 0 加載到處理器中,一個(gè)時(shí)間片處理后 13595
  3. CPU 把 count = 13595 存入到緩存,準(zhǔn)備下次接著算
  4. 緩存 把 count = 13595 刷新加內(nèi)存,等下個(gè)時(shí)間片再加載
  5. 線程 2 得到了 CPU2 時(shí)間片,從內(nèi)存中把剛剛線程 1 算了一半的 count = 13595 加載到了緩存
  6. CPU 2 把 count = 13595 加載到了處理器,開始運(yùn)算。與些同時(shí) CPU 1把時(shí)間片又分配給了線程1,線程接著剛才的 count = 13595 運(yùn)算,很快算完得到 10 w ,并把結(jié)果最終刷進(jìn)了內(nèi)存,現(xiàn)在內(nèi)存中的數(shù)據(jù)為 count = 10w。
  7. 線程2也很快運(yùn)行完了 10w 次,現(xiàn)在他得到的結(jié)果 13595 + 10w = 113595。然后同樣把結(jié)果最終的刷新進(jìn)了內(nèi)存,現(xiàn)在內(nèi)存中的數(shù)據(jù)為 count = 113595。

看到問題了嗎?可以理解緩存中的 count 是內(nèi)存中的 count 的一份拷貝。在緩存中修改時(shí)并不會(huì)變更內(nèi)存中的值,而是過一段時(shí)間后刷新回內(nèi)存,而線程1把計(jì)算了一半的值,刷新進(jìn)內(nèi)存后,線程2把這個(gè)新值加載到了 CPU2中,然后計(jì)算。與些同時(shí) CPU 1完成了計(jì)算,并把值刷新進(jìn)了內(nèi)存,CPU2仍在計(jì)算,因?yàn)樗恢?CPU1把值改變了,計(jì)算完了,把自己計(jì)算的值也刷新進(jìn)了內(nèi)存中,這樣就把剛剛 CPU1 忙乎半天的結(jié)果覆蓋了。<br />
<br />出現(xiàn)這個(gè)問題的根本原因就是,CPU 1與 CPU 2各自的操作對(duì)于雙方不可見。在這種情況下,運(yùn)行期間其實(shí)總共有 3 個(gè) count 變量,一個(gè)是內(nèi)存中的 count,一個(gè)是 CPU1中的 count拷貝,最后一個(gè)是 CPU2中的 count 拷貝。<br />

結(jié)論

硬件工程師為均衡 CPU 與 緩存之間的速度差異,而特意加的 CPU 緩存,竟然在多核的場(chǎng)景下陰差陽錯(cuò)的成為了并發(fā)問題中可見性的根源!<br />

其它

本文是《并發(fā)那些事》的第三篇,前兩篇如下:

  1. 【并發(fā)那些事】創(chuàng)建線程的三種方式
  2. 【并發(fā)那些事】生產(chǎn)者消費(fèi)者問題

<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • Swift1> Swift和OC的區(qū)別1.1> Swift沒有地址/指針的概念1.2> 泛型1.3> 類型嚴(yán)謹(jǐn) 對(duì)...
    cosWriter閱讀 11,656評(píng)論 1 32
  • Java內(nèi)存區(qū)域 Java虛擬機(jī)在運(yùn)行程序時(shí)會(huì)把其自動(dòng)管理的內(nèi)存劃分為以上幾個(gè)區(qū)域,每個(gè)區(qū)域都有的用途以及創(chuàng)建銷毀...
    架構(gòu)師springboot閱讀 1,933評(píng)論 0 5
  • 以上代碼會(huì)重復(fù)運(yùn)行 , 不會(huì)停止。 JMM(java內(nèi)存模型) 若想學(xué)習(xí)好多線程, 那么必須了解一下JMM Jav...
    尼爾君閱讀 1,825評(píng)論 0 2
  • 又是一年秋招季,哎呀媽呀我被虐的慘來~這不,前幾陣失蹤沒更新博客,其實(shí)是我偷偷把時(shí)間用在復(fù)習(xí)課本了(霧 堅(jiān)持在社區(qū)...
    tengshe789閱讀 2,151評(píng)論 0 8
  • 一直以來我都以為自己因?yàn)閻鬯圆烹y以忘懷 其實(shí),我只是對(duì)自己投入的感情不甘心 最怕的不是愛錯(cuò)了人,而是自己跟自己...
    23點(diǎn)閱讀 175評(píng)論 0 0

友情鏈接更多精彩內(nèi)容