一文搞懂高效并發編程:無鎖編程的秘密與實戰
無鎖編程是一種并發編程的技術,旨在避免使用傳統的鎖機制來保護共享數據。相比有鎖編程,無鎖編程可以提供更高的并發性能和可伸縮性。在無鎖編程中,線程或進程通過使用原子操作、CAS(Compare-and-Swap)等技術來實現對共享數據的訪問和修改,而不需要依賴互斥鎖。
無鎖編程常用于高并發場景,如網絡服務器、多線程應用程序等。它可以減少線程之間的競爭和阻塞,并且能夠充分利用多核處理器的計算能力。然而,由于無鎖編程對開發者要求較高,在設計和實現上也更加復雜,需要考慮線程安全性、內存模型等因素。
一、無鎖編程概述
1.1什么是無鎖編程
無鎖編程,即不使用鎖的情況下實現多線程之間的變量同步,也就是在沒有線程被阻塞的情況下實現變量的同步,所以也叫非阻塞同步(Non-blocking Synchronization),實現非阻塞同步的方案稱為“無鎖編程算法”。
為什么要非阻塞同步,使用lock實現線程同步有非常多缺點:
- 產生競爭時,線程被阻塞等待,無法做到線程實時響應
- dead lock
- live lock
- 優先級反轉
- 使用不當,造成性能下降
假設在不使用 lock 的情況下,實現變量同步,那就會避免非常多問題。盡管眼下來看,無鎖編程并不能替代 lock。
實現級別
非同步阻塞的實現分為三個級別:wait-free/lock-free/obstruction-free
(1)wait-free
- 最理想的模式,整個操作保證每一個線程在有限步驟下完畢
- 保證系統級吞吐(system-wide throughput)以及無線程饑餓
- 截止2011年,沒有多少詳細的實現。即使實現了,也須要依賴于詳細CPU
(2)lock-free
- 同意個別線程饑餓,但保證系統級吞吐。
- 確保至少有一個線程可以繼續運行。
- wait-free的算法必然也是lock-free的。
(3)obstruction-free
在不論什么時間點,一個線程被隔離為一個事務進行運行(其它線程suspended),而且在有限步驟內完畢。在運行過程中,一旦發現數據被改動(採用時間戳、版本),則回滾,也叫做樂觀鎖,即樂觀并發控制(OOC)。
事務的過程是:
- 讀取,并寫時間戳
- 準備寫入,版本號校驗
- 檢驗通過則寫入,檢驗不通過,則回滾
lock-free必然是obstruction-free的。
1.2為什么要無鎖?
首先是性能考慮。通信項目一般對性能有極致的追求,這是我們使用無鎖的重要原因。當然,無鎖算法如果實現的不好,性能可能還不如使用鎖,所以我們選擇比較擅長的數據結構和算法進行lock-free實現,比如Queue,對于比較復雜的數據結構和算法我們通過lock來控制,比如Map(雖然我們實現了無鎖Hash,但是大小是限定的,而Map是大小不限定的),對于性能數據,后續文章會給出無鎖和有鎖的對比。
次要是避免鎖的使用引起的錯誤和問題:
- 死鎖(dead lock):兩個以上線程互相等待
- 鎖護送(lock convoy):多個同優先級的線程反復競爭同一個鎖,搶占鎖失敗后強制上下文切換,引起性能下降
- 優先級反轉(priority inversion):低優先級線程擁有鎖時被中優先級的線程搶占,而高優先級的線程因為申請不到鎖被阻塞。
1.3如何無鎖?
在現代的 CPU 處理器上,很多操作已經被設計為原子的,比如對齊讀(Aligned Read)和對齊寫(Aligned Write)等。Read-Modify-Write(RMW)操作的設計讓執行更復雜的事務操作變成了原子操作,當有多個寫入者想對相同的內存進行修改時,保證一次只執行一個操作。
RMW 操作在不同的 CPU 家族中是通過不同的方式來支持的:
- x86/64 和 Itanium 架構通過 Compare-And-Swap (CAS) 方式來實現
- PowerPC、MIPS 和 ARM 架構通過 Load-Link/Store-Conditional (LL/SC) 方式來實現
在x64下進行實踐的,用的是CAS操作,CAS操作是lock-free技術的基礎,我們可以用下面的代碼來描述:
template <class T>
bool CAS(T* addr, T expected, T value)
{
if (*addr == expected)
{
*addr = value;
return true;
}
return false;
}
在GCC中,CAS操作如下所示:
bool __sync_bool_compare_and_swap (type *ptr, type oldval type newval, ...)
type __sync_val_compare_and_swap (type *ptr, type oldval type newval, ...)
這兩個函數提供原子的比較和交換,如果*ptr == oldval,就將newval寫入*ptr,第一個函數在相等并寫入的情況下返回true,第二個函數的內置行為和第一個函數相同,只是它返回操作之前的值。
后面的可擴展參數(...)用來指出哪些變量需要memory barrier,因為目前gcc實現的是full barrier,所以可以略掉這個參數,除過CAS操作,GCC還提供了其他一些原子操作,可以在無鎖算法中靈活使用:
type __sync_fetch_and_add (type *ptr, type value, ...)
type __sync_fetch_and_sub (type *ptr, type value, ...)
type __sync_fetch_and_or (type *ptr, type value, ...)
type __sync_fetch_and_and (type *ptr, type value, ...)
type __sync_fetch_and_xor (type *ptr, type value, ...)
type __sync_fetch_and_nand (type *ptr, type value, ...)
type __sync_add_and_fetch (type *ptr, type value, ...)
type __sync_sub_and_fetch (type *ptr, type value, ...)
type __sync_or_and_fetch (type *ptr, type value, ...)
type __sync_and_and_fetch (type *ptr, type value, ...)
type __sync_xor_and_fetch (type *ptr, type value, ...)
type __sync_nand_and_fetch (type *ptr, type value, ...)
_sync_*系列的built-in函數,用于提供加減和邏輯運算的原子操作。這兩組函數的區別在于第一組返回更新前的值,第二組返回更新后的值。
無鎖算法感觸最深的是復雜度的分解,比如多線程對于一個雙向鏈表的插入或刪除操作,如何能一步一步分解成一個一個串聯的原子操作,并能保證事務內存的一致性。
1.4從傳統到無鎖:編程范式的轉變
在并發編程的世界中,傳統的鎖編程曾是保障數據一致性和線程安全的中流砥柱 。就像在一場熱鬧的集市中,每個攤位是一個共享資源,而鎖就是攤位的鑰匙,線程則是想要使用攤位的商人。當一個商人拿到鑰匙(獲取鎖)后,就能在攤位上自由交易(訪問和修改資源),其他商人只能在一旁等待,直到鑰匙被歸還(鎖被釋放)。
這種方式雖然有效,但在多線程高并發的場景下,卻逐漸暴露出諸多局限性。
線程阻塞是最直觀的問題。當一個線程持有鎖時,其他試圖獲取同一鎖的線程就會被阻塞,進入等待狀態。這就好比多個商人都想使用同一個攤位,但只有一個人能拿到鑰匙,其他人只能干等著,白白浪費時間。在高并發系統中,大量線程的阻塞會導致上下文切換頻繁,極大地消耗系統資源,降低了程序的執行效率 。
死鎖隱患更是讓人頭疼。想象一下,兩個商人 A 和 B,A 拿著攤位 1 的鑰匙,卻想要使用攤位 2;B 拿著攤位 2 的鑰匙,卻想要使用攤位 1。雙方都不愿意放棄自己手中的鑰匙,又都在等待對方釋放鑰匙,結果就陷入了僵局,誰也無法繼續。在編程中,死鎖一旦發生,程序就會陷入停滯,無法正常運行,嚴重影響系統的穩定性 。
傳統鎖編程還會帶來較大的資源開銷。鎖的獲取、釋放以及維護鎖的狀態都需要消耗系統資源。隨著線程數量的增加,這種開銷會愈發明顯,成為系統性能提升的瓶頸 。
為了解決這些問題,無鎖編程應運而生,開啟了并發編程的新篇章。它就像是一場創新的集市改革,不再依賴鑰匙(鎖)來分配攤位,而是通過一種更巧妙的方式,讓商人們能夠更高效地使用攤位資源。無鎖編程摒棄了傳統的鎖機制,采用原子操作、CAS(Compare-And-Swap)算法等技術,實現了多線程對共享資源的無阻塞訪問,讓線程在訪問共享資源時無需等待鎖的釋放,大大提高了并發性能 。
二、無鎖編程的底層原理剖析
無鎖編程具體使用和考慮到的技術方法包括:原子操作(atomic operations), 內存柵欄(memory barriers), 內存順序沖突(memory order), 指令序列一致性(sequential consistency)和順ABA現象等等。
在這其中最基礎最重要的是操作的原子性或說原子操作。原子操作可以理解為在執行完畢之前不會被任何其它任務或事件中斷的一系列操作。原子操作是非阻塞編程最核心基本的部分,沒有原子操作的話,操作會因為中斷異常等各種原因引起數據狀態的不一致從而影響到程序的正確。
2.1原子操作:無鎖的基石
原子操作是無鎖編程的基石,它確保了指令執行的不可分割性 。就像在建造高樓時,每一塊基石都必須堅實穩固,原子操作對于無鎖編程也是如此,是構建整個體系的基礎。在現代處理器中,簡單類型的對齊讀寫操作通常是原子的,這意味著這些操作要么完全執行,要么完全不執行,不會出現部分執行的情況 。比如對一個int類型變量的賦值操作int a = 10;,在多線程環境下,這個賦值操作要么成功完成,將10賦值給a,要么就沒有執行,不會出現只賦了一半值的情況。
而 Read-Modify-Write 操作則更進一步,允許進行更復雜的原子事務性操作 。以經典的i++操作為例,它實際上包含了讀取i的值、對值進行加 1 修改、再將修改后的值寫回這三個步驟。在傳統的非原子操作中,這三個步驟可能會被線程調度打斷,導致數據不一致 。但在無鎖編程中,通過原子的 Read-Modify-Write 操作,這三個步驟被視為一個整體,不可分割,從而保證了操作的原子性和數據的一致性 。
假設線程 A 和線程 B 都要對共享變量i執行i++操作,如果沒有原子操作的保證,可能會出現線程 A 讀取i的值后,還沒來得及寫回,線程 B 就讀取了相同的值,最終導致i只增加了 1,而不是 2。但有了原子操作,就能確保兩個線程的i++操作都能正確執行,i最終會增加 2 。
2.2CAS 算法:核心機制解析
CAS(Compare-And-Swap)算法是無鎖編程的核心機制,它如同無鎖編程的 “大腦”,指揮著各個線程有序地訪問共享資源 。CAS 算法包含三個參數:V(要更新的變量)、E(預期值)、N(新值) 。其工作原理是:當且僅當變量 V 的值等于預期值 E 時,才會將變量 V 的值更新為新值 N;如果 V 的值和 E 的值不同,那就說明已經有其他線程對 V 進行了更新,當前線程則什么都不做,最后 CAS 返回當前 V 的真實值 。
在一個多線程的計數器場景中,假設有多個線程都要對計數器count進行加 1 操作 。每個線程在執行加 1 操作時,會先讀取count的當前值作為預期值 E,然后計算新值 N(即 E + 1),接著使用 CAS 算法嘗試將count的值更新為 N 。如果此時count的值仍然等于預期值 E,說明在讀取和嘗試更新的過程中沒有其他線程修改過count,那么更新操作就會成功;反之,如果count的值已經被其他線程修改,不等于預期值 E,更新操作就會失敗,線程會重新讀取count的當前值,再次嘗試 。通過這種不斷比較和交換的方式,CAS 算法實現了無鎖同步,避免了傳統鎖機制帶來的線程阻塞和性能開銷 。
2.3內存屏障與順序一致性
在多線程環境中,由于處理器的優化和指令重排序等原因,可能會導致內存操作的順序與程序代碼中編寫的順序不一致,從而引發數據一致性問題 。內存屏障就像是一個 “交通警察”,負責維護內存操作的順序,確保不同線程對內存操作的順序可見性 。內存屏障通過阻止處理器對其前后的內存操作進行重排序,使得在內存屏障之前的操作一定先于屏障之后的操作完成,從而維持了程序的順序一致性 。
在一個簡單的多線程賦值和讀取場景中,線程 A 先對共享變量x進行賦值操作x = 10;,然后對另一個共享變量y進行賦值操作y = 20;;線程 B 則先讀取y的值,再讀取x的值 。如果沒有內存屏障的保證,由于指令重排序,線程 A 的x = 10;和y = 20;操作可能會被重新排序,導致線程 B 讀取到的y的值是 20,但讀取到的x的值卻還是初始值 0,這就破壞了程序的順序一致性 。但通過在適當的位置插入內存屏障,就可以保證線程 A 的賦值操作按照代碼順序執行,線程 B 讀取到的x和y的值也是符合預期的,從而確保了程序的正確性 。
三、無鎖隊列
無鎖隊列是lock-free中最基本的數據結構,一般應用場景是資源分配,比如TimerId的分配,WorkerId的分配,上電內存初始塊數的申請等等。對于多線程用戶來說,無鎖隊列的入隊和出隊操作是線程安全的,不用再加鎖控制。
3.1無鎖隊列API
ErrCode initQueue(void** queue, U32 unitSize, U32 maxUnitNum);
ErrCode enQueue(void* queue, void* unit);
ErrCode deQueue(void* queue, void* unit);
U32 getQueueSize(void* queue);
BOOL isQueueEmpty(void* queue);
- initQueue初始化隊列:根據unitSize和maxUnitNum申請內存,并對內存進行初始化。
- enQueue入隊:從隊尾增加元素
- dequeue出隊:從隊頭刪除元素
- getQueueSize獲取隊列大小:返回隊列中的元素數
- isQueueEmpty隊列是否為空:true表示隊列為空,false表示隊列非空
API使用示例
我們以定時器Id的管理為例,了解一下無鎖隊列主要API的使用。
初始化:主線程調用
ErrCode ret = initQueue(&queue, sizeof(U32), MAX_TMCB_NUM);
if (ret != ERR_TIMER_SUCC)
{
ERR_LOG("lock free init_queue error: %u\n", ret);
return;
}
for (U32 timerId = 0; timerId < MAX_TMCB_NUM; timerId++)
{
ret = enQueue(queue, &timerId);
if (ret != ERR_TIMER_SUCC)
{
ERR_LOG("lock free enqueue error: %u\n", ret);
return;
}
}
timerId分配:多線程調用
U32 timerId;
ErrCode ret = deQueue(queue, &timerId);
if (ret != ERR_TIMER_SUCC)
{
ERR_LOG("deQueue failed!");
return INVALID_TIMER_ID;
}
timerId回收:多線程調用
ErrCode ret = enQueue(queue, &timerId);
if (ret != ERR_TIMER_SUCC)
{
ERR_LOG("enQueue failed!");
}
3.2核心實現
顯然,隊列操作的核心實現為入隊和出隊操作。
(1)入隊
入隊的關鍵點有下面幾點:
- 通過寫次數確保隊列元素數小于最大元素數
- 獲取next tail的位置
- 將新元素插入到隊尾
- 尾指針偏移
- 讀次數加1
①最大元素數校驗
#include <atomic>
template<typename T>
class LockFreeQueue {
private:
struct Node {
T data;
Node* next;
Node(const T& value) : data(value), next(nullptr) {}
};
std::atomic<Node*> head;
std::atomic<Node*> tail;
std::atomic<int> count;
public:
LockFreeQueue() : head(nullptr), tail(nullptr), count(0) {}
void push(const T& value) {
Node* newNode = new Node(value);
newNode->next = nullptr;
Node* prevTail = tail.exchange(newNode, std::memory_order_acq_rel);
if (prevTail != nullptr)
prevTail->next = newNode;
count.fetch_add(1, std::memory_order_release);
}
bool pop(T& value) {
if (head == nullptr)
return false;
Node* oldHead = head.load(std::memory_order_acquire);
Node* newHead = oldHead->next;
if (newHead == nullptr)
return false;
value = newHead->data;
head.store(newHead, std::memory_order_release);
delete oldHead;
count.fetch_sub(1, std::memory_order_release);
return true;
}
int size() const {
return count.load(std::memory_order_relaxed);
}
};
在入隊操作開始,就判斷寫次數是否超過隊列元素的最大值,如果已超過,則反饋隊列已滿的錯誤碼,否則通過CAS操作將寫次數加1。如果CAS操作失敗,說明有多個線程同時判斷了寫次數都小于隊列最大元素數,那么只有一個線程CAS操作成功,其他線程則需要重新做循環操作。
②獲取next tail的位置
do
{
do
{
nextTail = queueHead->nextTail;
} while (!__sync_bool_compare_and_swap(&queueHead->nextTail, nextTail, (nextTail + 1) % (queueHead->maxUnitNum + 1)));
unitHead = UNIT_HEAD(queue, nextTail);
} while (unitHead->hasUsed);
當前next tail的位置就是即將入隊的元素的目標位置,并通過CAS操作更新隊列頭中nextTail的值。如果CAS操作失敗,則說明其他線程也正在進行入隊操作,并比本線程快,則需要進行重新嘗試,從而更新next tail的值,確保該入隊元素的位置正確。
然而事情并沒有這么簡單,由于多線程的搶占,導致隊列并不是按下標大小依次鏈接起來的,所以要判斷一下next tail的位置是否正被占用。如果next tail的位置正被占用,則需要重新競爭next tail,直到next tail的位置是空閑的。
③將新元素插入到隊尾
初始化新元素:
unitHead->next = LIST_END;
memcpy(UNIT_DATA(queue, nextTail), unit, queueHead->unitSize);
插入到隊尾:
do
{
listTail = queueHead->listTail;
oldListTail = listTail;
unitHead = UNIT_HEAD(queue, listTail);
if ((++tryTimes) >= 3)
{
while (unitHead->next != LIST_END)
{
listTail = unitHead->next;
unitHead = UNIT_HEAD(queue, listTail);
}
}
} while (!__sync_bool_compare_and_swap(&unitHead->next, LIST_END, nextTail));
通過CAS操作判斷當前指針是否到達隊尾,如果到達隊尾,則將新元素連接到隊尾元素之后(next),否則進行追趕。
在這里,我們做了優化,當CAS操作連續失敗3次后,那么就直接通過next的遞推找到隊尾,這樣比CAS操作的效率高很多。我們在測試多線程的隊列操作時,出現過一個線程插入到tail為400多的時候,已有線程插入到tail為1000多的場景。
④尾指針偏移
do
{
__sync_val_compare_and_swap(&queueHead->listTail, oldListTail, nextTail);
oldListTail = queueHead->listTail;
unitHead = UNIT_HEAD(queue, oldListTail);
nextTail = unitHead->next;
} while (nextTail != LIST_END);
在多線程場景下,隊尾指針是動態變化的,當前尾可能不是新尾了,所以通過CAS操作更新隊尾。當CAS操作失敗時,說明隊尾已經被其他線程更新,此時不能將nextTail賦值給隊尾。
⑤讀次數加1
__sync_fetch_and_add(&queueHead->readCount, 1);
寫次數加1是為了保證隊列元素的數不能超過最大元素數,而讀次數加1是為了確保不能從空隊列出隊。
(2)出隊
出隊的關鍵點有下面幾點:
- 通過讀次數確保不能從空隊列出隊
- 頭指針偏移
- dummy頭指針
- 寫次數減1
①空隊列校驗
do
{
readCount = queueHead->readCount;
if (readCount == 0) return ERR_QUEUE_HAS_EMPTY;
} while (!__sync_bool_compare_and_swap(&queueHead->readCount, readCount, readCount - 1));
讀次數為0,說明隊列為空,否則通過CAS操作將讀次數減1。如果CAS操作失敗,說明其他線程已更新讀次數成功,必須重試,直到成功。
②頭指針偏移
U32 readCount;
do
{
listHead = queueHead->listHead;
unitHead = UNIT_HEAD(queue, listHead);
} while (!__sync_bool_compare_and_swap(&queueHead->listHead, listHead, unitHead->next));
如果CAS操作失敗,說明隊頭指針已經在其他線程的操作下進行了偏移,所以要重試,直到成功。
③dummy頭指針
我們可以看出,頭元素為head->next,這就是說隊列的第一個元素都是基于head->next而不是head,這樣設計是有原因的。
考慮一個邊界條件:在隊列只有一個元素條件下,如果head和tail指針指向同一個結點,這樣入隊操作和出隊操作本身就需要互斥了。通過引入一個dummy頭指針來解決這個問題,如下圖所示:
圖片
④寫次數減1
__sync_fetch_and_sub(&queueHead->writeCount, 1);
出隊操作結束前,要將寫次數減1,以便入隊操作能成功。
四、無鎖編程技術
事實證明,當你試圖滿足無鎖編程的無阻塞條件時,會出現一系列技術:原子操作、內存屏障、避免ABA問題,等等。從這里開始,事情很快變得棘手了。
4.1原子的 Read-Modify-Write 操作
所謂原子操作是指,通過一種看起來不可分割的方式來操作內存:線程無法看到原子操作的中間過程。在現代的處理器上,有很多操作本身就是的原子的。例如,對簡單類型的對齊的讀和寫通常就是原子的。
Read-Modify-Write(RMW)操作更進一步,它允許你按照原子的方式,操作更復雜的事務。當一個無鎖的算法必須支持多個寫入者時,原子操作會尤其有用,因為多個線程試圖在同一個地址上進行RMW時,它們會按“一次一個”的方式排隊執行這些操作。我已經在我的博客中涉及了RMW操作了,如實現 輕量級互斥量、遞歸互斥量 和 輕量級日志系統。
RMW 操作的例子包括:Win32上 的 _InterlockedIncrement,iOS 上的 OSAtomicAdd32 以及 C++11 中的 std::atomic<int>::fetch_add。需要注意的是,C++11 的原子標準不保證其在每個平臺上的實現都是無鎖的,因此最好要清楚你的平臺和工具鏈的能力。你可以調用 std::atomic<>::is_lock_free 來確認一下。
不同的 CPU 系列支持 RMW 的方式也是不同的。例如,PowerPC 和 ARM 提供 load-link/store-conditional 指令,這實際上是允許你實現你自定義的底層 RMW 操作。常用的 RMW 操作就已經足夠了。
如上面流程圖所述,即使在單處理器系統上,原子的 RMW 操作也是無鎖編程的必要部分。沒有原子性的話,一個線程的事務會被中途打斷,這可能會導致一個錯誤的狀態。
4.2Compare-And-Swap 循環
或許,最常討論的 RMW 操作是 compare-and-swap (CAS)。在Win32上,CAS 是通過如 _InterlockedCompareExchange 等一系列指令來提供的。通常,程序員會在一個事務中使用 CAS 循環。這個模式通常包括:復制一個共享的變量至本地變量,做一些特定的工作(改動),再試圖使用 CAS 發布這些改動。
void LockFreeQueue::push(Node* newHead)
{
for (;;)
{
// Copy a shared variable (m_Head) to a local.
Node* oldHead = m_Head;
// Do some speculative work, not yet visible to other threads.
newHead->next = oldHead;
// Next, attempt to publish our changes to the shared variable.
// If the shared variable hasn't changed, the CAS succeeds and we return.
// Otherwise, repeat.
if (_InterlockedCompareExchange(&m_Head, newHead, oldHead) == oldHead)
return;
}
}
這樣的循環仍然有資格作為無鎖的,因為如果一個線程檢測失敗,意味著有其它線程成功—盡管某些架構提供一個 較弱的CAS變種 。無論何時實現一個CAS循環,都必須十分小心地避免 ABA 問題。
4.3順序一致性
順序一致性(Sequential consistency)意味著,所有線程就內存操作的順序達成一致。這個順序是和操作在程序源代碼中的順序是一致的。
一種實現順序一致性的簡單(但顯然不切實際)的方法是禁用編譯器優化,并強制所有線程在單個處理器上運行。即使線程在任意時間被搶占和調度,處理器也永遠不會看到自己的內存影響。
某些編程語言甚至可以為在多處理器環境中運行的優化代碼提供順序一致性。在C ++ 11中,可以將所有共享變量聲明為具有默認內存排序約束的C ++ 11原子類型。
std::atomic X(0), Y(0);
int r1, r2;
void thread1()
{
X.store(1);
r1 = Y.load();
}
void thread2()
{
Y.store(1);
r2 = X.load();
}
因為 C ++ 11 原子類型保證順序一致性,所以結果 r1 = r2 = 0 是不可能的。為此,編譯器會在后臺輸出其他指令——通常是 內存圍欄 和/或 RMW 操作。與程序員直接處理內存排序的指令相比,那些額外的指令可能會使實現效率降低。
4.4內存保序
正如前面流程圖所建議的那樣,任何時候做多核(或者任何對稱多處理器)的無鎖編程,如果你的環境不能保證順序一致性,你都必須考慮如何來防止 內存重新排序。
在當今的架構中,增強內存保序性的工具通常分為三類,它們既防止 編譯器重新排序 又防止 處理器重新排序:
- 一個輕型的同步或屏障指令
- 一個完全的內存屏障指令
- 提供獲取或釋放語義的內存操作
獲取語義可防止按照程序順序對其進行操作的內存重新排序,而釋放語義則可防止對其進行操作前的內存重新排序。這些語義尤其適用于存在生產者/消費者關系的情況,其中一個線程發布一些信息,而另一個線程讀取它。
不同的處理器有不同的內存模型
在內存重新排序方面,不同的CPU系列具有不同的習慣。每個CPU供應商都記錄了這些規則,并嚴格遵循了硬件。例如,PowerPC 和 ARM 處理器可以相對于指令本身更改內存存儲的順序,但是通常,英特爾和 AMD 的 x86 / 64 系列處理器不會。
有人傾向于將這些特定于平臺的細節抽象出來,尤其是C ++ 11為我們提供了編寫可移植的無鎖代碼的標準方法。但是目前,我認為大多數無鎖程序員至少對平臺差異有所了解。如果需要記住一個關鍵的區別,那就是在x86 / 64指令級別上,每次從內存中加載都會獲取語義,并且每次存儲到內存都將提供釋放語義–至少對于非SSE指令和非寫組合內存 。因此,過去通常會編寫可在x86 / 64上運行但 在其他處理器上無法運行 的無鎖代碼。
如果你對硬件如何處理內存重新排序的細節感興趣,我建議看看《Is Pararllel Programming Hard》的附錄C。無論如何,請記住,由于編譯器對指令的重新排序,也會發生內存重新排序。
五、無鎖編程的優勢與應用場景
5.1性能飛躍:高并發下的卓越表現
在高并發場景中,無鎖編程的性能優勢格外顯著。以一個簡單的計數器為例,在傳統鎖編程中,每次對計數器的更新都需要獲取鎖,這會導致大量線程等待,造成上下文切換頻繁 。假設一個應用中有 100 個線程同時對計數器進行 10000 次加 1 操作,使用傳統的synchronized關鍵字實現的鎖編程,在高并發情況下,線程的阻塞和上下文切換會使得執行時間明顯增加 。而采用無鎖編程,利用AtomicInteger類提供的原子操作,線程無需等待鎖的釋放,能夠無阻塞地執行加 1 操作 。
通過實際測試,在同樣的硬件環境和線程數量下,無鎖編程實現的計數器操作,其執行時間相較于傳統鎖編程大幅縮短,系統吞吐量顯著提升 。這是因為無鎖編程減少了鎖競爭帶來的開銷,讓 CPU 能夠更專注于實際的業務計算,大大提高了 CPU 的利用率 。在一些對吞吐量要求極高的互聯網應用中,如電商平臺的訂單計數、社交平臺的點贊計數等,無鎖編程能夠輕松應對高并發的讀寫操作,保證系統的高效運行 。
5.2避免死鎖:穩定性的保障
死鎖是傳統鎖編程中一個令人頭疼的問題,它會導致程序陷入無限期的等待,無法繼續執行 。在一個多線程的文件讀寫系統中,線程 A 持有文件讀取鎖,試圖獲取文件寫入鎖;而線程 B 持有文件寫入鎖,試圖獲取文件讀取鎖,雙方都不釋放自己持有的鎖,就會陷入死鎖狀態 。而無鎖編程從根本上避免了死鎖的產生,因為它不依賴鎖機制來實現線程同步 。
無鎖編程使用原子操作和 CAS 算法,線程在訪問共享資源時,無需等待鎖的獲取,而是通過不斷嘗試原子操作來更新資源 。這種方式使得線程之間不會因為等待鎖而相互阻塞,從而杜絕了死鎖的發生 。在復雜的分布式系統中,無鎖編程的這一優勢尤為重要,它能夠確保系統在高并發和復雜業務邏輯下的穩定性,減少因死鎖導致的系統故障和數據不一致問題 。
5.3應用領域大掃描
無鎖編程在眾多領域都有著廣泛的應用,其獨特的優勢使其成為解決高并發和性能問題的有力工具 。
在高性能計算領域,無鎖編程能夠充分發揮多核處理器的并行計算能力,提高計算效率 。在科學計算中,如氣象模擬、分子動力學模擬等,需要處理大量的數據和復雜的計算任務,無鎖編程可以減少線程之間的同步開銷,讓各個核心能夠高效地協同工作,加速計算過程 。
數據庫系統也常常運用無鎖編程來提升并發性能 。在多線程并發訪問數據庫時,傳統的鎖機制會導致大量線程阻塞,降低數據庫的讀寫性能 。而無鎖編程通過原子操作和無鎖數據結構,實現了對數據庫資源的高效訪問,提高了數據庫的并發處理能力,能夠更好地滿足高并發業務的需求 。
緩存系統同樣依賴無鎖編程來實現快速的數據讀寫 。以Redis為例,它在處理大量并發請求時,采用了無鎖的數據結構和原子操作,確保了數據的一致性和高效讀寫,使得緩存系統能夠快速響應請求,減輕后端數據庫的壓力 。
在消息傳遞和事件驅動系統中,無鎖編程能夠避免線程阻塞,提高系統的響應速度和可伸縮性 。在分布式消息隊列中,多個生產者和消費者并發地進行消息的發送和接收,無鎖編程保證了消息的高效傳遞和處理,使得系統能夠快速響應各種事件,滿足實時性要求較高的應用場景 。
六、無鎖編程實踐困境
6.1編程復雜度提升
無鎖編程雖然帶來了性能上的優勢,但也極大地提升了編程的復雜度,對開發者提出了更高的要求 。在無鎖編程中,開發者需要深入理解底層硬件和操作系統的并發機制,這是因為無鎖編程依賴于原子操作、內存屏障等底層技術來實現線程安全,而這些技術與硬件的特性密切相關 。不同的處理器架構可能對原子操作的支持和實現方式有所不同,開發者需要清楚地了解這些差異,才能正確地編寫無鎖代碼 。
無鎖編程的代碼邏輯往往更加復雜。以實現一個無鎖隊列為例,在傳統的鎖編程中,使用鎖來保證隊列操作的線程安全,代碼邏輯相對簡單,只需要在關鍵操作前后加鎖和解鎖即可 。但在無鎖編程中,需要使用 CAS 算法等技術來實現無鎖的入隊和出隊操作,這涉及到復雜的指針操作和狀態判斷 。每次入隊操作都需要通過 CAS 算法嘗試將新節點插入到隊列尾部,如果插入失敗,還需要根據失敗的原因進行相應的處理,可能需要重新獲取隊列的狀態并再次嘗試插入 。這種復雜的邏輯使得代碼的編寫和維護難度大幅增加,容易出現邏輯錯誤,對開發者的編程能力和經驗是一個巨大的挑戰 。
6.2ABA 問題及解決方案
ABA 問題是無鎖編程中一個較為棘手的問題,它可能導致程序出現意想不到的錯誤 。假設一個共享變量的初始值為 A,線程 1 讀取到這個值 A 后,由于某些原因被阻塞 。在這期間,線程 2 將變量的值從 A 改為 B,然后又改回 A 。當線程 1 恢復執行時,它通過 CAS 操作檢查變量的值,發現仍然是 A,就會認為變量沒有被修改過,從而繼續執行后續的操作 。但實際上,變量已經經歷了兩次變化,這可能會導致程序的邏輯出現錯誤 。
在一個無鎖的鏈表結構中,當進行節點刪除操作時就可能出現 ABA 問題 。假設鏈表的初始狀態是 A -> B -> C,線程 1 想要刪除節點 A,它讀取到節點 A 的指針,準備進行刪除操作 。但此時線程 2 介入,先刪除了節點 A,然后又重新插入了一個值為 A 的新節點,鏈表變為 A -> B -> C(這里的新 A 節點和原來的 A 節點不是同一個對象) 。線程 1 繼續執行刪除操作時,由于 CAS 操作檢查到節點 A 的指針沒有變化,就會誤以為刪除操作可以正常進行,但實際上它刪除的是新插入的節點 A,這就破壞了鏈表的結構,導致數據不一致 。
為了解決 ABA 問題,通常采用帶有版本號的 CAS 操作 。以 Java 中的AtomicStampedReference類為例,它在進行 CAS 操作時,不僅會比較引用值,還會比較一個時間戳(版本號) 。每次變量發生變化時,時間戳也會相應地增加 。在上述鏈表刪除節點的例子中,使用AtomicStampedReference來管理鏈表節點的引用,當線程 1 讀取節點 A 的引用和時間戳后,線程 2 進行刪除和重新插入操作時,時間戳會增加 。線程 1 執行刪除操作時,通過 CAS 操作比較引用和時間戳,發現時間戳已經改變,就知道變量已經被修改過,從而避免了錯誤的刪除操作 。除了版本號,還可以使用原子標記引用(如AtomicMarkableReference),它通過一個標記位來記錄變量是否被修改過,也能有效地解決 ABA 問題 。
ABA問題可以俗稱為“調包問題”,我們先看一個生活化的例子:
你拿著一個裝滿錢的手提箱在飛機場,此時過來了一個火辣性感的美女,然后她很暖昧地挑逗著你,并趁你不注意的時候,把用一個一模一樣的手提箱和你那裝滿錢的箱子調了個包,然后就離開了,你看到你的手提箱還在那,于是就提著手提箱去趕飛機了。
我們再看一個CAS化的例子:
若線程對同一內存地址進行了兩次讀操作,而兩次讀操作得到了相同的值,通過 "值相同" 來判定 "值沒變"是不可靠的。因為在這兩次讀操作的時間間隔之內,另外的線程可能已經多次修改了該值,這樣就相當于欺騙了前面的線程,使其認為 "值沒變",實際上值已經被篡改過了。
下面是 ABA 問題發生的過程:
- T1 線程從共享的內存地址讀取值 A;
- T1 線程被搶占,線程 T2 開始運行;
- T2 線程將共享的內存地址中的值由 A 改成 B,然后又改成 A;
- T1 線程繼續執行,讀取共享的內存地址中的值 A,認為沒有改變,然后繼續執行
由于 T1 并不知道兩次讀取的值 A 已經被 "隱性" 的修改過,所以可能產生無法預期的結果;當 CAS操作循環執行時,存在多個線程交錯地對共享的內存地址進行處理,如果實現的不正確,將有可能遇到 ABA 問題。
6.3調試困難與策略
無鎖編程的調試難度遠遠大于傳統的鎖編程,這是無鎖編程在實踐中面臨的又一挑戰 。由于無鎖編程依賴于多線程并發執行,線程之間的執行順序和時間片分配是不確定的,這使得問題難以復現 。一個在測試環境中偶爾出現的問題,可能在再次運行時就不會出現,這給定位和解決問題帶來了極大的困難 。
無鎖編程中線程執行順序的不確定性也增加了跟蹤和調試的難度 。在傳統的鎖編程中,由于鎖的存在,線程的執行順序相對容易跟蹤,開發者可以通過在關鍵代碼處添加日志,清晰地了解線程的執行流程 。但在無鎖編程中,多個線程可能同時對共享資源進行操作,線程之間的執行順序交錯復雜,很難通過簡單的日志來跟蹤每個線程的具體操作和執行順序 。
為了應對這些調試困難,可以借助一些專業的調試工具 。在 Java 開發中,Java Mission Control就是一個強大的調試工具,它可以監控和分析 Java 應用程序的性能和運行狀態,幫助開發者查看線程的執行情況、內存使用情況等 。通過該工具,能夠直觀地看到各個線程在不同時間點的狀態和執行的代碼片段,有助于定位無鎖編程中線程競爭和數據不一致等問題 。詳細的日志分析也是一種有效的策略 。在無鎖代碼中,合理地添加日志,記錄關鍵操作的執行時間、操作前后的變量值以及線程的 ID 等信息,通過對這些日志的分析,可以更深入地了解程序的執行過程,從而發現潛在的問題 。