無鎖佇列

weixin_33976072發表於2016-08-11

簡介

無鎖佇列是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!");
}

核心實現

顯然,佇列操作的核心實現為入隊和出隊操作。

入隊

入隊的關鍵點有下面幾點:

  1. 通過寫次數確保佇列元素數小於最大元素數
  2. 獲取next tail的位置
  3. 將新元素插入到隊尾
  4. 尾指標偏移
  5. 讀次數加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是為了確保不能從空佇列出隊。

出隊

出隊的關鍵點有下面幾點:

  1. 通過讀次數確保不能從空佇列出隊
  2. 頭指標偏移
  3. dummy頭指標
  4. 寫次數減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
dummy-head.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,那麼

  1. 執行緒T1 執行出隊操作,當執行到while迴圈時被執行緒T2 搶佔,執行緒T1 等待
  2. 執行緒T2 成功執行了兩次出隊操作,並free了A和B結點的記憶體
  3. 執行緒T3 進行入隊操作,malloc的記憶體地址和A相同,入隊操作成功
  4. 執行緒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問題。

你是否已感到有些燒腦?
我們將在下一篇文章中深度剖析無鎖雙向連結串列,是否會燒的更厲害?讓我們拭目以待。

相關文章