01 | 可見性醋拧、原子性和有序性問題:并發(fā)編程Bug的源頭

如果你細心觀察的話慷嗜,你會發(fā)現(xiàn),不管是哪一門語言丹壕,并發(fā)類的只是都是在高級篇里庆械,換句話說,這塊知識點對于程序員來講是比較進階的知識雀费,比如你若對操作系統(tǒng)的知識一無所知的話,那去理解一些原理就會比較費力氣痊焊。

你我都知道編寫并發(fā)程序是一件困難的事情盏袄,并發(fā)程序的Bug 往往會詭異的出現(xiàn),然后又詭異的消失薄啥,很多時候讓人很抓狂辕羽。但要快速而精準(zhǔn)的解決“并發(fā)” 類的疑難雜癥,你就要了解事情的本質(zhì)垄惧,追本溯源刁愿,深入分析Bug 的源頭。

并發(fā)程序幕后的故事

這些年到逊,我們的 CPU铣口、內(nèi)存滤钱、I/O 設(shè)備都在不斷迭代, 不斷朝著更快的方向努力脑题,但是件缸,在這個快速發(fā)展的過程中,有一個核心矛盾一直存在叔遂,就是者三者的速度差異他炊,CPU 和內(nèi)存的速度差異可以形象地描述為:CPU 是天上一天,內(nèi)存是地上一年(假設(shè) CPU 執(zhí)行一條普通指令需要一天已艰,那么 CPU 讀寫內(nèi)存得等待一年的時間)痊末。內(nèi)存和 I/O 設(shè)備的速度差異就更大了,內(nèi)存是天上一天哩掺,I/O 設(shè)備是地上十年凿叠。

程序里大部分語句都要訪問內(nèi)存,有些還要訪問I/O疮丛, 根據(jù)木桶理論幔嫂,程序的整體性能取決于最慢的讀寫-讀寫 I/O 設(shè)備, 也就是說單方面提高CPU 性能是無效的誊薄。

為了合理利用CPU 的高性能履恩,平衡者三者的差異,計算機體系結(jié)構(gòu)呢蔫,操作系統(tǒng)切心,編譯程序都做出了貢獻,主要體現(xiàn)為:

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

2俏脊、操作系統(tǒng)增加了進程全谤、線程,以分時復(fù)用CPU 爷贫,進而均衡CPU 與 I/O 設(shè)備的速度差異认然;

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

現(xiàn)在我們都在默默的享受著這些成果,但是天下沒有免費的午餐腾务,并發(fā)程序很多詭異問題的根源也在這里毕骡。

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

在單核時代,所有的線程都是在一顆 CPU 上執(zhí)行,CPU 緩存與數(shù)據(jù)的一致性容易解決未巫。因為所有的線程都是操作同一個CPU 的緩存窿撬, 一個線程對緩存的讀寫,對另一個線程來說一定是可見的橱赠。例如在下圖中線程 A 和線程 B 都是操作同一個 CPU 里面的緩存尤仍,所以線程 A 更新了變量 V 的值,那么線程 B 之后再訪問變量 V狭姨,得到的一定是 V 的最新值(線程 A 寫過的值)宰啦。

img

一個線程對共享變量的修改,另外一個線程能夠立刻看到饼拍,我們成為可見性赡模。

多核時代,每顆CPU都有自己的緩存师抄,這時漓柑,CPU 緩存與內(nèi)存的數(shù)據(jù)一致性就沒那么容易解決了,當(dāng)多個線程在不同的CPU 上執(zhí)行時叨吮,這些線程操作的時不同的CPU 緩存辆布,比如下圖中 線程 A 操作的是 CPU-1 上的緩存,而線程 B 操作的是 CPU-2 上的緩存茶鉴,很明顯锋玲,這個時候線程 A 對變量 V 的操作對于線程 B 而言就不具備可見性了。這個就屬于硬件程序員給軟件程序員挖的“坑”涵叮。

img

下面我們再用一段代碼來驗證一下多核場景下的可見性問題惭蹂。下面的代碼,每執(zhí)行一次 add10K() 方法割粮,都會循環(huán) 10000 次 count+=1 操作盾碗。在 calc() 方法中我們創(chuàng)建了兩個線程,每個線程調(diào)用一次 add10K() 方法舀瓢,我們來想一想執(zhí)行 calc() 方法得到的結(jié)果應(yīng)該是多少呢廷雅?

當(dāng)只有一個線程調(diào)用的時候

public class CounterTest {

    private int count = 0;

    private void add10k() {
        for (int idx = 0; idx < 10000; idx++) {
            count += 1;
        }
    }

    public static void main(String[] args) {

        CounterTest test = new CounterTest();
        //當(dāng)只有一個線程調(diào)用時,輸出結(jié)果
        new Thread(() -> {
            test.add10k();
            System.out.println(test.count);
        }).start();

        System.out.println("main thread is over");
    }
}

輸出結(jié)果:
main thread is over
10000

增加一個線程:

public class CounterTest {

    private int count = 0;

    private void add10k() {
        for (int idx = 0; idx < 10000; idx++) {
            count += 1;
        }
    }

    public static void main(String[] args) throws Exception {

        CounterTest test = new CounterTest();
        //當(dāng)只有一個線程調(diào)用時京髓,輸出結(jié)果
        Thread t1 = new Thread(() -> {
            test.add10k();
            System.out.println(test.count);
        });

        //增加一個線程
        Thread t2 = new Thread(() -> {
            test.add10k();
            System.out.println(test.count);
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("main thread is over");
    }
}

輸出結(jié)果:
11827
19664
main thread is over

直覺告訴我們應(yīng)該是 20000航缀,因為在單線程里調(diào)用兩次 add10K() 方法,count 的值就是 20000朵锣,但實際上 calc() 的執(zhí)行結(jié)果是個 10000 到 20000 之間的隨機數(shù)谬盐。為什么呢甸私?

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

循環(huán) 10000 次 count+=1 操作如果改為循環(huán) 1 億次,你會發(fā)現(xiàn)效果更明顯汰聋,最終 count 的值接近 1 億门粪,而不是 2 億。如果循環(huán) 10000 次烹困,count 的值接近 20000玄妈,原因是兩個線程不是同時啟動的,有一個時差髓梅。

img

源頭之二:線程切換帶來的原子性問題

由于IO 太慢拟蜻, 早期的操作系統(tǒng)就發(fā)明了多線程,即便在單核的CPU 上枯饿,我們也可以一邊聽歌酝锅,一邊寫B(tài)UG,這個就是多進程的功勞鸭你。

操作系統(tǒng)允許某個進程執(zhí)行一小段時間屈张,例如:50 毫秒,過了 50 毫秒操作系統(tǒng)就會重新選擇一個進程來執(zhí)行(我們稱為“任務(wù)切換”)袱巨,這個 50 毫秒稱為“時間片”阁谆。

img

在一個時間片里,如果一個進程進行IO操作愉老,例如讀某個文件场绿,這個時候該進程可以把自己標(biāo)記為休眠狀態(tài)” 并讓出CPU的使用權(quán),待文件讀進內(nèi)存嫉入,操作系統(tǒng)會把這個休眠的進程喚醒焰盗,喚醒后的進程就有機會從新獲得CPU的使用權(quán)了。

這里的進程在等待IO時之所以會釋放CPU 的使用權(quán)咒林,是為了讓CPU 在這段時間可以做別的事情熬拒,這樣一來CPU 的使用率就上來了;此外垫竞,如果這時也有另外一個進程也讀文件澎粟,讀文件的操作就會排隊蛀序,磁盤驅(qū)動在完成一個進程的讀操作后,發(fā)現(xiàn)有排隊的任務(wù)活烙,就會立即啟動下一個讀操作徐裸,這樣IO的使用率就上來了。

是不是很簡單的邏輯啸盏?但是重贺,雖然看似簡單,支持多進程分時復(fù)用在操作系統(tǒng)的發(fā)展史上卻有里程碑的意義回懦,Unix 就是因為解決了這個問題而名震天下气笙。

早期的操作系統(tǒng)基于進程來調(diào)度CPU ,不同進程間是不共享內(nèi)存空間的怯晕,所以進程要做切換就要切換內(nèi)存映射地址健民。而一個進程創(chuàng)建的所有線程,都是共享一個內(nèi)存空間的贫贝,所以線程做任務(wù)切換成本就很低了秉犹,現(xiàn)代操作系統(tǒng)都是基于更輕量的線程來調(diào)度的,現(xiàn)在我們提到的“任務(wù)切換”都是指“線程切換”稚晚。

Java 并發(fā)程序都是基于多線程的崇堵,自然也會涉及到任務(wù)切換,也許你想不到客燕,任務(wù)切換竟然也是并發(fā)編程的Bug 源頭之一鸳劳,任務(wù)切換的時機大多是在時間片結(jié)束的時候,我們現(xiàn)在基本都使用高級編程語言也搓,高級語言里赏廓,一條語句往往需要多條CPU指令完成,例如上面代碼中的count += 1傍妒,至少需要三條 CPU 指令幔摸。

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

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

指令 3:最后嗦玖,將結(jié)果寫入內(nèi)存(緩存機制導(dǎo)致可能寫入的是 CPU 緩存而不是內(nèi)存)患雇。

操作系統(tǒng)做任務(wù)切換,可以發(fā)生在任何一條CPU 指令 執(zhí)行完宇挫,是的苛吱,是CPU 指令 ,而不是高級語言里的一條語句器瘪,對于上面的三條指令來說翠储,我們假定count=0拼缝, 如果線程A 在指令 1 執(zhí)行完后做線程切換, 線程 A 和線程 B 按照下圖的序列執(zhí)行彰亥,那么我們會發(fā)現(xiàn)兩個線程都執(zhí)行了 count+=1 的操作,但是得到的結(jié)果不是我們期望的 2衰齐,而是 1任斋。

img

我們潛意識里就覺得count+=1 這個 操作是一個不可分割的整體,就像一個原子一樣耻涛,線程的切換可以發(fā)生在count+=1 之前废酷,也可以發(fā)生在count+=1 之后,但就是不會發(fā)生在中間抹缕,我們把一個或者多個操作在CPU 執(zhí)行的過程中不被中斷的特性成為原子性澈蟆。CPU 能保證的原子操作是CPU 指令級別的,而不是高級語言的操作符卓研。這是違背我們直覺的地方趴俘。因此,很多時候我們需要在高級語言層面保證操作的原子性奏赘。

源頭之三:編譯優(yōu)化帶來的有序性問題

那并發(fā)編程還有沒有其他有違直覺容易導(dǎo)致Bug 的技術(shù)呢寥闪?有,就是有序性磨淌,顧名思義疲憋,有序性是指按照代碼的先后順序執(zhí)行。編譯器為了優(yōu)化性能梁只,有時候會改變程序中語句的先后順序缚柳,例如程序中:“a=6;b=7搪锣;”編譯器優(yōu)化后可能變成“b=7秋忙;a=6;”构舟, 在這個例子中翰绊,編譯器調(diào)整了語句的順序,但是不影響程序的最終結(jié)果旁壮。有時候編譯器及解釋器的優(yōu)化可能導(dǎo)致意想不到的Bug 监嗜。

在Java 領(lǐng)域一個經(jīng)典的案例就是利用雙重檢查創(chuàng)建單利對象,例如下面代碼:在獲取實例 getInstance() 的方法中抡谐,我們首先判斷 instance 是否為空裁奇,如果為空,則鎖定 Singleton.class 并再次檢查 instance 是否為空麦撵,如果還為空則創(chuàng)建 Singleton 的一個實例刽肠。


public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

假設(shè)有兩個線程 A溃肪、B 同時調(diào)用 getInstance() 方法,他們會同時發(fā)現(xiàn) instance == null 音五,于是同時對 Singleton.class 加鎖惫撰,此時 JVM 保證只有一個線程能夠加鎖成功(假設(shè)是線程 A),另外一個線程則會處于等待狀態(tài)(假設(shè)是線程 B)躺涝;線程 A 會創(chuàng)建一個 Singleton 實例厨钻,之后釋放鎖,鎖釋放后坚嗜,線程 B 被喚醒夯膀,線程 B 再次嘗試加鎖,此時是可以加鎖成功的苍蔬,加鎖成功后诱建,線程 B 檢查 instance == null 時會發(fā)現(xiàn),已經(jīng)創(chuàng)建過 Singleton 實例了碟绑,所以線程 B 不會再創(chuàng)建一個 Singleton 實例俺猿。

這看上去一切都很完美,無懈可擊格仲,但實際上這個 getInstance() 方法并不完美辜荠。 問題出在哪里呢?出在 new 操作上抓狭,我們以為的 new 操作應(yīng)該是:

  1. 分配一塊內(nèi)存M伯病;
  2. 在內(nèi)存 M 上初始化 Singleton 對象;
  3. 然后 M 的地址賦值給 instance 變量否过。

但是實際上優(yōu)化后的路徑卻是 這樣的:

  1. 分配一塊內(nèi)存M午笛;
  2. 將 M 的地址賦值給 instance 變量;
  3. 最后在內(nèi)存 M 上初始化 Singleton 對象苗桂。

優(yōu)化后會導(dǎo)致什么問題呢药磺?我們假設(shè)現(xiàn)在A先執(zhí)行g(shù)etInstance() 方法, 當(dāng)執(zhí)行完指令 2 時恰好發(fā)生了線程切換煤伟,切換到了線程 B 上癌佩;如果此時線程 B 也執(zhí)行 getInstance() 方法,那么線程 B 在執(zhí)行第一個判斷時會發(fā)現(xiàn) instance != null 便锨,所以直接返回 instance围辙,而此時的 instance 是沒有初始化過的,如果我們這個時候訪問 instance 的成員變量就可能觸發(fā)空指針異常放案。

img

總結(jié)

要寫好并發(fā)程序姚建,首先要知道并發(fā)程序的問題在哪里,只有確定了“靶子”吱殉, 才有可能把問題解決掸冤,畢竟所有的解決方案都是針對問題的厘托。并發(fā)程序經(jīng)常出現(xiàn)的詭異問題看上去非常無厘頭,但是深究的話稿湿,無外乎就是直覺欺騙了我們铅匹,只要我們能夠深刻理解可見性、原子性饺藤、有序性在并發(fā)場景下的原理包斑,很多并發(fā)Bug 都是可以理解、可以診斷的策精。

在介紹可見性、原子性崇棠、有序性的時候咽袜,特意提到緩存導(dǎo)致的可見性問題,線程切換帶來的原子性問題枕稀,編譯優(yōu)化帶來的有序性問題询刹,其實緩存、線程萎坷、編譯優(yōu)化的目的和我們寫并發(fā)程序的目的是相同的凹联,都是提高程序性能。但是技術(shù)在解決一個問題的同時哆档,必然會帶來另一個問題蔽挠,所以在采用一項技術(shù)的同時,一定要清楚他帶來的問題是什么瓜浸,以及如何規(guī)避澳淑。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市插佛,隨后出現(xiàn)的幾起案子杠巡,更是在濱河造成了極大的恐慌,老刑警劉巖雇寇,帶你破解...
    沈念sama閱讀 222,681評論 6 517
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件氢拥,死亡現(xiàn)場離奇詭異,居然都是意外死亡锨侯,警方通過查閱死者的電腦和手機嫩海,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 95,205評論 3 399
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來囚痴,“玉大人出革,你說我怎么就攤上這事《伤希” “怎么了骂束?”我有些...
    開封第一講書人閱讀 169,421評論 0 362
  • 文/不壞的土叔 我叫張陵耳璧,是天一觀的道長。 經(jīng)常有香客問我展箱,道長旨枯,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 60,114評論 1 300
  • 正文 為了忘掉前任混驰,我火速辦了婚禮攀隔,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘栖榨。我一直安慰自己昆汹,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 69,116評論 6 398
  • 文/花漫 我一把揭開白布婴栽。 她就那樣靜靜地躺著满粗,像睡著了一般。 火紅的嫁衣襯著肌膚如雪愚争。 梳的紋絲不亂的頭發(fā)上映皆,一...
    開封第一講書人閱讀 52,713評論 1 312
  • 那天,我揣著相機與錄音轰枝,去河邊找鬼捅彻。 笑死,一個胖子當(dāng)著我的面吹牛鞍陨,可吹牛的內(nèi)容都是我干的步淹。 我是一名探鬼主播,決...
    沈念sama閱讀 41,170評論 3 422
  • 文/蒼蘭香墨 我猛地睜開眼诚撵,長吁一口氣:“原來是場噩夢啊……” “哼贤旷!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起砾脑,我...
    開封第一講書人閱讀 40,116評論 0 277
  • 序言:老撾萬榮一對情侶失蹤幼驶,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后韧衣,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體盅藻,經(jīng)...
    沈念sama閱讀 46,651評論 1 320
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,714評論 3 342
  • 正文 我和宋清朗相戀三年畅铭,在試婚紗的時候發(fā)現(xiàn)自己被綠了氏淑。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,865評論 1 353
  • 序言:一個原本活蹦亂跳的男人離奇死亡硕噩,死狀恐怖假残,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤辉懒,帶...
    沈念sama閱讀 36,527評論 5 351
  • 正文 年R本政府宣布阳惹,位于F島的核電站,受9級特大地震影響眶俩,放射性物質(zhì)發(fā)生泄漏莹汤。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 42,211評論 3 336
  • 文/蒙蒙 一颠印、第九天 我趴在偏房一處隱蔽的房頂上張望纲岭。 院中可真熱鬧,春花似錦线罕、人聲如沸止潮。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,699評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽喇闸。三九已至,卻和暖如春窿凤,著一層夾襖步出監(jiān)牢的瞬間仅偎,已是汗流浹背跨蟹。 一陣腳步聲響...
    開封第一講書人閱讀 33,814評論 1 274
  • 我被黑心中介騙來泰國打工雳殊, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人窗轩。 一個月前我還...
    沈念sama閱讀 49,299評論 3 379
  • 正文 我出身青樓夯秃,卻偏偏與公主長得像,于是被迫代替她去往敵國和親痢艺。 傳聞我的和親對象是個殘疾皇子仓洼,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,870評論 2 361

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