如何解決原子性問題

一個或者多個操作在 CPU 執(zhí)行的過程中不被中斷的特性,稱為“原子性”。

那并發(fā)下的原子性問題到底該如何解決呢?

原子性問題的源頭是線程切換,如果能夠禁用線程切換那不就能解決這個問題了嗎?而操作系統(tǒng)做線程切換是依賴 CPU 中斷的,所以禁止 CPU 發(fā)生中斷就能夠禁止線程切換。

在早期單核 CPU 時代,這個方案的確是可行的,而且也有很多應(yīng)用案例,但是并不適合多核場景。

例如,在32 位 CPU 上執(zhí)行 long 型變量的寫操作。long 型變量是 64 位,在 32 位 CPU 上執(zhí)行寫操作會被拆分成兩次寫操作(寫高 32 位和寫低 32 位,如下圖所示)。

圖片發(fā)自簡書App

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

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

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


簡易鎖模型

互斥,很容易想到了那個殺手級解決方案:鎖。如同以下模型:

圖片發(fā)自簡書App

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


這個過程非常像辦公室里高峰期搶占坑位,每個人都是進(jìn)坑鎖門(加鎖),出坑開門(解鎖),如廁這個事就是臨界區(qū)。

但這樣理解很容易忽視兩個非常非常重要的點:鎖的是什么?保護的又是什么?


改進(jìn)后的鎖模型

在現(xiàn)實世界里,鎖和鎖要保護的資源是有對應(yīng)關(guān)系的,比如你用你家的鎖保護你家的東西,我用我家的鎖保護我家的東西。

在并發(fā)編程世界里,鎖和資源也應(yīng)該有這個關(guān)系,但這個關(guān)系在上面的模型中是沒有體現(xiàn)的,所以需要完善一下模型。

圖片發(fā)自簡書App

首先要把臨界區(qū)要保護的資源標(biāo)注出來,如圖中臨界區(qū)里增加了一個元素:受保護的資源 R;其次,要保護資源 R 就得為它創(chuàng)建一把鎖 LR;最后,針對這把鎖 LR,還需在進(jìn)出臨界區(qū)時添上加鎖操作和解鎖操作。

另外,在鎖 LR 和受保護資源之間,有一條線做關(guān)聯(lián),這個關(guān)聯(lián)關(guān)系非常重要。很多并發(fā) Bug 的出現(xiàn)都是因為把它忽略了,然后就出現(xiàn)了類似鎖自家門來保護他家資產(chǎn)的事情,這樣的 Bug 非常不好診斷。


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

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

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


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

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

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

當(dāng)修飾非靜態(tài)方法的時候,鎖定的是當(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 這個類有兩個方法:一個是 get() 方法,用來獲得 value 的值;另一個是 addOne() 方法,用來給 value 加 1,并且 addOne() 方法我們用 synchronized 修飾。

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

對于addOne() 方法,首先可以肯定,被 synchronized 修飾后,無論是單核 CPU 還是多核 CPU,只有一個線程能夠執(zhí)行 addOne() 方法,所以一定能保證原子操作,那是否有可見性問題呢?

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

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

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

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


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


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

圖片發(fā)自簡書App

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


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

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

上面那個例子如果稍作改動,把 value 改成靜態(tài)變量,把 addOne() 方法改成靜態(tài)方法,此時 get() 方法和 addOne() 方法是否存在并發(fā)問題呢?


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


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

圖片發(fā)自簡書App

總結(jié)

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

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

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

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

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