無鎖隊列

簡介

無鎖隊列是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頭指針來解決這個問題,如下圖所示。

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問題。

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,362評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,577評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,486評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,852評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,600評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,944評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,944評論 3 447
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,108評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,652評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,385評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,616評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,111評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,798評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,205評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,537評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,334評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,570評論 2 379

推薦閱讀更多精彩內容