CLH lock queue的原理解釋及Java實現
目錄
背景
相信大部分人在看AQS的時候都能看到註釋上有這麼一段話:
The wait queue is a variant of a "CLH" (Craig, Landin, and Hagersten) lock queue.
為了更好的理解AQS中使用鎖的思想,所以決定先好好理解CLH鎖。
在網上能查到很多關於CLH的部落格,但不如我想象中的那麼全面,於是自己來整理一篇清晰點的。
原理解釋
CLH的作者:Craig, Landin, and Hagersten。
CLH lock is Craig, Landin, and Hagersten (CLH) locks, CLH lock is a spin lock, can ensure no hunger, provide fairness first come first service. The CLH lock is a scalable, high performance, fairness and spin lock based on the list, the application thread spin only on a local variable, it constantly polling the precursor state, if it is found that the pre release lock end spin.
我們能看到它是一個自旋鎖,能確保無飢餓性,提供先來先服務的公平性。同時它也是一種基於連結串列的可擴充套件、高效能、公平的自旋鎖,申請執行緒只在本地變數上自旋,它不斷輪詢前驅的狀態,如果發現前驅釋放了鎖就結束自旋。
這個演算法很妙的點在於,在一個CAS操作幫助下,所有等待獲取鎖的執行緒之下的節點輕鬆且正確地構建成了全域性佇列。等待中的執行緒正如佇列中的節點依次獲取鎖。
接下來就說一下這個演算法的Java實現。
Java程式碼實現
這裡面貼出的程式碼是主要流程程式碼,詳細程式碼在GitHub中。
定義QNode
CLH佇列中的節點QNode中含有一個locked欄位,該欄位若為true表示該執行緒需要獲取鎖,且不釋放鎖,為false表示執行緒釋放了鎖。
public class QNode {
volatile boolean locked;
}
定義Lock介面
public interface Lock {
void lock();
void unlock();
}
定義CLHLock
節點之間是通過隱形的連結串列相連,之所以叫隱形的連結串列是因為這些節點之間沒有明顯的next指標,而是通過myPred所指向的節點的變化情況來影響myNode的行為。CLHLock上還有一個尾指標,始終指向佇列的最後一個節點。
public class CLHLock implements Lock {
// 尾巴,是所有執行緒共有的一個。所有執行緒進來後,把自己設定為tail
private final AtomicReference<QNode> tail;
// 前驅節點,每個執行緒獨有一個。
private final ThreadLocal<QNode> myPred;
// 當前節點,表示自己,每個執行緒獨有一個。
private final ThreadLocal<QNode> myNode;
public CLHLock() {
this.tail = new AtomicReference<>(new QNode());
this.myNode = ThreadLocal.withInitial(QNode::new);
this.myPred = new ThreadLocal<>();
}
@Override
public void lock() {
// 獲取當前執行緒的代表節點
QNode node = myNode.get();
// 將自己的狀態設定為true表示獲取鎖。
node.locked = true;
// 將自己放在佇列的尾巴,並且返回以前的值。第一次進將獲取建構函式中的那個new QNode
QNode pred = tail.getAndSet(node);
// 把舊的節點放入前驅節點。
myPred.set(pred);
// 在等待前驅節點的locked域變為false,這是一個自旋等待的過程
while (pred.locked) {
}
// 列印myNode、myPred的資訊
peekNodeInfo();
}
@Override
public void unlock() {
// unlock. 獲取自己的node。把自己的locked設定為false。
QNode node = myNode.get();
node.locked = false;
myNode.set(myPred.get());
}
}
使用場景
public class KFC {
private final Lock lock = new CLHLock();
private int i = 0;
public void takeout() {
try {
lock.lock();
System.out.println(Thread.currentThread().getName() + ": 拿了第" + ++i + "份外賣");
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
執行程式碼
public static void main(String[] args) {
final KFC kfc = new KFC();
Executor executor = Executors.newFixedThreadPool(5);
for (int i = 1; i <= 35; i++) {
executor.execute(kfc::takeout);
}
}
程式碼輸出
為什麼輸出這麼多日誌後面有解釋。
thread-1 acquire lock success. myNode(QNode_2_locked:true), myPred(QNode_1_locked:false)
thread-1: 拿了第1份外賣
thread-2 acquire lock success. myNode(QNode_3_locked:true), myPred(QNode_2_locked:false)
thread-2: 拿了第2份外賣
thread-3 acquire lock success. myNode(QNode_4_locked:true), myPred(QNode_3_locked:false)
thread-3: 拿了第3份外賣
thread-4 acquire lock success. myNode(QNode_5_locked:true), myPred(QNode_4_locked:false)
thread-4: 拿了第4份外賣
thread-5 acquire lock success. myNode(QNode_6_locked:true), myPred(QNode_5_locked:false)
thread-5: 拿了第5份外賣
----------------------------------------------------------------------------------------
thread-1 acquire lock success. myNode(QNode_1_locked:true), myPred(QNode_6_locked:false)
thread-1: 拿了第6份外賣
thread-2 acquire lock success. myNode(QNode_2_locked:true), myPred(QNode_1_locked:false)
thread-2: 拿了第7份外賣
thread-3 acquire lock success. myNode(QNode_3_locked:true), myPred(QNode_2_locked:false)
thread-3: 拿了第8份外賣
thread-4 acquire lock success. myNode(QNode_4_locked:true), myPred(QNode_3_locked:false)
thread-4: 拿了第9份外賣
thread-5 acquire lock success. myNode(QNode_5_locked:true), myPred(QNode_4_locked:false)
thread-5: 拿了第10份外賣
----------------------------------------------------------------------------------------
thread-1 acquire lock success. myNode(QNode_6_locked:true), myPred(QNode_5_locked:false)
thread-1: 拿了第11份外賣
thread-2 acquire lock success. myNode(QNode_1_locked:true), myPred(QNode_6_locked:false)
thread-2: 拿了第12份外賣
thread-3 acquire lock success. myNode(QNode_2_locked:true), myPred(QNode_1_locked:false)
thread-3: 拿了第13份外賣
thread-4 acquire lock success. myNode(QNode_3_locked:true), myPred(QNode_2_locked:false)
thread-4: 拿了第14份外賣
thread-5 acquire lock success. myNode(QNode_4_locked:true), myPred(QNode_3_locked:false)
thread-5: 拿了第15份外賣
----------------------------------------------------------------------------------------
thread-1 acquire lock success. myNode(QNode_5_locked:true), myPred(QNode_4_locked:false)
thread-1: 拿了第16份外賣
thread-2 acquire lock success. myNode(QNode_6_locked:true), myPred(QNode_5_locked:false)
thread-2: 拿了第17份外賣
thread-3 acquire lock success. myNode(QNode_1_locked:true), myPred(QNode_6_locked:false)
thread-3: 拿了第18份外賣
thread-4 acquire lock success. myNode(QNode_2_locked:true), myPred(QNode_1_locked:false)
thread-4: 拿了第19份外賣
thread-5 acquire lock success. myNode(QNode_3_locked:true), myPred(QNode_2_locked:false)
thread-5: 拿了第20份外賣
----------------------------------------------------------------------------------------
thread-1 acquire lock success. myNode(QNode_4_locked:true), myPred(QNode_3_locked:false)
thread-1: 拿了第21份外賣
thread-2 acquire lock success. myNode(QNode_5_locked:true), myPred(QNode_4_locked:false)
thread-2: 拿了第22份外賣
thread-3 acquire lock success. myNode(QNode_6_locked:true), myPred(QNode_5_locked:false)
thread-3: 拿了第23份外賣
thread-4 acquire lock success. myNode(QNode_1_locked:true), myPred(QNode_6_locked:false)
thread-4: 拿了第24份外賣
thread-5 acquire lock success. myNode(QNode_2_locked:true), myPred(QNode_1_locked:false)
thread-5: 拿了第25份外賣
----------------------------------------------------------------------------------------
thread-1 acquire lock success. myNode(QNode_3_locked:true), myPred(QNode_2_locked:false)
thread-1: 拿了第26份外賣
thread-2 acquire lock success. myNode(QNode_4_locked:true), myPred(QNode_3_locked:false)
thread-2: 拿了第27份外賣
thread-3 acquire lock success. myNode(QNode_5_locked:true), myPred(QNode_4_locked:false)
thread-3: 拿了第28份外賣
thread-4 acquire lock success. myNode(QNode_6_locked:true), myPred(QNode_5_locked:false)
thread-4: 拿了第29份外賣
thread-5 acquire lock success. myNode(QNode_1_locked:true), myPred(QNode_6_locked:false)
thread-5: 拿了第30份外賣
----------------------------------------------------------------------------------------
thread-1 acquire lock success. myNode(QNode_2_locked:true), myPred(QNode_1_locked:false)
thread-1: 拿了第31份外賣
thread-2 acquire lock success. myNode(QNode_3_locked:true), myPred(QNode_2_locked:false)
thread-2: 拿了第32份外賣
thread-3 acquire lock success. myNode(QNode_4_locked:true), myPred(QNode_3_locked:false)
thread-3: 拿了第33份外賣
thread-4 acquire lock success. myNode(QNode_5_locked:true), myPred(QNode_4_locked:false)
thread-4: 拿了第34份外賣
thread-5 acquire lock success. myNode(QNode_6_locked:true), myPred(QNode_5_locked:false)
thread-5: 拿了第35份外賣
程式碼解釋
CLHLock的加鎖、釋放鎖過程
-
當一個執行緒需要獲取鎖時,會建立一個新的QNode,將其中的locked設定為true表示需要獲取鎖,然後執行緒對tail域呼叫getAndSet方法,使自己成為佇列的尾部,同時返回舊的尾部節點作為其前驅引用myPred,然後該執行緒就在前驅節點的locked欄位上自旋,直到前驅節點釋放鎖。
-
當一個執行緒需要釋放鎖時,將當前節點的locked域設定為false,同時回收前驅節點。如下圖所示,執行緒A需要獲取鎖,其myNode域為true,些時tail指向執行緒A的節點,然後執行緒B也加入到執行緒A後面,tail指向執行緒B的節點。然後執行緒A和B都在它的myPred域上旋轉,一量它的myPred節點的locked欄位變為false,它就可以獲取鎖掃行。明顯執行緒A的myPred locked域為false,此時執行緒A獲取到了鎖。
第一個使用CLHLock的執行緒自動獲取到鎖
初始狀態的tail的值是一個新的QNode,locked的值是預設的false。後面新加入的QNode在獲取鎖的時候都把locked置為true後放入尾節點併成為前驅節點。
為什麼使用ThreadLocal儲存myNode和myPred?
因為每個執行緒使用lock方法的時候,將QNode繫結到當前執行緒上,等unlock操作的時候還可以獲取到本執行緒之前呼叫lock方法裡建立的QNode物件。
為什麼tail要用AtomicReference修飾?
因為我們在把尾節點更新成當前節點並返回舊的尾節點作為前驅節點的時候,我們希望這個操作是原子性的,AtomicReference的getAndSet()方法正好能滿足我們的需求。
unlock中的set操作怎麼理解?
也就是這一段程式碼:
myNode.set(myPred.get());
它有以下幾點影響:
- 將當前node指向前驅node,lock方法中再也拿不到當前Node的引用了。這樣操作等於把當前node從連結串列頭部刪除(並不是被JVM回收,第二個執行緒的myPred還引用它)
- 當前執行緒若要在unlock之後再次拿鎖需重新排隊(每個執行緒自己都維護了兩個QNode,一個在釋放鎖的時候把當前node置為前驅node,另一個在lock方法的時候重新獲取尾node作為前驅node)
- 如果所有的任務都是由固定數量的執行緒池執行的話,你會看到所有的QNode的使用會形成一個環形連結串列(實際不是),在列印日誌中可看到,日誌“拿了第31份”和日誌“拿了第1份”的myNode和myPred一樣。
為什麼要有myPred,不用行不行?
也就是程式碼改成這樣:
public void lock() {
QNode node = myNode.get();
node.locked = true;
// spin on pre-node
QNode pred = tail.getAndSet(node);
while (pred.locked) {
}
}
public void unlock() {
QNode node = myNode.get();
node.locked = false;
}
答案肯定是不行啦。
假設有兩個執行緒:T1 & T2,T1持有鎖,T2等待T1釋放鎖。
這時候T1.node.locked為true,T2.node.locked也為true,tail變數指向T2.node,卻T2正在pred.locked自旋。這裡的pred也這是T1.node。
現在T1開始釋放鎖(設定T1.node.locked為false)並且在T2搶佔到鎖之前再次獲取鎖,此時T1.node.locked再次變成true,但是此時的尾節點是T2.node,所以T1只好等待T2釋放鎖。而T2也在等待T1釋放鎖,死鎖發生了。
CLH優缺點
CLH佇列鎖的優點是空間複雜度低(如果有n個執行緒,L個鎖,每個執行緒每次只獲取一個鎖,那麼需要的儲存空間是O(L+n),n個執行緒有n個myNode,L個鎖有L個tail)。
CLH的一種變體被應用在了JAVA併發框架中。唯一的缺點是在NUMA系統結構下效能很差,在這種系統結構下,每個執行緒有自己的記憶體,如果前趨結點的記憶體位置比較遠,自旋判斷前趨結點的locked域,效能將大打折扣,但是在SMP系統結構下該法還是非常有效的。一種解決NUMA系統結構的思路是MCS佇列鎖。
NUMA與SMP
SMP(Symmetric Multi-Processor),即對稱多處理器結構,指伺服器中多個CPU對稱工作,每個CPU訪問記憶體地址所需時間相同。其主要特徵是共享,包含對CPU,記憶體,I/O等進行共享。SMP的優點是能夠保證記憶體一致性,缺點是這些共享的資源很可能成為效能瓶頸,隨著CPU數量的增加,每個CPU都要訪問相同的記憶體資源,可能導致記憶體訪問衝突,可能會導致CPU資源的浪費。常用的PC機就屬於這種。
NUMA(Non-Uniform Memory Access)非一致儲存訪問,將CPU分為CPU模組,每個CPU模組由多個CPU組成,並且具有獨立的本地記憶體、I/O槽口等,模組之間可以通過互聯模組相互訪問,訪問本地記憶體的速度將遠遠高於訪問遠地記憶體(系統內其它節點的記憶體)的速度,這也是非一致儲存訪問NUMA的由來。NUMA優點是可以較好地解決原來SMP系統的擴充套件問題,缺點是由於訪問遠地記憶體的延時遠遠超過本地記憶體,因此當CPU數量增加時,系統性能無法線性增加。
最後
我的疑惑點都在上文敘述了,如果還有不清晰的地方,希望可以在評論區指出,謝謝