“加鎖”是保護共享資源邏輯一致性的通用方案:在并發環境下,通過鎖使得在某個時刻只有一個線程或進程可以訪問該資源(以下統一用線程同時代表線程和進程)。其他線程必須等待鎖被釋放后,繼續通過競爭鎖來獲取資源的控制權,當其中一個線程勝出,其他線程只能繼續等待。
等待獲取鎖的方式有“被動”和“主動”兩種。被動模式指的是互相競爭的線程不再關心鎖是否已經被釋放,而是讓自身進入阻塞狀態,等待某種調度機制在鎖釋放時喚醒其中某個線程;主動模式通常使用自旋的方式實現:線程進入某種循環主動判斷鎖的狀態并嘗試獲得鎖,一旦某個線程獲得了鎖會立即退出循環。
關于兩種模式的優劣,Anderson曾提到[1]:
Even though spin-waiting wastes processor cycles, it is useful in two situations: if the critical is small, so that the expected wait is less than the cost of blocking and resuming the process, or if no other work is available
簡單來說,盡管自旋浪費了計算資源,但是在鎖資源很快就可以被釋放的前提下,自旋鎖的開銷,要低于線程的阻塞和喚起導致的系統開銷。軟件領域沒有所謂的銀彈,這兩種模式都有各自適用的場景,沒有絕對的孰優孰劣之分,本文介紹的是自旋鎖的一種實現機制。
在自旋模式下,如果單純依賴線程自身的搶占可能會導致其中某些線程始終無法獲得鎖,因此需要一個策略來保證所有爭奪鎖的線程可以以某種順序獲得鎖的控制權,根據線程請求鎖的順序或線程自身的優先級來作為排序依據是兩種常見的實現方式。
本文是閱讀java.util.concurrent
包下核心框架AbstractQueuedSynchronizer
源碼前的導讀,雖然作者Doug Lea表示[2]:
The resulting design is far enough removed from the original CLH structure to require explanation.
但還是有必要學習一下Craig在93年發表的原始版本[3]。論文的標題是“Building FIFO and Priority-Queuing Spin Locks from Atomic Swap”,有三個關鍵字FIFO
、Priority
和Atomic Swap
這里只分析FIFO(First-In-First-Out)
的實現方式,關于原子交換操作Atomic Swap
會在下文詳述。
CLH指的是該算法的三位作者:Craig、Landin和Hagersten名字首字母的縮寫。
模型定義
Request 對鎖的請求。包含1個標志位state
(其中G
表示鎖已經被釋放,P
表示需要等待鎖釋放)。
Lock 待競爭的鎖。包含一個tail
請求,在初始化時狀態為G
表示目前任意線程都可以對其加鎖。
Process 線程。包含2個請求,分別記為myreq
和watch
,含義是“我對于鎖的請求”及“我自旋的請求或我盯著的請求”。當某個線程自旋的請求為G
時代表改線程已經搶占到了鎖。
模型及算法圖示一(a)展示了模型的定義;(b)展示了1個包含1個鎖資源(L)和3個待競爭鎖的線程(P1 P2 P3)的系統初始化時的狀態。
算法描述
加鎖,當某個線程P1對鎖L發送加鎖請求時,首先該線程確保myreq
請求R1為PENDING
狀態;然后 原子的{將鎖L的tail
請求R0取出,將tail
設置為P1的myreq
請求R1},最后將watch
請求設置為R0。完成以上步驟后P1進入自旋,直到“我盯著的請求”狀態變為鎖釋放,偽代碼如下:
while(!P.watch.state!="GRANTED");
下面對于上述3個步驟再作以下說明:
步驟1的目的是為了告訴隨后請求加鎖的線程P2即原文中的successor
需要自旋等待。因為P2在請求加鎖后watch
的請求其實變成了P1的myreq
,在P1釋放鎖之前P2必須只能等待。
步驟2的核心是使用原子操作,即取出原始的tail
和設置某個待加鎖進程P的myreq
必須一起執行不能被中斷。否則考慮當P1請求加鎖時,剛取出了鎖L的tail
,此時P2中斷了上述操作并且首先完成了加鎖,隨后P1再次將鎖L的tail
設置為R1。那么步驟3完成后,P1和P2的watch
會同時指向R0,這意味著 兩個線程同時搶占到了鎖資源。因此原子操作是該算法實現的核心部分。
模型及算法圖示一(c)展示了線程P1請求加鎖后,該系統的狀態。模型及算法圖示二(a)展示了線程P1 P2 P3依次請求加鎖后,該系統的狀態。
解鎖,當某個線程P1需要釋放鎖時,首先將myreq
請求對應的狀態設置為GRANTED
(此時正在自旋的線程P2會立刻停止自旋同時獲得鎖的控制權),然后將watch
的請求丟給myreq
,這意味著該線程獲得了這個請求的控制權(可以在下次請求時使用)。
模型及算法圖示二(b)展示了線程P1釋放鎖后,該系統的狀態。模型及算法圖示二(c)展示了線程P1 P2 P3依次釋放鎖后,該系統的狀態。
算法實現
Craig在論文中給出了算法的Pascal偽代碼,Landin和Hagersten也給出了相應的C代碼[4]。
本文用Java實現了一個demo,加鎖和解鎖部分的代碼摘錄如下:
/**
* a Process(say p) marks a Request as PENDING and provides it to the lock for p's successor to spin on(watch) in
* exchange for the Request left there by p's predecessor. p then spins on the predecessor's request record
* until it's granted.
*/
public void lock() {
myreq.get().setStatus(PENDING);
watch.set(tail.getAndSet(myreq.get())); // atomically fetch old & store
while (watch.get().getStatus() == PENDING); // spin
System.out.println(Thread.currentThread().getName() + " acquire lock...");
}
/**
* the lock holder simply grants the Request(myreq) that it put on the queue in the first place,
* allowing the next requester to obtain the lock. Then the releaser alters its own Process record to take
* ownership of the Request that was granted to it by predecessor.
*/
public void unlock() {
myreq.get().setStatus(GRANTED);
myreq.set(watch.get());
System.out.print(Thread.currentThread().getName() + " release lock...");
}
為了線程間能及時看到watch
請求的更新,該變量中的status
字段需要使用volatile
關鍵字修飾,原子交換操作使用AtomicReference
的getAndSet()
方法完成。
完整的代碼托管在GitHub上[5]。歡迎拍磚~