非阻塞演算法在併發上下文下是指一個演算法允許執行緒訪問共享狀態(亦或是協作和溝通)時不會阻塞到其他相關執行緒.更通俗的講,一個非阻塞演算法是指在該演算法中一個執行緒的停頓並不會引起其他相關執行緒的停頓.
為了更好的理解阻塞和非阻塞併發演算法之間的區別,我們會先講解阻塞演算法再講解非阻塞演算法.
阻塞併發演算法
一個阻塞的併發演算法需要包含以下兩個行為:
- 執行來自執行緒的請求操作
- 阻塞執行緒直到執行緒的請求操作能夠被安全的執行
許多演算法和併發資料結構都是阻塞的.例如,所有java.util.concurrent.BlockingQueue
介面的實現類都是阻塞的資料結構.如果一個執行緒嘗試插入元素到一個阻塞佇列中並且發現佇列已經沒有剩餘空間了,那麼插入執行緒會被阻塞直到阻塞佇列中有剩餘空間可以插入元素為止.
以下示例圖描述了一個阻塞演算法保證共享資料結構安全訪問的行為:
非阻塞併發演算法
一個非阻塞併發演算法需要包含以下兩個行為:
- 執行來自執行緒的請求操作
- 通知請求執行緒它的請求操作不能被執行
Java中同時包含了一些非阻塞資料結構.像AtomicBoolean, AtomicInteger, AtomicLong 和 AtomicReference都是非阻塞資料結構活生生的例子.
以下示例圖描述了一個非阻塞演算法保證共享資料結構安全訪問的行為:
非阻塞 vs 阻塞演算法
非阻塞和阻塞演算法之間的不同主要體現在上文提及演算法需要包含兩個行為中的第二個.換句話說,它們兩的不同之處主要體現在當請求操作不能被執行時做出的響應.
阻塞演算法會阻塞請求執行緒直到請求操作能夠被執行為止.而非阻塞演算法則是通知請求執行緒它的請求操作不能被執行.
在阻塞演算法中,一個執行緒可能會被阻塞到它的請求操作能夠被安全執行為止.通常其他執行緒請求操作的阻塞成就了第一個執行緒請求操作的安全執行.出於某些原因,如果應用中某些地方的其他執行緒發生停頓或阻塞,可能導致第一個執行緒的請求操作無法順利的執行,那麼第一個執行緒會陷入阻塞甚者是永久阻塞,直到有其他執行緒執行了必要的操作喚醒它為止.
例如,一個執行緒在嘗試插入元素到一個已經滿了的阻塞佇列中時,會被阻塞到其他執行緒取走佇列中的元素為止.如果出於某些原因,在應用中的某些地方負責取走佇列元素的執行緒發生了停頓或阻塞,那麼嘗試插入元素到阻塞佇列中的執行緒將會發生阻塞甚至是永久阻塞,直到最終有執行緒取走阻塞佇列中的一個元素為止.
非阻塞併發資料結構
在多執行緒系統中,執行緒通常需要通過一些不同型別的資料結構來進行通訊.這些資料結構可以是簡單的變數,也可以是像佇列,map,棧等這樣複雜的資料結構.為了確保正確性,多個執行緒併發訪問資料結構時,需要通過一些併發演算法來保障.由於這些併發演算法的保障才讓資料結構成為了併發資料結構.
如果一個演算法是通過阻塞的方式來保障併發資料結構的,我們稱為阻塞演算法.那麼這種資料結構我們稱為阻塞的併發資料結構.
如果一個演算法是通過非阻塞的方式來保障併發資料結構的,我們稱為非阻塞演算法.那麼這種資料結構我們稱為非阻塞的併發資料結構.
每種併發資料結構都是為特定的通訊場景設計的.至於需要使用哪種併發資料結構取決於你的通訊場景.我們在接下來的章節中會講解幾種非阻塞的併發資料結構.並且說明哪些情況下會用到這些資料結構.這些非阻塞併發資料結構工作原理的講解能夠給你一些思路怎麼設計和實現一個非阻塞資料結構.
Volatile 變數
Java中的volatile變數能夠讓變數始終是從主存中載入的.只要volatile變數被賦予新值就會立即被寫回到主存中去.這可以保證volatile變數最新的修改始終可以對執行在其他CPU上的執行緒可見.其他執行緒每次都會從主存中載入volatile變數而不是從它們執行CPU上的CPU快取中.
volatile變數是非阻塞的.對volatile變數值的寫入是一個原子操作.它不會被打斷.然而,對一個volatile變數的讀取更新寫入一系列操作並不是原子的.也就是說,下面這段程式碼在多執行緒環境下仍然會出現競態條件.
volatile myVar = 0;
...
int temp = myVar;
temp++;
myVar = temp;
複製程式碼
首先我們從主存中載入myVar變數然後賦予temp變數.然後對temp變數累加1.然後將變數temp重新賦予myVar,這意味著myVar變數會被立即寫回到主存中去.
如果兩個執行緒同時執行這段程式碼,同時載入變數myVar增加1並將變數值寫回到主存中.那麼存在一定的風險,本來對myVar變數的加法操作,現在只剩下一個了.(例如兩個執行緒都會讀取到變數值19,累加為20,再把20寫回).
或許你覺得你不會寫出像上面這樣的程式碼,但在實操中上面的程式碼等同於:
myVar++;
複製程式碼
當你執行這段程式碼時,myVar變數值會被載入到CPU暫存器或CPU快取中,進行一次加法操作,然後會將CPU暫存器或快取中的值寫回主存.
單個寫執行緒的場景
某些場景下,你只有一個執行緒寫入共享變數而有多個執行緒來讀取變數.當只有一個執行緒更新變數時,無論有多少個執行緒同時讀取變數都不會有競態條件出現.所以只要只有一個寫執行緒的情況下,你都可以使用volatile變數.
竟態條件只會在多個執行緒同時對一個共享變數做讀取更新和寫入一系列操作時才會發生.當你只有一個執行緒執行讀取更新寫入系列操作而有多個執行緒執行讀取操作時,竟態條件不會發生.
這是一個只有一個寫執行緒場景下的Counter例項,即使沒有使用同步裝置也不會有併發問題:
public class SingleWriterCounter {
private volatile long count = 0;
/**
* 只能讓一個相同的執行緒來呼叫該方法,
* 否則將會有竟態條件出現
*/
public void inc() {
this.count++;
}
/**
* 這個方法可以被多個讀取執行緒呼叫
* @return
*/
public long count() {
return this.count;
}
}
複製程式碼
當只有一個執行緒呼叫inc()的情況下,多個執行緒可以安全的訪問相同的Counter例項.當然相同的執行緒可以多次呼叫inc()方法,而不是隻呼叫一次.多個執行緒可以同時呼叫count()方法而不會產生竟態條件.
下圖描述了多個執行緒是如何訪問volatile修飾的count變數的:
基於Volatile變數構建更加高階的資料結構
我們可以聯合使用多個volatile變數來構建資料結構,每一個volatile變數都可以被一個執行緒寫入和多個執行緒讀取.每一個volatile變數可以被不同的執行緒寫入(但只能是相同的執行緒).利用這種資料結構中的volatile變數可以讓多個執行緒互相傳送資訊而不會發生阻塞.
這是一個可以讓兩個寫執行緒操作的counter物件示例:
public class DoubleWriterCounter {
private volatile long countA = 0;
private volatile long countB = 0;
/**
* 只能讓一個相同的寫執行緒來呼叫該方法,
* 否則會發生竟態條件
*/
public void incA() { this.countA++; }
/**
* 只能讓一個相同的寫執行緒來呼叫該方法,
* 否則會發生竟態條件
*/
public void incB() { this.countB++; }
/**
* 多個讀執行緒可以呼叫該方法
*/
public long countA() { return this.countA; }
/**
* 多個讀執行緒可以呼叫該方法
*/
public long countB() { return this.countB; }
}
複製程式碼
如你所見,DoubleWriterCounter有兩個volatile變數和兩對累加和讀取方法.只能有一個相同的執行緒呼叫incA()和一個相同的執行緒呼叫incB().但可以由不同的執行緒分別呼叫incA()和inB()方法.多個執行緒可以同時呼叫countA()和countB()方法,而不會出現竟態條件.
DoubleWriterCounter可以用作兩個執行緒之間互相通訊.兩個count計數器可以用於執行生產任務和消費任務.下圖描述了兩個執行緒通過上述資料結構進行通訊的場景:
聰明的讀者可以發現可以使用兩個SingleWriterCounter例項來達到DoubleWriterCounter一樣的效果.你甚至可以增加更多的SingleWriterCounter例項來實現更多執行緒之間互相通訊.
CAS樂觀鎖
如果你確實需要滿足多個執行緒同時寫入共享變數,那麼僅僅是使用volatile已經不夠用了.你需要特定型別的互斥訪問.下面利用了Java中的synchronized
同步塊來使用互斥訪問.
public class SynchronizedCounter {
long count = 0;
public void inc() {
synchronized(this) {
count++;
}
}
public long count() {
synchronized(this) {
return this.count;
}
}
}
複製程式碼
我們可以注意到inc()和count()方法都被包裹在synchronized
同步塊中了.這就是我們需要解決的問題,即不呼叫synchronized
同步塊和wait()/notify()方法等也能使上文提及程式碼變成執行緒安全.
我們可以使用Java中的原子變數AtomicLong來替換兩個synchronized
同步塊.下面給出的是AtomicLong版本的Counter物件:
public class AtomicCounter {
private AtomicLong count = new AtomicLong(0);
public void inc() {
boolean updated = false;
while(!updated){
long prevCount = this.count.get();
updated = this.count.compareAndSet(prevCount, prevCount + 1);
}
}
public long count() {
return this.count.get();
}
}
複製程式碼
這個版本與之前的synchronized
同步塊版本一樣也是執行緒安全的.這個版本有趣的地方是對inc()方法的更改.inc()方法中的程式碼不再包含在synchronized
同步塊中.而是更改為:
boolean updated = false;
while(!updated){
long prevCount = this.count.get();
updated = this.count.compareAndSet(prevCount, prevCount + 1);
}
複製程式碼
這些程式碼並不全是原子操作.這意味仍然有可能被兩個不同的執行緒呼叫,它們會同時執行long = prevCount = this.count.get();
語句,同時會取得更改前Counter中的count變數值.即使這樣這些程式碼仍然不會出現竟態條件.有趣吧!(筆者此刻的感受:當你對程式碼的底層知根知底時,即使是while(!updated)這樣看似枯燥無味的程式碼也會變得十分有趣.)
祕密就在於while迴圈中的第二行程式碼.compareAndSet()呼叫是原子的.這段呼叫會比較AtomicLong中的值是不是預期值,如果符合預期則設定AtomicLong為新的值.這裡的compareAndSet()方法直接使用CPU指令級的CAS.因此這裡不需要任何同步限制也不需要阻塞執行緒.省去了阻塞執行緒所需要的效能開銷.
想象一下AtomicLong此時內部值為20.現在同時有兩個執行緒讀取該值,並嘗試呼叫compareAndSet(20, 20 + 1)
.由於compareAndSet()是原子操作的,同一時間只能有一個執行緒執行這個方法.
第一個執行的執行緒會先比較AtomicLong的內部值是否為20(執行更改前的值),當符合預期時,執行緒會將AtomicLong的內部值更改為21(20 + 1).如果更改變數成功,會將updated置換為true並停止while迴圈.
現在第二個執行緒可以呼叫compareAndSet(20, 20 + 1)
了.當然現在AtomicLong的內部值已經不再是20了,此次呼叫將會失敗.AtomicLong的值不會被設定為21.updated變數此時會被置換為false,執行緒會在while迴圈上自旋一次,重新進入迴圈內部.這一次,如果沒有其他執行緒在呼叫compareAndSet()的話,它會讀取到AtomicLong內部值為21,並重新呼叫compareAndSet(21, 21 + 1)將AtomicLong更新為22.
為什麼稱它為樂觀鎖?
前文中提到的程式碼實現我們稱之為樂觀鎖.樂觀鎖跟傳統的鎖不太一樣,我們通常稱傳統的鎖為悲觀鎖.傳統的方式是通過synchronized
同步塊和和各種型別的鎖來鎖住共享記憶體的訪問.一個synchronized
同步塊或是鎖會導致執行緒發生阻塞.
樂觀鎖允許所有執行緒建立共享記憶體的副本而不會發生阻塞.執行緒會對它們自己所持有的副本進行更改,並嘗試將更改寫回到共享記憶體.如果沒有其他執行緒正在更改共享記憶體,那麼cas允許執行緒將它的更改寫回到共享記憶體中.如果已經有執行緒在更改共享記憶體,那麼會讀取一個新的拷貝,在新的拷貝上進行修改並重新嘗試將修改寫回到共享記憶體中.
我們稱之為樂觀鎖的原因是執行緒會獲取一份資料拷貝,並基於這份拷貝進行修改,基於樂觀的假定,此時沒有任何執行緒在修改共享記憶體.如果假定成真,那麼執行緒只需要繼續更改共享記憶體即可而不需要鎖定任何東西.如果假定不成真,那麼此次修改會被作廢,但也不會鎖定任何東西.
樂觀鎖,在對共享記憶體競爭率較低的情況下效能表現較好.如果對共享記憶體的競爭率比較高的話,執行緒會浪費大部分CPU執行時鐘來做無效的資料拷貝修改和失敗的共享記憶體寫入.但是如果你的共享資源比較龐大的話,你需要考慮將你的程式碼重新設計為對共享記憶體竟爭率較低的情況.
樂觀鎖是非阻塞的
上文示例的樂觀鎖是非阻塞的.如果一個執行緒出於未知的原因對共享記憶體資料進行拷貝和修改的過程中發生了阻塞將不會影響其他執行緒繼續訪問共享記憶體.
一個傳統鎖lock/unlock的情況.當一個執行緒取得鎖例項時會阻塞其他執行緒直到它釋放鎖為止.如果一個執行緒在取得鎖後執行臨界區程式碼的過程中發生阻塞,那麼會持有鎖一段時間甚至是永遠也不會釋放.這樣其他等待持有該鎖的執行緒也會永遠等待下去.
不可替換資料結構
一個簡單的cas樂觀鎖能夠在一次cas操作後將共享資料結構整個替換為新的.將整個資料結構替換為一個已經修改過的拷貝並不總是可行的.
想象一下如果共享資料結構是一個佇列.每個執行緒都會拷貝一份它自己的副本,並嘗試在副本上插入和取出元素以達到更改副本的效果.這裡可以通過AtomicReference來達到目的.拷貝引用物件即拷貝和修改佇列,並嘗試將AtomicReference的引用指向新建立的佇列.
然而,一個較大的資料結構需要花費更多的CPU執行時間和記憶體來進行拷貝.這會讓你的應用消耗大量的記憶體和執行時間來進行拷貝.這可能會影響應用的執行,特別是對資料結構競爭比較激烈的情況.更多的,如果執行緒花費拷貝和修改資料結構的時間越多,那麼就越有可能其他執行緒會在此期間已經對共享記憶體中的資料結構進行修改.如果執行緒拷貝的共享資料結構已經被修改過了,所有的執行緒都需要重新進行它們的拷貝和修改操作.這會對程式的執行效能和記憶體消耗造成更大的負面影響.
下一節中,將會介紹一個實現能夠被並行修改的非阻塞資料結構的方法.
該系列博文為筆者複習基礎所著譯文或理解後的產物,複習原文來自Jakob Jenkov所著Java Concurrency and Multithreading Tutorial