03|互斥鎖(上):解決原子性問題

在上一篇中我們提到,一個(gè)或者多個(gè)操作在CPU 執(zhí)行過程中不被中斷的特性,稱為“原子性”。 理解這個(gè)特性有助于你分析并發(fā)編程Bug 出現(xiàn)的原因, 例如可以利用它分析出long 型變量在 32 位機(jī)器上讀寫可能出現(xiàn)的詭異 Bug, 明明已經(jīng)把變量成功寫入了內(nèi)存,重新讀出來卻不是自己寫入的。

那原子性問題到底該如何解決呢?

你已經(jīng)知道,原子性問題的源頭是線程切換,如果能夠禁用線程切換不是就能夠解決這個(gè)問題了嘛?而操作系統(tǒng)做線程切換是依賴CPU 中斷的,所以禁止CPU 中斷就能夠禁止線程切換。

在早期單核CPU 時(shí)代,這個(gè) 方案的卻是可行的,而且 有很多應(yīng)用案例,但是并不適合多核場景。這里我們以 32 位 CPU 上執(zhí)行 long 型變量的寫操作為例來說明這個(gè)問題,long 型變量是 64 位,在 32 位 CPU 上執(zhí)行寫操作會(huì)被拆分成兩次寫操作(寫高 32 位和寫低 32 位,如下圖所示)。

img

在單核CPU 場景下,同一時(shí)刻只有一個(gè)線程執(zhí)行,禁止CPU 中斷,意味著操作系統(tǒng)不會(huì)重新調(diào)度線程,也就是禁止了線程切換,也就是CPU 使用權(quán)的線程可以不間斷的執(zhí)行,所以兩次寫操作一定是:要么被執(zhí)行,要么都沒有被執(zhí)行,具有原子性。

但是在多核場景下,同一時(shí)刻,有可能有兩個(gè)線程在同時(shí)執(zhí)行,一個(gè)線程執(zhí)行在CPU-1 上,一個(gè)線程執(zhí)行在CPU-2 上, 此時(shí)禁止CPU 中斷, 只須保證CPU 上的線程連續(xù)執(zhí)行, 并不能保證同一時(shí)刻只有一個(gè)線程執(zhí)行,如果這兩個(gè)線程同時(shí)寫long 型變量高 32 位的話,那就有可能出現(xiàn)我們開頭提及的詭異 Bug 了。

“同一時(shí)刻只有一個(gè)線程執(zhí)行” 這個(gè)條件非常重要,我們稱之為互斥,如果我們能保證對共享變量的修改是互斥的,那么,無論是單核CPU 還是多核CPU ,就能保證原子性了。

簡易鎖模型

當(dāng)談到互斥,相信聰明的你一定想到了那個(gè)殺手級解決方案:鎖,同時(shí)大腦中還會(huì)出現(xiàn)以下模型:

img

我們把一段需要互斥執(zhí)行的代碼稱為臨界區(qū)。線程在進(jìn)入臨界區(qū)前,首先嘗試加鎖如果成功,則進(jìn)入臨界區(qū),此時(shí)我們稱為這個(gè)線程持有鎖;否則就等待,直到持有鎖的線程解鎖,持有鎖的線程執(zhí)行完臨界區(qū)代碼后,執(zhí)行解鎖unlock()。

這個(gè) 過程非常像辦公室高峰搶占坑位,每個(gè)人都是進(jìn)坑鎖門(加鎖),出坑開門(解鎖),如廁這個(gè)事就是臨界區(qū)。很長時(shí)間里 我也事這么理解的,這樣理解本身沒有問題,但卻很容易讓我們忽視兩個(gè)非常重要的點(diǎn),我們的鎖是什么?我們保護(hù)的又是什么?

改進(jìn)后的模型

我們知道在現(xiàn)實(shí)世界里,鎖和鎖要保護(hù)的資源是有對應(yīng)關(guān)系的,比如你用你家的鎖保護(hù)你家的東西,我用我家的鎖保護(hù)我家的東西。在并發(fā)世界里,鎖和資源也應(yīng)該有這個(gè)對應(yīng)關(guān)系,但這個(gè)對應(yīng)關(guān)系在我們上面的模型中是沒有體現(xiàn)的,所以我們需要完善一下我們的模型。

img

首先,我們把臨界區(qū)要保護(hù)的資源標(biāo)注出來,如圖中臨界區(qū)里增加了一個(gè)元素:受保護(hù)的資源R,其次,我們要保護(hù)資源R就得為它創(chuàng)建一把鎖LR;最后針對這把鎖LR, 我們還得在進(jìn)出臨界區(qū)時(shí)添上加鎖操作和解鎖操作。另外,在鎖LR 和 受保護(hù)資源之間,我特地用了一條線做了關(guān)聯(lián),這個(gè)關(guān)聯(lián)關(guān)系非常重要。很多并發(fā)Bug 的出現(xiàn),都是因?yàn)榘阉雎粤恕H缓缶统霈F(xiàn)了鎖自家門來保護(hù)別家財(cái)產(chǎn)的事情,這樣的Bug 非常不好診斷,因?yàn)闈撘庾R里,我們已經(jīng)認(rèn)為已經(jīng)正確加鎖了。

Java 語言提供的鎖技術(shù):synchronized

鎖是一種通用的技術(shù)方案,Java 語言里提供的synchronized 關(guān)鍵字,就是鎖的一種實(shí)現(xiàn)。synchronized 關(guān)鍵字可以用來修飾方法,也可以用來修飾代碼塊,它的使用示例基本都是以下這個(gè)樣子:



class X {
  // 修飾非靜態(tài)方法
  synchronized void foo() {
    // 臨界區(qū)
  }
  // 修飾靜態(tài)方法
  synchronized static void bar() {
    // 臨界區(qū)
  }
  // 修飾代碼塊
  Object obj = new Object();
  void baz() {
    synchronized(obj) {
      // 臨界區(qū)
    }
  }
}  

看完之后 你可能覺得有點(diǎn)奇怪,這個(gè) 和我們上面提到的模型有點(diǎn)對不上啊,加鎖lock() 和解鎖 unlock() 在哪里呢? 其實(shí)這兩個(gè)操作都是有的,只是這兩個(gè)操作是被Java 默默加上去的。Java 編譯器會(huì)在 synchronized 修飾的方法或代碼塊前后自動(dòng)加上加鎖 lock() 和解鎖 unlock(),這樣做的好處就是加鎖 lock() 和解鎖 unlock() 一定是成對出現(xiàn)的, 畢竟忘記解鎖unlock() 可是個(gè)致命的 Bug(意味著其他線程只能死等下去了)。

那 synchronized 里的加鎖 lock() 和解鎖 unlock() 鎖定的對象在哪里呢? 上面的代碼我們看到只有修飾代碼塊的時(shí)候鎖定了一個(gè) obj 對象, 那修飾方法的時(shí)候鎖定的是什么呢?這個(gè)也是Java 的一條隱式規(guī)則:

當(dāng)修飾靜態(tài)方法的時(shí)候,鎖定的是當(dāng)前類的Class 對象,在上面的例子中就是Class X;

當(dāng)修飾非靜態(tài)方法時(shí),鎖定的是當(dāng)前示例對象this 。

對于上面的例子,synchronized 修飾靜態(tài)方法相當(dāng)于:

class X { 
    // 修飾靜態(tài)方法 
    synchronized(X.class) static void bar() { 
        // 臨界區(qū) 
        }
    }

修飾非靜態(tài)方法,相當(dāng)于:


class X {
  // 修飾非靜態(tài)方法
  synchronized(this) void foo() {
    // 臨界區(qū)
  }
}

用 synchronized 解決 count+=1 問題

相信你一定記得我們前面文章中提到的count+=1 存在并發(fā)的問題,現(xiàn)在我們可以嘗試用synchronized 來小試牛刀一把,代碼如下所示。SafeCalc 這個(gè)類有兩個(gè)方法,一個(gè)是get() 方法,用來獲得 value 的值; 另一個(gè)是 addOne() 方法,用來給 value 加 1, 并且 addOne() 方法我們用 synchronized 修飾。 那么我們使用的這兩兩個(gè)方法有沒有并發(fā)問題呢?


class SafeCalc {
  long value = 0L;
  long get() {
    return value;
  }
  synchronized void addOne() {
    value += 1;
  }
}

我們先來看看 addOne() 方法,首先可以肯定,被 synchronized 修飾后,無論是單核 CPU 還是多核 CPU,只有一個(gè)線程能夠執(zhí)行 addOne() 方法,所以一定能保證原子操作, 那是否有可見性問題呢?要回答這問題,就要重溫一下上一篇文章中提到的管程中鎖的規(guī)則。

管程中鎖的規(guī)則:對一個(gè)鎖的解鎖Happens-Before 于后續(xù)對這個(gè)鎖的加鎖。

管程就是我們這里的 synchronized(至于為什么叫管程,我們后面介紹),我們知道 synchronized 修飾的臨界區(qū)是互斥的,也就是說同一時(shí)刻只有一個(gè)線程執(zhí)行臨界區(qū)的代碼;而所謂“對一個(gè)鎖解鎖 Happens-Before 后續(xù)對這個(gè)鎖的加鎖”,指的是前一個(gè)線程的解鎖操作對后一個(gè)線程的加鎖操作可見,綜合 Happens-Before 的傳遞性原則,我們就能得出前一個(gè)線程在臨界區(qū)修改的共享變量(該操作在解鎖之前),對后續(xù)進(jìn)入臨界區(qū)(該操作在加鎖之后)的線程是可見的。

按照 這個(gè)規(guī)則,如果多個(gè)線程執(zhí)行addOne() 方法, 可見性是可以保證的,也就是說如果有1000 個(gè)線程執(zhí)行addOne() 方法, 最終結(jié)果一定是value 值增加了1000,看到這個(gè)結(jié)果,問題終于解決了。

但也許,你一不小心就忽視了get() 方法。 執(zhí)行 addOne() 方法后,value 的值對 get() 方法是可見的嗎?這個(gè)可見性是沒法保證的。 管程中鎖的規(guī)則就是,只保證后續(xù)對這個(gè)鎖的加鎖的可見性,而 get() 方法并沒有加鎖操作, 所以可見性沒法保證。那如何解決呢?很簡單,就是get() 方法也 synchronized 一下,完整的代碼如下所示。


class SafeCalc {
  long value = 0L;
  synchronized long get() {
    return value;
  }
  synchronized void addOne() {
    value += 1;
  }
}

上面的代碼轉(zhuǎn)換為我們提到的鎖模型,就是下面圖示的樣子,get() 方法和 addOne() 方法都需要訪問 value 這個(gè)受保護(hù)的資源,這個(gè)資源用 this 這把鎖來保護(hù)。線程要進(jìn)入臨界區(qū) get() 和 addOne(),必須先獲得 this 這把鎖,這樣 get() 和 addOne() 也是互斥的。

img

這個(gè)模型更像是現(xiàn)實(shí)世界里的球賽門票的管理,一個(gè)作為只允許一個(gè)人使用,這個(gè)座位就是“受保護(hù)的資源”,球賽的入口就是Java 類里的方法,而門票就是用來保護(hù)資源的“鎖”, Java 里的檢票工作是由synchronized 解決的。

鎖和受保護(hù)資源的關(guān)系

我們前面提到,受保護(hù)的資源和鎖之間的關(guān)聯(lián)關(guān)系非常重要,他們的關(guān)系是怎么樣的呢?一個(gè)合理的關(guān)系是:受保護(hù)資源和鎖之間的關(guān)聯(lián)關(guān)系是 N:1 的關(guān)系。 還拿前面球賽門票的管理來類比,就是一個(gè)座位,我們只能用一張票來保護(hù),如果多發(fā)了重復(fù)的票,那就要打架了?,F(xiàn)實(shí)世界里,我們可以用多把鎖來保護(hù)同一個(gè)資源,但在并發(fā)領(lǐng)域是不行的,并發(fā)領(lǐng)域的鎖和現(xiàn)實(shí)世界的鎖不是完全匹配的。不過倒是可以用同一把鎖來保護(hù)多個(gè)資源,這個(gè)對應(yīng)到現(xiàn)實(shí)世界就是我們所謂的“包場”了。

上面的那個(gè)例子我稍作改動(dòng),把value 改成靜態(tài)變量,把 addOne() 方法改成靜態(tài)方法,此時(shí) get() 方法和 addOne() 方法是否存在并發(fā)問題呢?

如果你仔細(xì)觀察,就會(huì)發(fā)現(xiàn)改動(dòng)后的代碼是用兩個(gè)鎖保護(hù)一個(gè)資源。這個(gè)受保護(hù)的資源就是靜態(tài)變量 value,兩個(gè)鎖分別是 this 和 SafeCalc.class。我們可以用下面這幅圖來形象描述這個(gè)關(guān)系。由于臨界區(qū) get() 和 addOne() 是用兩個(gè)鎖保護(hù)的,因此這兩個(gè)臨界區(qū)沒有互斥關(guān)系,臨界區(qū) addOne() 對 value 的修改對臨界區(qū) get() 也沒有可見性保證,這就導(dǎo)致并發(fā)問題了。

img

總結(jié)

互斥鎖,在并發(fā)領(lǐng)域知名度很高,只要有了并發(fā)問題,大家很容易想到的就是加鎖,加鎖能夠保證執(zhí)行臨界區(qū)代碼的互斥性,這樣理解雖然正確但是卻不能知道你真正用好互斥鎖,臨界區(qū)的代碼是操作受保護(hù)資源的路徑,類似于球場的入口,入口一定要檢票,也就是要加鎖,但不是隨便一把鎖就有效,必須深入分析受保護(hù)對象跟受保護(hù)資源的關(guān)系,綜合考慮受保護(hù)資源的訪問路徑,多方面考量才能用好互斥鎖。

synchronized 是 Java 在語言層面提供的互斥原語,其實(shí) Java 里面還有很多其他類型的鎖,但作為互斥鎖,原理都是相通的:鎖,一定有一個(gè)要鎖定的對象,至于這個(gè)鎖定的對象要保護(hù)的資源以及在哪里加鎖 / 解鎖,就屬于設(shè)計(jì)層面的事情了。

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

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

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