CLH 鎖隊列介紹
之前說到在學習 java 併發框架 AQS 的時候,其中的鎖隊列是在 CLH 鎖隊列的基礎上改進而來的。本文主要介紹 CLH 隊列鎖。
SMP 和 NUMA 簡要介紹
- SMP (Symmetric MultiProcessing) 對稱多處理是一種包括軟硬件的多核計算機架構,會有兩個或以上的相同的核心共享一塊主存,這些核心在操作系統中地位相同,可以訪問所有 I/O 設備。它的優點是內存等一些組件在覈心之間是共享的,一致性可以保證,但也正因爲內存一致性和共享對象在擴展性上就受到限制了 。
- NUMA (Non-uniform memory access) 非一致存儲訪問也是一種在多處理任務中使用的計算機存儲設計,每個核心有自己對應的本地內存,各核心之間通過互聯進行相互訪問。在這種架構下,對內存的訪問時間取決於內存地址與具體核心之間的相對地址。在 NUMA 中,核心訪問自己的本地內存比訪問非本地內存(另一個核心的本地內存或者核心之間的共享內存)要快。NUMA 的優勢侷限於一些特定任務,尤其是對於那些數據經常與特定任務或者用戶具有強關聯關係的服務器。解決了SMP的擴展問題,但當核心數量很多時,核心訪問非本地內存開銷很大,性能增長會減慢。
CLH 隊列鎖
簡介
在共享內存多處理器環境中,維護共享數據結構的邏輯一致性是一個普遍問題。將這些數據結構用鎖保護起來是維持這種一致性的標準技術。需要訪問數據的進程(下文中進程,線程,process 都可以看做一個概念,併發運行的程序單位)必須先獲取這個數據對應的鎖。獲取鎖之後,進程就獨佔了對這個數據的訪問權知道進行將鎖釋放。其對鎖進行請求的進程都必須進行等待。在持有鎖的進程釋放鎖之後,等待進程的其中一個會獲取這把鎖,同時其他進程接着等待。
等待進程的等待方式也分兩種:被動等待(讓出CPU)或者主動等待(自旋)。被動等待就是,進程註冊對鎖的請求然後阻塞,以便在它等待的時候其他進程可以利用處理器。當鎖被釋放時,已註冊的進程中的其中一個會獲取鎖。於是被選中的進程就會被解除阻塞在調度就緒時運行。主動等待就是,最典型的就是進程進入一個不斷重複檢測鎖狀態並且/或者嘗試獲取鎖對象的緊湊循環(tight loop)。一旦它獲取鎖對象,就進入受保護數據運行程序。
Anderson[2] 和 Mellor-Crummey 與 Scott[3]提供了對等待方式優缺點的討論。直觀上感覺自旋就是CPU在空轉,肯定比阻塞等待浪費性能,但實際上對於小任務,空轉時間很短,鎖很快就被釋放,與阻塞方式在進程狀態管理和切換不可忽略的系統開銷相比,自旋的代價比阻塞和恢復進程反而小。CLH 鎖就是自旋鎖的這種被動方式的實踐。
隊列自旋鎖的一個潛在優勢就是等待進程不在同一個內存地址上自旋。對於 NUMA 甚至可以達到每個進程都在處理的核心對應的本地內存上自旋,就這降低了各個核心和內存之間互聯互通的負載。尤其是在對於某一時間若干等待進程對鎖的高爭用情況,這點尤其重要。另外隊列自旋鎖還可以用 FIFO 隊列保證對進程的某種公平性和對避免飢餓的保證。
CLH 隊列鎖中的結構(FIFO 隊列)
沒有特殊情況我們面對的基本上都是 SMP 架構的系統,這裏就只分析最基礎的對於 FIFO 隊列的鎖,優先隊列鎖和對於 NUMA 系統的鎖不做解析。
- Request :對鎖的請求,包含一個 state 狀態(Granted 表示可以將鎖授權給他的監視進程,Pending 表示他的監視進程需掛起等待)
- Lock : 鎖對象,包含一個 tail 指針,初始化時指向一個 state = G 的請求
- Process :需要請求鎖的進程,包含兩個請求指針爲 myreq 和 watch ,myreq 指向當前進程對應的鎖請求,當進程爲獲取鎖或者獲取鎖但未釋放是,myreq.state = P;當進程釋放鎖時,myreq.state = G。watch 指向前驅進程的 myreq 請求,監聽其狀態變化。
結構圖如圖3(a)所示:
隊列鎖工作步驟如下:
- 初始狀態下,鎖對象 L.tail 指向一個狀態爲 G 的 Request R0;
- 接着某進程 P 請求鎖,P.myreq 指向一個狀態爲 P 的 Request,同時 Request tmp = L.tail,L.tail = P.myreq P.watch = tmp,就是將 P 插入到隊列的隊尾。之後 P 就在其前驅進程的 myreq 請求(也就是 P.watch)上自旋,直到 P.watch 的狀態變爲 G ,然後獲取鎖對象,運行程序,最後解鎖。
- 當進程 P 運行結束後進行解鎖操作,P.myreq 的 state 由 P 置爲 G ,並且將 P.myreq = P.watch(原因下文解釋)
圖3(b) 表示初始狀態下,有三個進程 P1,P2,P3,P1 已經將狀態置爲 P ,準備入隊。
圖3(c) 表示 進程 P1 已經入隊之後的狀態,此時 P1 可以獲取鎖。
圖4(a) 表示進程 P1,P2,P3 都已入隊。
圖4(b) 表示進程 P1 已經執行完畢並釋放鎖。P2 可以獲取鎖。
圖4(c) 表示進程 P1,P2,P3 都已運行完畢釋放鎖。隊列中無等待進程。
CLH 隊列鎖的 java 實現
State 類
/**
* ClassName:State <br/>
* Function:request狀態. <br/>
* Reason:request狀態. <br/>
* Date:2017/9/12 8:34 <br/>
*
* @since JDK 1.8
*/
public enum State {
/**
* PENDING: 該狀態的request對應的線程等待鎖.
*
* @since JDK 1.8
*/
PENDING,
/**
* GRANTED: 該狀態的request對應的線程可以獲取鎖.
*
* @since JDK 1.8
*/
GRANTED
}
Lock 類
import java.util.concurrent.atomic.AtomicReference;
/**
* ClassName:Lock <br/>
* Function:CLH隊列鎖的Lock對象. <br/>
* Reason:CLH隊列鎖的Lock對象. <br/>
* Date:2017/9/11 16:55 <br/>
*
* @since JDK 1.8
*/
public class Lock {
/**
* tail: tail指針指向最後一個加入隊列的process的myreq.
* 由於入隊操作涉及的幾個指針賦值邏輯上不可分割,否則會出現問題,
* 所以對request指針都採用原子類。
*
* @since JDK 1.8
*/
private AtomicReference<Request> tail;
Lock() {
//初始狀態,tail指向一個不屬於任何線程,狀態爲GRANTED的request
tail = new AtomicReference<Request>(new Request(State.GRANTED, null));
}
AtomicReference<Request> getTail() {
return tail;
}
public void setTail(AtomicReference<Request> tail) {
this.tail = tail;
}
}
Request 類
/**
* ClassName:Request <br/>
* Function:對鎖的請求. <br/>
* Reason:對鎖的請求. <br/>
* Date:2017/9/11 16:55 <br/>
*
* @since JDK 1.8
*/
public class Request {
/**
* myProcess: 發起該請求的線程,myreq對應的myProcess.
*
* @since JDK 1.8
*/
private Process myProcess;
/**
* state: 請求狀態,PENDING 表示對應線程等待,GRANTED 表示對應線程可以獲取鎖.
*
* @since JDK 1.8
*/
private State state;
Request(State state, Process myProcess) {
this.myProcess = myProcess;
this.state = state;
}
public Request(State state) {
this.state = state;
}
State getState() {
return state;
}
void setState(State state) {
this.state = state;
}
Process getMyProcess() {
return myProcess;
}
public void setMyProcess(Process myProcess) {
this.myProcess = myProcess;
}
}
Process 類
/**
* ClassName:Process <br/>
* Function:請求鎖的線程. <br/>
* Reason:請求鎖的線程. <br/>
* Date:2017/9/11 17:01 <br/>
*
* @since JDK 1.8
*/
public class Process implements Runnable {
/**
* clh: 線程請求的clh鎖.
*
* @since JDK 1.8
*/
private CLH clh;
/**
* name: 當前線程名,方便觀察,request對象與線程的對應關係.
*
* @since JDK 1.8
*/
private String name;
Process(String name, CLH clh) {
this.clh = clh;
this.name = name;
}
@Override
public void run() {
//1.請求鎖
clh.lock(this);
//2.程序性等待,獲取鎖之後等待2秒鐘,釋放鎖
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//釋放鎖
clh.unlock();
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
CLH 隊列鎖類
/**
* ClassName:CLH <br/>
* Function:CLH 隊列鎖. <br/>
* Reason:CLH 隊列鎖. <br/>
* Date:2017/9/11 16:59 <br/>
*
* @since JDK 1.8
*/
public class CLH {
/**
* lock: clh隊列鎖的lock對象.
* @since JDK 1.8
*/
private Lock lock;
/**
* watch: 當前線程自旋監視的目標Request,爲前驅process的myreq.
*
* @since JDK 1.8
*/
private ThreadLocal<Request> watch;
/**
* myreq: 當前線程持有的Request,當且僅當當前線程釋放鎖後更新爲GRANTED狀態,否則爲PENDING狀態.
*
* @since JDK 1.8
*/
private ThreadLocal<Request> myreq;
private CLH() {
this.lock = new Lock();
//初始化myreq對象,狀態爲PENDING,對應的線程爲當前的myProcess
this.myreq = ThreadLocal.withInitial(() -> new Request(State.PENDING));
//watch 初始化爲null,加入到隊列之後,會指向前驅process的myreq
this.watch = new ThreadLocal<Request>();
}
/**
* lock:請求鎖. <br/>
*/
public void lock(Process process) {
myreq.get().setState(State.PENDING);
myreq.get().setMyProcess(process);
Request tmp = lock.getTail().getAndSet(myreq.get());
watch.set(tmp);
boolean flag = true;
while (watch.get().getState() == State.PENDING) {
try {
if (watch.get().getMyProcess() != null) {
if (flag) {
System.out.println(" " + myreq.get().getMyProcess().getName() + " | is waiting for " + watch.get().getMyProcess().getName()
+ " | " + myreq.get().getState() + " | " + watch.get().getState() + " | " +
"added to queue | ");
} else {
System.out.println(" " + myreq.get().getMyProcess().getName() + " | is waiting for " + watch.get().getMyProcess().getName()
+ " | " + myreq.get().getState() + " | " + watch.get().getState() + " | " +
" |");
}
if (lock.getTail().get().equals(myreq.get())) {
System.out.println("— — — — — — — — — — — — — — — — — — — — — — — — |");
}
}
Thread.sleep(4000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = false;
}
if (flag) {
System.out.println(" " + myreq.get().getMyProcess().getName() + " | get lock | " + myreq.get().getState() +
" | " + watch.get().getState() + " | added to queue | ");
} else {
System.out.println(" " + myreq.get().getMyProcess().getName() + " | get lock | " + myreq.get().getState() +
" | " + watch.get().getState() + " | |");
}
if (lock.getTail().get().equals(myreq.get())) {
System.out.println("— — — — — — — — — — — — — — — — — — — — — — — — |");
}
}
/**
* unlock:釋放鎖. <br/>
*/
public void unlock() {
myreq.get().setState(State.GRANTED);
System.out.println(" " + myreq.get().getMyProcess().getName() + " | release lock | " + myreq.get().getState() +
" | X | remove from queue |");
if (lock.getTail().get().equals(myreq.get())) {
System.out.println("— — — — — — — — — — — — — — — — — — — — — — — — |");
}
// threadlocal 類型使用之後強制remove保證沒有內存溢出
myreq.remove();
myreq.set(watch.get());
//釋放鎖之後,watch字段不關心,置空,並且可以保證無內存溢出
watch.remove();
}
public Lock getLock() {
return lock;
}
public void setLock(Lock lock) {
this.lock = lock;
}
public ThreadLocal<Request> getWatch() {
return watch;
}
public void setWatch(ThreadLocal<Request> watch) {
this.watch = watch;
}
public ThreadLocal<Request> getMyreq() {
return myreq;
}
public void setMyreq(ThreadLocal<Request> myreq) {
this.myreq = myreq;
}
public static void main(String[] args) throws InterruptedException {
CLH clh = new CLH();
Process process1 = new Process("p1",clh);
Process process2 = new Process("p2",clh);
Process process3 = new Process("p3",clh);
Process process4 = new Process("p4",clh);
System.out.println(" 線程 | action | myreq | watch | queue |");
System.out.println("— — — — — — — — — — — — — — — — — — — — — — — — |");
new Thread(process1).start();
Thread.sleep(100);
new Thread(process2).start();
Thread.sleep(100);
new Thread(process3).start();
}
}
CLH 的 myreq 和 watch 採用 ThreadLocal 類型,之前我寫的對於ThreadLocal的介紹就是爲此服務的,這裏對於調用CLH的lock()方法的每個新的線程,由於是 ThreadLocal 類型,所以都會自動爲其分配新的 myreq 和 watch 對象,達到線程間數據隔離的目的。
main() 方法中首先創建CLH隊列鎖實例,之後創建了三個線程p1,p2,p3,每個線程的run()方法都會按序調用 clh.lock()和 clh.unlock(),添加運行狀態的日誌打印語句之後,執行結果如下:
結果非常清晰:
- 程序啓動時 p1 入隊,因爲 p1.watch 的 request 對象狀態爲 GRANTED 所以 p1 獲取鎖;
- p2 入隊, p2.watch 指向 p1.myreq 狀態爲 PENDING ,所以 p2 等待 p1 釋放鎖;
- p3 入隊, p3.watch 指向 p2.myreq 狀態爲 PENDING ,所以 p3 等待 p2 釋放鎖;
- p1 釋放鎖,p1.myreq 狀態更新爲 GRANTED ,p1.myreq = p1.watch,p1 出隊,p2.watch = p1.myreq 發現更新爲 GRANTED , p2 獲取鎖,p3.watch 指向 p2.myreq 狀態仍然爲 PENDING , p3 繼續等待 p2 釋放鎖;
- p2 釋放鎖,p2.myreq 狀態更新爲 GRANTED ,p2.myreq = p2.watch,p2 出隊,p3.watch = p2.myreq 發現更新爲 GRANTED , p3 獲取鎖,此時隊列中僅剩 p3 一個線程;
- 最後 p3 運行完業務後釋放鎖,p3 出隊,程序結束。
釋放鎖時候的問題
CLH 隊列鎖論文原文[1]中關於釋放鎖時候的具體過程有一句話,很重要:
Then the releaser alters its own Process record to take ownership of the Request that was granted to it by its predecessor.
以圖4(a)到圖4(b)的 P1 釋放鎖過程解釋,在 P1 釋放鎖,將鎖的控制權傳遞給 P2 之後,原本 P1.myreq = R1,P1.watch = R0 , 此時 P1.watch 會賦值給 P1.myreq 使得 P1.myreq 指向 R0。
那麼究竟爲什麼要在釋放鎖時多做這一步呢?如果不這麼做又有什麼後果呢?請看下圖:
- 圖6(a) 隊列中 P1,P2 已經入隊,此時 P1 獲取鎖;
- 圖6(b) P1 釋放鎖,P1.myreq 指向的 R1 狀態更新爲 GRANTED, 原本 process.myreq 指向 R1 ,釋放鎖之後 process.myreq 更新之後會指向 R0,但此時未執行 process.myreq = process.watch,所以 P1.myreq 還是指向 R1;
- 由於 P2.watch 指向 R1 狀態爲 GRANTED,於是 P2 準備獲取鎖,此時考慮以下情況:在 P1 釋放鎖之後 P2 獲取鎖之前,P1 再次調用 lock() ,P1 準備入隊,那麼 P1.myreq 指向的 R1 狀態又會變爲 PENDING ,P1 再次入隊,在 P1 入隊之後 P2 開始獲取鎖的自旋操作,此時隊列情況如圖6(c)所示。P1.watch 指向 R2 , P2.watch 指向 R1 狀態都爲 PENDING ,於是 P1 等待 P2 釋放鎖,P2 又等待 P1 釋放鎖,死鎖形成!
將上文的代碼按這個錯誤步驟改造之後, 按順序執行 P1.lock() P2.lock() P1.unlock() 在 P2.lock() 未獲取鎖再次調用 P1.lock(),結果如下:
這就是爲什麼釋放鎖的時候 process.myreq 一定要更爲爲 process.watch 的原因。當然你也可以重新創建一個新的 Request 對象賦給 process.myreq , 但有現成的 process.watch 對象可以廢物利用,何必要重新去創建一個呢。
CLH 算法的優勢
之後原文再次解釋:
A key idea in our algorithms, then, is to exchange ownership of Request records each time a process is granted the lock. When a lock is initialized, it is allocated a Request record that is marked as GRANTED. When a process is initialized, it is allocated a Request record, too. A side effect of this change is to remove the requirement for a Request record per lock per process in the Graunke and Thakkar scheme (O(L*P) Requests in a system with L locks and P processes). Our scheme uses just one Request per lock or process in the system (O(L+P) Requests). Besides saving space, it seems easier to manage our structures in a system where the number of locks and/or processes might not be known beforehand.
CLH 算法的核心思想是每次只要某個 process 釋放鎖(granted the lock)線程就會交換 Request 對象的擁有權。當某個 CLH 鎖初始化時,會爲其 tail 指針分配一個狀態爲 GRANTED 的 Request 對象。當一個 process 初始化時,也會爲其分配一個 myreq Request 對象。這個設計帶來的一個副作用就是此模式消除了 Graunke 和 Thakkar 模式(在此模式中對於一個有 L 把鎖和 P 個 process 的系統來說,空間複雜度爲O(L*P))對於每鎖每 process 都需要分配一個 Request 對象的強制要求。CLH 算法模式中系統只需要爲每把鎖或者每個 process 分配一個 Request 對象(空間複雜度 O(L+P))。本算法除了更加節省空間,對於那些我們事先不清楚到底有多少鎖和/或 process 的系統似乎也更容易對其數據和結構進行管理。
參考文獻
- Building FIFO and Priority-Queuing Spin Locks from Atomic Swap
- The Performance of Spin Lock Alternatives for SharedMemory Multiprocessors
- Algorithms for Scalable Synchronization on SharedMemory Multiprocessor
- Why CLH Lock need prev-Node in java