無鎖佇列
簡介
無鎖佇列是lock-free中最基本的資料結構,一般應用場景是資源分配,比如TimerId的分配,WorkerId的分配,上電記憶體初始塊數的申請等等。
對於多執行緒使用者來說,無鎖佇列的入隊和出隊操作是執行緒安全的,不用再加鎖控制。
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!");
}
核心實現
顯然,佇列操作的核心實現為入隊和出隊操作。
入隊
入隊的關鍵點有下面幾點:
- 通過寫次數確保佇列元素數小於最大元素數
- 獲取next tail的位置
- 將新元素插入到隊尾
- 尾指標偏移
- 讀次數加1
最大元素數校驗
do
在入隊操作開始,就判斷寫次數是否超過佇列元素的最大值,如果已超過,則反饋佇列已滿的錯誤碼,否則通過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是為了確保不能從空佇列出隊。
出隊
出隊的關鍵點有下面幾點:
- 通過讀次數確保不能從空佇列出隊
- 頭指標偏移
- 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頭指標
memcpy(unit, UNIT_DATA(queue, unitHead->next), queueHead->unitSize);
我們可以看出,頭元素為head->next,這就是說佇列的第一個元素都是基於head->next而不是head。
這樣設計是有原因的。
考慮一個邊界條件:在佇列只有一個元素條件下,如果head和tail指標指向同一個結點,這樣入隊操作和出隊操作本身就需要互斥了。
通過引入一個dummy頭指標來解決這個問題,如下圖所示。
![2463211-83aea3fd31e87a77.png](https://i.iter01.com/images/f4679b75c4a40c1ea450f2836ea859f8131362a4c469eb4c0e6d8d2516340a6f.png)
寫次數減1
__sync_fetch_and_sub(&queueHead->writeCount, 1);
出隊操作結束前,要將寫次數減1,以便入隊操作能成功。
無鎖佇列的ABA問題分析
我們再看一下頭指標偏移的程式碼:
do
{
listHead = queueHead->listHead;
unitHead = UNIT_HEAD(queue, listHead);
} while (!__sync_bool_compare_and_swap(&queueHead->listHead, listHead, unitHead->next));
假設佇列中只有兩個元素A和B,那麼
- 執行緒T1 執行出隊操作,當執行到while迴圈時被執行緒T2 搶佔,執行緒T1 等待
- 執行緒T2 成功執行了兩次出隊操作,並free了A和B結點的記憶體
- 執行緒T3 進行入隊操作,malloc的記憶體地址和A相同,入隊操作成功
- 執行緒T1 重新獲得CPU,執行while中的CAS操作,發現A的地址沒有變,其實內容已經今非昔比了,而執行緒T1 並不知情,繼續更新頭指標,使得頭指標指向B所在結點,但是B所在結點的記憶體早已釋放。
這就是無鎖佇列的ABA問題。
為解決ABA問題,我們可以採用具有原子性的記憶體引用計數等辦法,而利用迴圈陣列實現無鎖佇列也可以解決ABA問題,因為迴圈陣列不涉及記憶體的動態分配和回收,線上程T2 成功執行兩次出隊操作後,佇列的head指標已經變化(指向到了下標head+2),執行緒T3 進行入隊操作不會改變佇列的head指標,當執行緒T1 重新獲得CPU進行CAS操作時,會因失敗重新do while,這時臨時head更新成了佇列head,所以規避了ABA問題。
小結
我們在上一篇簡要介紹了無鎖程式設計基礎,這一篇通過深度剖析無鎖佇列,對CAS原子操作的使用有了感性的認識,我們瞭解了無鎖佇列的API和核心實現,可以看出,要實現一個沒有問題且高效的無鎖佇列是非常困難的,最後對無鎖佇列的ABA問題進行了例項化,筆者在無鎖佇列的程式設計實踐中通過迴圈陣列的方法規避了ABA問題。
你是否已感到有些燒腦?
我們將在下一篇文章中深度剖析無鎖雙向連結串列,是否會燒的更厲害?讓我們拭目以待。
相關文章
- 認識無鎖佇列佇列
- 高效能無鎖佇列 Disruptor 初體驗佇列
- 效能優化-使用雙buffer實現無鎖佇列優化佇列
- 你應該知道的高效能無鎖佇列Disruptor佇列
- 一個高效能無鎖非阻塞連結串列佇列佇列
- 抽象佇列同步器(獨佔鎖)抽象佇列
- 001@多用派發佇列,少用同步鎖佇列
- 佇列、阻塞佇列佇列
- 深入學習Lock鎖(1)——佇列同步器佇列
- 佇列-單端佇列佇列
- Go和C語言的32 位的無鎖、併發、通用佇列的原始碼GoC語言佇列原始碼
- 模型的 save() 方法無法使用佇列?模型佇列
- 佇列 和 迴圈佇列佇列
- 【佇列】【懶排序】佇列Q佇列排序
- 陣列模擬佇列 以及佇列的複用(環形佇列)陣列佇列
- 佇列 手算到機算 入門 佇列 迴圈佇列佇列
- 什麼?無限緩衝的佇列(一)?佇列
- 什麼?無限緩衝的佇列(二)?佇列
- 圖解--佇列、併發佇列圖解佇列
- 單調佇列雙端佇列佇列
- 佇列佇列
- 執行緒安全佇列(使用互斥鎖進行實現)執行緒佇列
- RabbitMQ 訊息佇列之佇列模型MQ佇列模型
- Kafka 延時佇列&重試佇列Kafka佇列
- netcore下RabbitMQ佇列、死信佇列、延時佇列及小應用NetCoreMQ佇列
- 高效能無鎖佇列 Disruptor 核心原理分析及其在i主題業務中的應用佇列
- 棧、堆、佇列深入理解,面試無憂佇列面試
- Java版-資料結構-佇列(陣列佇列)Java資料結構佇列陣列
- 稀疏陣列、佇列陣列佇列
- 07-主佇列和全域性佇列佇列
- 阻塞佇列一——java中的阻塞佇列佇列Java
- synchronized 中的同步佇列與等待佇列synchronized佇列
- java佇列Java佇列
- aardio 佇列佇列
- 棧、佇列佇列
- 棧-佇列佇列
- 佇列,棧佇列
- 如何構建“高效能”“大小無限”(磁碟)佇列?佇列
- Laravel8的重複執行佇列無效Laravel佇列