簡介
無鎖隊列是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頭指針來解決這個問題,如下圖所示。
寫次數減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問題。
你是否已感到有些燒腦?
我們將在下一篇文章中深度剖析無鎖雙向鏈表,是否會燒的更厲害?讓我們拭目以待。