【并發(fā)那些事】可見性問題的萬惡之源
<br />
硬件工程師為均衡 CPU 與 緩存之間的速度差異制轰,特意加的 CPU 緩存,竟然在多核的場景下陰差陽錯的成為了并發(fā)可見性問題的萬惡之源重抖!(本文過長,如果不是特別無聊昭齐,看到這里就可以了)
前言
還記得那些年,你寫的那些多線程 BUG 嗎醒叁?明明只想得到個 1 + 1 = 2 的預期司浪,結果他有時候得到 1泊业,有時候得到 3把沼,但偏偏有時候他也會返回正確的 2。明明在本地運行的好好的吁伺,一上線一堆詭異的 BUG饮睬。你一遍一遍的檢查代碼,一行一行 debug篮奄,結果無功而返捆愁。<br />
<br />變量為何突然變異割去?代碼為何亂序運行?條件為何形同虛設昼丑?歡迎收看今天的《走進科學》之半夜呻逆。。菩帝。哦咖城,不對,歡迎閱讀今天的《并發(fā)那些事》之可見性問題的萬惡之源呼奢。就像上面說的宜雀,我們在寫并發(fā)程序時,經常會出現超出我們認識與直覺的問題握础,而按我們的以往的經驗辐董,很難去察覺到他的問題所在。而又因為我們不了解他發(fā)生的誘因禀综,即使我們按照書上的方案解決了简烘,但是下次還是會出現。所以本文的主旨并不是解決問題的術菇存,而是解決問題的道夸研。一起來探究多線程問題的根源。<br />
<br />首先揭開謎底依鸥,大多數并發(fā)問題的發(fā)生都是這三個問題導致的亥至,可見性問題、原子性問題贱迟、有序性問題姐扮。那么又是什么導致這三個問題的出現呢?本文將一步步解析可見性問題出現的原因衣吠。<br />
核心矛盾
眾所周知茶敏,電腦由很多的部件組成。其中最最最重要的有三個缚俏,它們分別是 CPU 惊搏、內存、IO(硬盤)忧换。一般來說它們三個的性能高低直接影響到了電腦的整體的性能優(yōu)劣恬惯。<br />
<br />但是從它們誕生之初,就有一個核心矛盾亚茬,即使過了幾十年后的現在酪耳,科技的飛速發(fā)展依舊沒能解決。那么是什么矛盾呢刹缝?<br />
<br />在說矛盾之前碗暗,先說我個同事颈将,他是個電競高手,英雄聯盟言疗、王者榮耀什么的意識特別歷害晴圾。每次看比賽的時候那種指點江山、揮斥方遒的英姿閃閃發(fā)光噪奄。但是呢疑务,一上手打游戲,一頓操作猛如虎梗醇,一看戰(zhàn)績0杠5知允,剛開始我們以為他是個青銅,但是呢叙谨,很多時候游戲的真的就像他說的那樣温鸽,他的預判,他的操作其實都相當的風騷手负。一直很疑惑涤垫,直到我們得出了一個結論,其實他的確是一個王者竟终,因為他滿腦子都是騷操作蝠猬,但是呢?他的雙手跟不上他風騷的大腦统捶。<br />
<br />問題就在這里榆芦,核心矛盾就是速度的差異。CPU 就像是那位同事的大腦喘鸟,很強很風騷匆绣,但是奈何 IO 就像那雙跟不上節(jié)奏的手,限制了發(fā)揮什黑。而且它們之間的速度差異要遠遠超出我們的想像崎淳,CPU 就好比是火箭,那么內存就是三輪車愕把,IO 可能就是馬路旁一只不起眼的小蝸牛拣凹。
各方的努力
既然有了這個問題,那就要想辦法解決恨豁,首先這個問題出在硬件層嚣镜,所以首當其沖的硬件工作師想了很多方式試圖去解決。經過內存跟 IO 硬件工程師的不懈努力圣絮,這兩個組件的速度都得到了大幅提升祈惶。但是呢雕旨?CPU 的工程師也沒閑著扮匠,甚至英特爾的 CEO--高登·摩爾還宣布了一個以自己姓名定義的摩爾定律捧请。其內容大致如下:<br />
集成電路上可容納的晶體管數目,約每18個月便會增加一倍
<br />可以簡單的理解棒搜,CPU 每 18 個月性能就能翻一倍疹蛉。這就讓內存跟 IO 的硬件工程師很絕望了,不怕別人比你聰明力麸,就怕比你聰明的人還比你努力可款。這還是怎么玩?<br />
<br />當然克蚂,獨木不成林闺鲸,CPU 工程師也意識到了這個問題,我再怎么獨領風騷埃叭,以1V5摸恍。沒有用呀?打的正嗨赤屋,一回頭立镶,家被推了。我下了一部電影类早,雙擊打開媚媒,CPU 飛速運行,IO 在緩慢加載涩僻。我 CPU 運行到冒煙也沒用呀缭召,IO 制約了。結果就是電影變成了 PPT逆日,一秒一停恼琼。這樣下去大家都沒得玩。眼看其它隊友帶不動屏富,CPU 工程師想出了一個辦法晴竞,我在 CPU 里面劃一塊出來做為緩存,這個緩存介于 CPU 與 內存之間狠半,跟我們常用的緩存功能差不多噩死,為了均衡 CPU 與內存之間的速度差,在執(zhí)行的時候會把數據先從 IO 加載到 內存神年,再把內存中的數據加載到 CPU 的緩存之中已维。將常用或者將用的數據緩存在 CPU 中后,CPU 每次處理時就不用老是等內存了已日,這極大的提高了CPU 的利用率垛耳。<br />
<br />到這里,硬件工程師圓滿的完成了任務,下面輪到了我們軟件工程師登場了堂鲜。<br />
<br />雖然說加了緩存之后栈雳,CPU 的利用率成倍上升,從當初的運行 5 分鐘缔莲,加載 2 小時哥纫。變成了,運行 2 分鐘痴奏,加載 1 小時蛀骇,但是體驗還是很差。還拿電影舉例读拆,看電影的時候不光有畫面擅憔,還得有聲音呀,你運行是快了檐晕,但是先放視頻雕欺,再放聲音。就像是先看一部默片棉姐,再聽一遍廣播屠列,這種音畫分離的觀感沒比 PPT 強多少。<br />
<br />后來在軟硬工程師的天才努力后伞矩,發(fā)明了一種神奇的東西--線程笛洛。說線程之前我們先說一下進程,這個東西可是我們能看到的東西乃坤,比始你啟動的瀏覽器苛让,比如你正在使用的微信,這些軟件啟動后湿诊,在操作系統(tǒng)中都是一個進程狱杰。而線程呢?它可以簡單理解成是一個進程的子集厅须,也就是說進程其實是一堆線程組成仿畸。而且操作系統(tǒng)通常會把所有硬件資源,包括內存之內的全分配給進程朗和,進程就像一個包工頭一樣再分配給底下的線程错沽。但是唯獨有一樣資源,操作系統(tǒng)是直接分配給線程的眶拉,那就是 CPU 資源千埃。<br />
<br />這樣的設置其實是有深意的∫渲玻可能有人覺得放可,分給進程也可以呀谒臼,但是進程要比線程重的多,切換的開銷過大耀里,得不嘗試蜈缤。就像是你想打開一個新的網頁,是打開一個新瀏覽器快呢备韧?還是打開一個新的 Tab 頁快呢?總之有了線程之后痪枫,我們就有了一個很酷炫的操作--線程切換织堂。他能帶來什么呢?接著說電影的事奶陈,我們其實還是先播視頻再放聲音易阳。但是與上面不同的是,我們是先放一會視頻吃粒,再放一會聲音潦俺。只要單次播放的夠短,兩種操作之間的切換夠快徐勃,就會讓人感覺其實視頻與聲音是同時播的錯覺事示。而輕量的線程以及提供的切換能力給這種操作提供了可能。<br />
<br />至此僻肖,問題在無數硬件與軟件工程師的努力下肖爵,得到了比較完美的解決。<br />
新的問題
事情到了這里臀脏,本該皆大歡喜劝堪、功德圓滿。結果英特爾又出來搞事揉稚,但其實他這次也是被逼無奈秒啦。<br />
<br />還記得我們上面說的以英特爾 CEO--高登·摩爾命名的摩爾定律嗎?這個定律其實并不是根據嚴謹的科學研究得出來的搀玖,而是通過英特爾的過往表現推導出的這個結論余境。按理說這是極不符合科學規(guī)律的,就像我遇到的每個程序員都背個電腦包灌诅,但是我在大街上不能隨便看到一個背著電腦包的人就說他是程序員葛超。但是英特爾就是這么 NB,他在的大街上全是程序員延塑。英特爾就這樣維護著這個定律每 18 個月把 CPU 的性能翻一倍绣张,持續(xù)了每多年。<br />
<br />直到第四任 CEO 的時候关带,摩爾定律突然不靈了侥涵,上圖就是時任英特爾 CEO--克瑞格·貝瑞特沼撕。在一次技術大會上,向與會者下跪芜飘。為一再延期直至最終失敗放棄的 4GHz 主頻奔 4 處理器致歉务豺。<br />
<br />到此,摩爾定律終結嗦明,CPU 的發(fā)展進入了瓶頸笼沥。直到有一天一個腦門閃光的硬件工程師敲響了克瑞格·貝瑞特辦公室的大門。"老板你不用跪了娶牌,我有個辦法可以把 CPU 性能提高一倍"奔浅。
一句話讓克瑞格老淚縱橫,那一天诗良,回想起了汹桦,受那些家伙支配的恐怖……被囚禁在鳥籠中的屈辱……
克瑞格激動的問道:"什么方案?"
硬件工程師:"很簡單呀鉴裹,我們只要把現在兩個的 CPU 裝到一個大號的 CPU 里面舞骆,那么他的性能就是兩個 CPU 的性能呀!我可真是一個小機靈鬼呢"
做了一輩子 CPU 的克瑞格径荔,氣的差點進了 ICU督禽。"我老克就算跪一輩子,也不會做這種傻事"总处。
上圖為英特爾發(fā)布的 28 核 CPU赂蠢。嗯?<br />
<br />當然上面其實有些戲謔的成分辨泳,但是 CPU 的發(fā)展結果也的確是往更多的核心數去發(fā)展虱岂。從單核到雙核再 6 核、8核不停的增長核心數菠红,CPU 的性能也的確跟著增長第岖。這其實跟我們軟件工程師常用的分布式架構一樣,當單機的性能達到了瓶頸试溯,不可能再通過縱向的增加服務器的性能提高系統(tǒng)負載蔑滓,只能通過把單機系統(tǒng),拆成多個分布式服務來進行橫向的擴展遇绞。<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 方法的操作是循環(huán) 10 w 次,每次都把這兩個線程共享的 count 變量加 1 叹坦。按照我們的直覺來說熊镣,count 開始是 0,每個線程加 10 w募书,總共兩個線程绪囱,所以 10 w * 2 = 20 w。<br />
<br />可是呢莹捡?結果并不是我們想的那樣鬼吵,我運行的結果是:113595。而且每次運行的結果都不一樣篮赢,你可以試試而柑。結果基本上都在 10w ~ 20w 之間文捶,而且無限趨向于 10w。<br />
<br />這是什么鬼媒咳?還記得前面說的 CPU 緩存嗎粹排?沒錯,他就是這只鬼涩澡。為了便于說明問題顽耳,我畫了幾張圖。<br />
<br />
- 首先 count 被加載到內存冬三,緊接著線程1被 CPU 1調用,把內存的 count = 0 加載到了緩存中
- 然后 CPU 1把緩存中 count = 0 加載到處理器中缘缚,一個時間片處理后 13595
- CPU 把 count = 13595 存入到緩存勾笆,準備下次接著算
- 緩存 把 count = 13595 刷新加內存,等下個時間片再加載
- 線程 2 得到了 CPU2 時間片忙灼,從內存中把剛剛線程 1 算了一半的 count = 13595 加載到了緩存
- CPU 2 把 count = 13595 加載到了處理器匠襟,開始運算。與些同時 CPU 1把時間片又分配給了線程1该园,線程接著剛才的 count = 13595 運算酸舍,很快算完得到 10 w ,并把結果最終刷進了內存里初,現在內存中的數據為 count = 10w啃勉。
- 線程2也很快運行完了 10w 次,現在他得到的結果 13595 + 10w = 113595双妨。然后同樣把結果最終的刷新進了內存淮阐,現在內存中的數據為 count = 113595叮阅。
看到問題了嗎?可以理解緩存中的 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 緩存,竟然在多核的場景下陰差陽錯的成為了并發(fā)問題中可見性的根源别惦!<br />
其它
本文是《并發(fā)那些事》的第三篇狈茉,前兩篇如下:
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />