可見性 原子性和有序性問(wèn)題:并發(fā)編程bug源頭

我們的CPU,內(nèi)存,I/O設(shè)備都在不斷迭代,不斷朝著更快的方向努力,但是,在這個(gè)快速發(fā)展的過(guò)程中,有一個(gè)核心矛盾一直存在,就是這三者的速度差異.

cpu和內(nèi)存的速度差異可以形象描述為:CPU是天上一天,內(nèi)存是地上一年(假設(shè)CPU執(zhí)行一條普通指令需要一天,那么CPU讀寫內(nèi)存的等待一年的時(shí)間).

內(nèi)存和I/O設(shè)備的速度差異更大,內(nèi)存是天上一天,I/O設(shè)備是地上十年!

程序里大部分語(yǔ)句都要訪問(wèn)內(nèi)存,有些還要訪問(wèn)I/O,根據(jù)木桶理論(一只水桶能裝多少水取決于它最短的那塊木板),程序整體的性能取決于最慢的操作--讀寫I/O設(shè)備,也就是所單方面提高CPU性能是無(wú)效的.

為了合理利用CPU的高性能,平衡這三者的速度差異,計(jì)算機(jī)體系機(jī)構(gòu),操作系統(tǒng),編譯程序都做出了貢獻(xiàn),主要體現(xiàn)為:

1.CPU增加了緩存,以均衡與內(nèi)存的速度差異;

2,操作系統(tǒng)增加了進(jìn)程,線程,以分時(shí)復(fù)用CPU,進(jìn)而均衡CPU與I/O設(shè)備的速度差異;

3.編譯程序優(yōu)化指令執(zhí)行次序,使得緩存能夠得到更加合理的利用.

并發(fā)程序很多詭異問(wèn)題的根源也在這里!

源頭一:緩存導(dǎo)致的可見性問(wèn)題

在單核時(shí)代,所有的線程都是在一顆CPU上執(zhí)行,CPU緩存與內(nèi)存的數(shù)據(jù)一致性容易解決.因?yàn)樗芯€程都是操作同樣一個(gè)CPU的緩存,一個(gè)線程對(duì)緩存的寫,對(duì)另外一個(gè)線程來(lái)說(shuō)一定是可見的.例如下面的圖中,線程A和線程B都是在操作同一個(gè)CPU里面的緩存,所以線程A更新了變量V的值,那么線程B之后再訪問(wèn)變量V,得到的一定是V的最新值(線程A寫過(guò)的值).



CPU緩存與內(nèi)存關(guān)系圖

一個(gè)線程對(duì)共享變量的修改,另外一個(gè)線程能夠立即看到,我們稱為可見性.

多核時(shí)代,每顆CPU都有自己的緩存,這時(shí)CPU緩存與內(nèi)存的數(shù)據(jù)一只熊就沒(méi)那么容易解決了,當(dāng)多個(gè)線程再不同的CPU上執(zhí)行時(shí),這些線程操作的是不同的CPU緩存.比如下圖中,線程A操作的是CPU-1上的緩存,而線程B操作的是CPU-2上的緩存,很明顯,這時(shí)候線程A對(duì)變量V的操作對(duì)于線程B而言就不具備可見性了.


多核 CPU 的緩存與內(nèi)存關(guān)系

用代碼驗(yàn)證多核場(chǎng)景下可見性問(wèn)題,下面的代碼,每執(zhí)行一次add10k()方法,都會(huì)循環(huán)10000次count+=1操作,在calc()方法中創(chuàng)建了兩個(gè)線程,每次線程調(diào)用依次add10k()方法,calc()方法得到的結(jié)果會(huì)是多少?


看似是20000,因?yàn)樵趩尉€程里帶哦用兩次add10k()方法,count值就是20000,但實(shí)際上calc()的執(zhí)行結(jié)果是10000-20000之間的隨機(jī)數(shù).

我們假設(shè)線程A和線程B同時(shí)開始執(zhí)行,那么第一次都會(huì)將count=0讀到各自的CPU緩存里,執(zhí)行完count+=1之后,各自CPU緩存里的值都是1,同時(shí)寫入內(nèi)存后,我們會(huì)發(fā)現(xiàn)內(nèi)存中是1,而不是期望的2.之后由于各自的CPU緩存里都有了count的值,兩個(gè)線程都是基于CPU緩存里的count值來(lái)計(jì)算,所以導(dǎo)致最終count的值都是小于20000的,這就是緩存的可見性問(wèn)題.

循環(huán)10000次count+=1操作如果改成1億次,效果會(huì)更明顯,最終count的值接近1億,而不是2億.如果循環(huán)10000次,count的值接近20000,原因是兩個(gè)線程不是同時(shí)啟動(dòng)的,有一個(gè)時(shí)差.

源頭二:線程切換帶來(lái)的原子性問(wèn)題

由于I/O太慢,早期的操作系統(tǒng)就發(fā)明了多進(jìn)程,即便在單核的CPU上我們可以一邊寫bug一邊聽歌.

操作系統(tǒng)允許某個(gè)線程執(zhí)行一小段時(shí)間,例如50毫秒,過(guò)了50毫秒操作系統(tǒng)就會(huì)重新選擇一個(gè)進(jìn)程來(lái)執(zhí)行,(稱為"任務(wù)切換"),這個(gè)50毫秒稱為"時(shí)間片"!


線程切換

在一個(gè)時(shí)間片內(nèi)滑沧,如果一個(gè)進(jìn)程進(jìn)行一個(gè) IO 操作萍摊,例如讀個(gè)文件软瞎,這個(gè)時(shí)候該進(jìn)程可以把自己標(biāo)記為“休眠狀態(tài)”并出讓 CPU 的使用權(quán),待文件讀進(jìn)內(nèi)存,操作系統(tǒng)會(huì)把這個(gè)休眠的進(jìn)程喚醒,喚醒后的進(jìn)程就有機(jī)會(huì)重新獲得 CPU 的使用權(quán)了.

這里的進(jìn)程在等待 IO 時(shí)之所以會(huì)釋放 CPU 使用權(quán),是為了讓 CPU 在這段等待時(shí)間里可以做別的事情槽奕,這樣一來(lái) CPU 的使用率就上來(lái)了;此外房轿,如果這時(shí)有另外一個(gè)進(jìn)程也讀文件粤攒,讀文件的操作就會(huì)排隊(duì),磁盤驅(qū)動(dòng)在完成一個(gè)進(jìn)程的讀操作后囱持,發(fā)現(xiàn)有排隊(duì)的任務(wù)夯接,就會(huì)立即啟動(dòng)下一個(gè)讀操作,這樣 IO 的使用率也上來(lái)了纷妆。

是不是很簡(jiǎn)單的邏輯?

早期的操作系統(tǒng)基于進(jìn)程來(lái)調(diào)度 CPU盔几,不同進(jìn)程間是不共享內(nèi)存空間的,所以進(jìn)程要做任務(wù)切換就要切換內(nèi)存映射地址掩幢,而一個(gè)進(jìn)程創(chuàng)建的所有線程逊拍,都是共享一個(gè)內(nèi)存空間的,所以線程做任務(wù)切換成本就很低了〖柿冢現(xiàn)代的操作系統(tǒng)都基于更輕量的線程來(lái)調(diào)度芯丧,現(xiàn)在我們提到的“任務(wù)切換”都是指“線程切換”。

Java 并發(fā)程序都是基于多線程的世曾,自然也會(huì)涉及到任務(wù)切換.任務(wù)切換的時(shí)機(jī)大多數(shù)是在時(shí)間片結(jié)束的時(shí)候缨恒,我們現(xiàn)在基本都使用高級(jí)語(yǔ)言編程,高級(jí)語(yǔ)言里一條語(yǔ)句往往需要多條 CPU 指令完成轮听,例如上面代碼中的count += 1骗露,至少需要三條 CPU 指令。

指令 1:首先血巍,需要把變量 count 從內(nèi)存加載到 CPU 的寄存器萧锉;

指令 2:之后,在寄存器中執(zhí)行 +1 操作藻茂;

指令 3:最后驹暑,將結(jié)果寫入內(nèi)存(緩存機(jī)制導(dǎo)致可能寫入的是 CPU 緩存而不是內(nèi)存)玫恳。

操作系統(tǒng)做任務(wù)切換辨赐,可以發(fā)生在任何一條CPU 指令執(zhí)行完,是 CPU 指令,是 CPU 指令.而不是高級(jí)語(yǔ)言里的一條語(yǔ)句。對(duì)于上面的三條指令來(lái)說(shuō)京办,我們假設(shè) count=0掀序,如果線程 A在指令 1 執(zhí)行完后做線程切換,線程 A 和線程 B 按照下圖的序列執(zhí)行惭婿,那么我們會(huì)發(fā)現(xiàn)兩個(gè)線程都執(zhí)行了 count+=1 的操作不恭,但是得到的結(jié)果不是我們期望的 2叶雹,而是 1。

非原子操作的執(zhí)行

我們潛意識(shí)里面覺得 count+=1 這個(gè)操作是一個(gè)不可分割的整體换吧,就像一個(gè)原子一樣折晦,線程的切換可以發(fā)生在 count+=1 之前,也可以發(fā)生在 count+=1 之后沾瓦,但就是不會(huì)發(fā)生在中間满着。我們把一個(gè)或者多個(gè)操作在 CPU 執(zhí)行的過(guò)程中不被中斷的特性稱為原子性!

CPU 能保證的原子操作是 CPU 指令級(jí)別的,而不是高級(jí)語(yǔ)言的操作符贯莺,這是違背我們直覺的地方风喇。因此,很多時(shí)候我們需要在高級(jí)語(yǔ)言層面保證操作的原子性缕探。

源頭之三:編譯優(yōu)化帶來(lái)的有序性問(wèn)題

有序性指的是程序按照代碼的先后順序執(zhí)行魂莫。編譯器為了優(yōu)化性能,有時(shí)候會(huì)改變程序中語(yǔ)句的先后順序爹耗,例如程序中:“a=6耙考;b=7;”編譯器優(yōu)化后可能變成“b=7潭兽;a=6

在 Java 領(lǐng)域一個(gè)經(jīng)典的案例就是利用雙重檢查創(chuàng)建單例對(duì)象琳骡,例如下面的代碼:在獲取實(shí)例 getInstance() 的方法中,我們首先判斷 instance 是否為空讼溺,如果為空楣号,則鎖定Singleton.class 并再次檢查 instance 是否為空,如果還為空則創(chuàng)建 Singleton 的一個(gè)實(shí)例.

假設(shè)有兩個(gè)線程 A怒坯、B 同時(shí)調(diào)用 getInstance() 方法炫狱,他們會(huì)同時(shí)發(fā)現(xiàn) instance ==null ,于是同時(shí)對(duì) Singleton.class 加鎖剔猿,此時(shí) JVM 保證只有一個(gè)線程能夠加鎖成功(假設(shè)是線程 A)视译,另外一個(gè)線程則會(huì)處于等待狀態(tài)(假設(shè)是線程 B);線程 A 會(huì)創(chuàng)建一個(gè) Singleton 實(shí)例归敬,之后釋放鎖酷含,鎖釋放后,線程 B 被喚醒汪茧,線程 B 再次嘗試加鎖椅亚,此時(shí)是可以加鎖成功的,加鎖成功后舱污,線程 B 檢查 instance == null 時(shí)會(huì)發(fā)現(xiàn)呀舔,已經(jīng)創(chuàng)建過(guò) Singleton 實(shí)例了,所以線程 B 不會(huì)再創(chuàng)建一個(gè) Singleton 實(shí)例.

看上去沒(méi)毛病.但是getInstance()方法不完美.問(wèn)題出在new操作上,你以為的new操作是這樣:

1.開辟一塊內(nèi)存M

2.在內(nèi)存M上初始化Singleton對(duì)象,

3.然后M的地址賦值給instance變量.

實(shí)際上優(yōu)化后是這樣:

1.開辟一塊內(nèi)存M

2.M的地址賦值給instance變量.

3.在內(nèi)存M上初始化Singleton對(duì)象

我們假設(shè)線程 A 先執(zhí)行 getInstance() 方法扩灯,當(dāng)執(zhí)行完指令 2時(shí)恰好發(fā)生了線程切換媚赖,切換到了線程 B 上霜瘪;如果此時(shí)線程 B 也執(zhí)行 getInstance() 方法,那么線程 B 會(huì)發(fā)現(xiàn)instance != null惧磺,所以直接返回 instance颖对,而此時(shí)的instance 是沒(méi)有初始化過(guò)的,如果我們這個(gè)時(shí)候訪問(wèn) instance 的成員變量就可能觸發(fā)空指針異常


雙重檢查創(chuàng)建單例的異常執(zhí)行路徑

總結(jié)

只要我們能夠深刻理解可見性磨隘、原子性惜互、有序性在并發(fā)場(chǎng)景下的原理,很多并發(fā) Bug 都是可以理解琳拭、可以診斷的训堆。

緩存導(dǎo)致的可見性問(wèn)題,線程切換帶來(lái)的原子性問(wèn)題白嘁,編譯優(yōu)化帶來(lái)的有序性問(wèn)題坑鱼,其實(shí)緩存、線程絮缅、編譯優(yōu)化的目的和我們寫并發(fā)程序的目的是相同的鲁沥,都是提高程序性能。但是技術(shù)在解決一個(gè)問(wèn)題的同時(shí)耕魄,必然會(huì)帶來(lái)另外一個(gè)問(wèn)題画恰,所以在采用一項(xiàng)技術(shù)的同時(shí),一定要清楚它帶來(lái)的問(wèn)題是什么吸奴,以及如何規(guī)避允扇。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市则奥,隨后出現(xiàn)的幾起案子考润,更是在濱河造成了極大的恐慌,老刑警劉巖读处,帶你破解...
    沈念sama閱讀 219,039評(píng)論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件糊治,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡罚舱,警方通過(guò)查閱死者的電腦和手機(jī)井辜,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,426評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)管闷,“玉大人粥脚,你說(shuō)我怎么就攤上這事〗ケ保” “怎么了阿逃?”我有些...
    開封第一講書人閱讀 165,417評(píng)論 0 356
  • 文/不壞的土叔 我叫張陵铭拧,是天一觀的道長(zhǎng)赃蛛。 經(jīng)常有香客問(wèn)我恃锉,道長(zhǎng),這世上最難降的妖魔是什么呕臂? 我笑而不...
    開封第一講書人閱讀 58,868評(píng)論 1 295
  • 正文 為了忘掉前任破托,我火速辦了婚禮,結(jié)果婚禮上歧蒋,老公的妹妹穿的比我還像新娘土砂。我一直安慰自己,他們只是感情好谜洽,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,892評(píng)論 6 392
  • 文/花漫 我一把揭開白布萝映。 她就那樣靜靜地躺著,像睡著了一般阐虚。 火紅的嫁衣襯著肌膚如雪序臂。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,692評(píng)論 1 305
  • 那天实束,我揣著相機(jī)與錄音奥秆,去河邊找鬼。 笑死咸灿,一個(gè)胖子當(dāng)著我的面吹牛构订,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播避矢,決...
    沈念sama閱讀 40,416評(píng)論 3 419
  • 文/蒼蘭香墨 我猛地睜開眼悼瘾,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了审胸?” 一聲冷哼從身側(cè)響起分尸,我...
    開封第一講書人閱讀 39,326評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎歹嘹,沒(méi)想到半個(gè)月后箩绍,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,782評(píng)論 1 316
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡尺上,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,957評(píng)論 3 337
  • 正文 我和宋清朗相戀三年材蛛,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片怎抛。...
    茶點(diǎn)故事閱讀 40,102評(píng)論 1 350
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡卑吭,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出马绝,到底是詐尸還是另有隱情豆赏,我是刑警寧澤,帶...
    沈念sama閱讀 35,790評(píng)論 5 346
  • 正文 年R本政府宣布,位于F島的核電站掷邦,受9級(jí)特大地震影響白胀,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜抚岗,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,442評(píng)論 3 331
  • 文/蒙蒙 一或杠、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧宣蔚,春花似錦向抢、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,996評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至亩冬,卻和暖如春兄猩,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背鉴未。 一陣腳步聲響...
    開封第一講書人閱讀 33,113評(píng)論 1 272
  • 我被黑心中介騙來(lái)泰國(guó)打工枢冤, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人铜秆。 一個(gè)月前我還...
    沈念sama閱讀 48,332評(píng)論 3 373
  • 正文 我出身青樓淹真,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親连茧。 傳聞我的和親對(duì)象是個(gè)殘疾皇子核蘸,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,044評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容