併發編程中常見的鎖機制:樂觀鎖、悲觀鎖、CAS、自旋鎖、互斥鎖、讀寫鎖


樂觀鎖 VS 悲觀鎖

樂觀鎖和悲觀鎖故名思意,它們的區別就是做事的心態不同

悲觀鎖

悲觀鎖做事比較悲觀,它始終認爲共享資源在我們使用的時候會被其他線程修改,容易導致線程安全的問題,因此在訪問共享數據之前就要先加鎖,阻塞其他線程的訪問

常見的例子就是數據庫中的行鎖、表鎖、讀鎖、寫鎖等


樂觀鎖

樂觀鎖則於悲觀鎖相反,它則比較樂觀。它始終認爲多線程同時修改共享資源的概率較低,所以先不管三七二十一,改了再說。

樂觀鎖會直接對共享資源進行修改,但是在更新修改結果之前它會驗證這段時間有沒有其他線程對資源進行修改,如果沒有則提交更新,如果有的話則放棄本次操作。

由於樂觀鎖全程沒有進行加鎖,所以它也被稱爲無鎖編程通常以CAS操作+版本號機制實現


CAS

CAS機制

CAS是英文單詞Compare And Swap的縮寫,也就是比較和替換,這也正是它的核心。
CAS機制中用到了三個基本操作數,內存地址V,舊預期值A,新預期值B

當我們需要對一個變量進行修改時,會對內存地址V和舊預期值進行比較,如果兩者相同,則將舊預期值A替換成新預期值B。而如果不同,則將V中的值作爲舊預期值,繼續重複以上操作,即自旋

下面分別舉出成功和失敗的例子
此時內存地址中存儲的值爲9,線程1的舊預期值爲9,新預期值爲10,即我們要對裏面的值進行一個加一操作
在這裏插入圖片描述
此時舊預期值與V相同,將B與V交換
在這裏插入圖片描述
此時修改成功。

接着看看修改失敗的情況

此時V中的值爲9,線程1中的舊預期值爲9,想將V中的值修改爲10
在這裏插入圖片描述
當我們正要開始修改時,突然一個線程搶先更新數據,此時V的值變爲了14
在這裏插入圖片描述
由於此時A的值與V不同,我們就需要重新獲取V中的值,並計算出新的預期值
在這裏插入圖片描述

此時兩者相同,完成替換,V=15

從上面可以看出,CAS是樂觀鎖,它樂觀地認爲程序中的併發情況不那麼嚴重,所以讓線程不斷去嘗試更新。


ABA問題

所謂的ABA問題,就是將一個變量從A變爲了B,再從B變爲了A

假設我正在銀行中提款,此時我的賬戶中有1000元,我想從中取出500元,但是由於忽然的網絡波動,此時這個操作被重複了兩次,於是如下圖
在這裏插入圖片描述
此時我們只能執行第一個扣費,由於執行完後A != V,所以第二個線程即不斷自旋比較
在這裏插入圖片描述
此時正好舍友還了你幾年前借的500塊錢,你的金額又重新變爲了1000
在這裏插入圖片描述
這時線程2又給你扣了500,於是你取出了500塊錢,卻意外的扣了1000
在這裏插入圖片描述
那麼這個問題如何解決呢?我們可以引入版本號機制只有版本號相同的時候才能進行替換操作
在這裏插入圖片描述
當舍友給你轉賬的時候,由於數值發生了變化,版本號也得到了修改
在這裏插入圖片描述
此時雖然我們A和V中的數值相同,但是版本號不同,所以無法進行交換


CAS的優缺點

優點

  • 在併發量少或者對變量修改操作少的時候,效率會比傳統的加鎖高,因爲不涉及用戶態和內核態的切換。

缺點

  • 自旋進行比較和替換,當併發量大的時候可能會因爲變量一直更新而無法比較成功,而不斷地進行自旋,導致CPU壓力過大
  • CAS只能保證一個變量的原子性,並不能保證整個代碼塊的原子性,所以在處理多個變量的原子性更新的時候還是得加鎖。
  • 上述的ABA問題,可以通過引入版本號解決

互斥鎖 VS 自旋鎖

互斥鎖和自旋鎖是最底層的兩種鎖,大部分的高級鎖都是基於它們實現的,下面就來講講它們的區別

互斥鎖

互斥鎖是一種睡眠鎖,即當一個線程佔據了鎖之後,其他加鎖失敗的線程就會進行睡眠

例如我們有A、B兩個線程一同爭搶互斥鎖,當線程A成功搶到了互斥鎖時,該鎖就被他獨佔,在它釋放鎖之前,B的加鎖操作就會失敗,並且此時線程B將CPU讓給其他線程,而自己則被阻塞。

對於互斥鎖加鎖失敗後進入阻塞的現象,由操作系統的內核實現,如下圖

在這裏插入圖片描述

  • 當加鎖失敗時,內核會將線程置爲睡眠狀態,並將CPU切換給其他線程運行。此時從用戶態切換至內核態
  • 當鎖被釋放時,內核將線程至爲就緒狀態,然後在合適的時候喚醒線程獲取鎖,繼續執行業務。此時從內核態切換至用戶態

所以當互斥鎖加鎖失敗的時候,就伴隨着兩次上下文切換的開銷,而如果我們鎖定的時間較短,可能上下文切換的時間會比鎖定的時間還要長。

雖然互斥鎖的使用難度較低,但是考慮到上下文切換的開銷,在某些情況下我們還是會優先考慮自旋鎖。


自旋鎖

自旋鎖是基於CAS實現的,它在用戶態完成了加鎖和解鎖的操作,不會主動進行上下文的切換,因此它的開銷相比於互斥鎖也會少一些。

任何嘗試獲取該鎖的線程都將一直進行嘗試(即自旋),直到獲得該鎖,並且同一時間內只能由一個線程能夠獲得自旋鎖。

自旋鎖的本質其實就是對內存中一個整數的CAS操作,加鎖包含以下步驟

  1. 查看整數的值,如果爲0則說明鎖空閒,則執行第二步,如果爲1則說明鎖忙碌,執行第三步
  2. 將整數的值設爲1,當前線程進入臨界區中
  3. 繼續自旋檢查(回到第一步),直到整數的值爲0

從上面可以看出,對於獲取自旋鎖失敗的線程會一直處於忙等待的情況,不斷自旋直至獲取鎖資源,這也就要求我們必須要儘快釋放鎖,否則會佔用大量的CPU資源


對比及應用場景

由於自旋鎖和互斥鎖的失敗策略不同,自旋鎖採用忙等待的策略,而互斥鎖採用線程切換的策略,由於策略不同,它們的應用場景也不同。

由於自旋鎖不需要進行線程切換,所以它完全在用戶態下實現,加鎖開銷低,但是由於其採用忙等待的策略,對於短期加鎖來說沒問題,但是長期鎖定的時候就會導致CPU資源的大量消耗。並且由於它不會睡眠,所以它可以應用於中斷處理程序中。

互斥鎖採用線程切換的策略,當切換到別的線程的時候,原線程就會進入睡眠(阻塞)狀態,所以如果對睡眠有要求的情況可以考慮使用互斥鎖。並且由於睡眠不會佔用CPU資源,在長期加鎖中它比起自旋鎖有極大的優勢

具體的應用場景如下表格所示

需求 加鎖方式
低開銷加鎖 自旋鎖
短期鎖定 自旋鎖
長期鎖定 互斥鎖
中斷上下文中加鎖 自旋鎖
持有鎖需要睡眠 互斥鎖

讀寫鎖

讀寫鎖用於明確區分讀操作和寫操作的場景

其核心在於寫獨佔,讀共享

  • 讀鎖是一個共享鎖,當沒有線程持有寫鎖的時候,讀鎖就可以被多個線程併發的持有,大大的提高了共享資源的訪問效率。由於讀鎖只具備讀權限,因此不存在線程安全問題。
  • 寫鎖是一個獨佔鎖(排他鎖),當有任何一個線程持有寫鎖的時候,其餘線程獲取讀鎖和寫鎖的操作都會被阻塞

如下圖

讀鎖 寫鎖
讀鎖 兼容 不兼容
寫鎖 不兼容 不兼容

實現方式

根據實現方式的不同,讀寫鎖又分爲讀者優先、寫者優先、讀寫公平

讀者優先

讀者優先期望的是讀鎖能夠被更多的線程持有,以提高讀線程的併發性。

爲了做到這一點,它的規則如下:即使有線程申請了寫鎖,但是隻要還有讀者在讀取內容,就允許其他的讀線程繼續申請讀鎖,而將申請寫鎖的進程阻塞,直到沒有讀線程在讀時,才允許該線程寫

流程如下圖
在這裏插入圖片描述

寫者優先

而寫者優先則是優先服務於寫進程

假設此時有讀線程已經持有讀鎖,正在讀,而另一寫線程申請了寫鎖,寫線程被阻塞。爲了能夠保證寫者優先,此時後來的讀線程獲取讀鎖時則會被阻塞。而當先前的讀線程釋放讀鎖時,寫線程則進行寫操作,直到寫線程寫完之前,其他的線程都會被阻塞。

流程如下圖
在這裏插入圖片描述

讀寫公平

從上面兩個規則可以看出,讀寫優先都會導致另一方飢餓

  • 讀者優先時,對於讀進程併發性高,但是如果一直有都進程獲取讀鎖,就會導致寫進程永遠獲取不到寫鎖,此時就會導致寫進程飢餓。
  • 寫者優先時,雖然可以保證寫進程不會餓死,但是如果一直有寫進程獲取寫鎖,導致讀進程永遠獲取不到讀鎖,此時就會導致讀進程飢餓。

既然偏袒哪一方都會導致另一方被餓死,所以我們可以搞一個讀寫公平的規則

實現方式:用隊列把獲取鎖的線程排隊,不管是寫線程還是讀線程都按照先進先出的原則加鎖,這也讀線程仍然可以併發,也不會出現飢餓的情況。


讀寫鎖 VS 互斥鎖

性能方面來說,讀寫鎖的效率並不比互斥鎖高。讀鎖加鎖的開銷並不比互斥鎖小,因爲它要實時維護當前讀者的數量,在臨界區很小,鎖競爭不激烈的情況下,互斥鎖的效率往往更快

雖然讀寫鎖在速度上可能不如互斥鎖,但是併發性好,對於併發要求高的地方,應該優先考慮讀寫鎖。