1. 程式人生 > >lockFreeQueue 無鎖佇列實現與總結

lockFreeQueue 無鎖佇列實現與總結

無鎖佇列

介紹

  在工程上,為了解決兩個處理器互動速度不一致的問題,我們使用佇列作為快取,生產者將資料放入佇列,消費者從佇列中取出資料。這個時候就會出現四種情況,單生產者單消費者,多生產者單消費者,單生成者多消費者,多生產者多消費者。我們知道,多執行緒往往會帶來資料不一致的情況,一般需要靠加鎖解決問題。但是,加鎖往往帶來阻塞,阻塞會帶來執行緒切換開銷,在資料量大的情況下鎖帶來的開銷是很大的,因此無鎖佇列實現勢在必行。下面就詳細講一下每種情況的不同實現方法。

單生產者單消費者

  從最簡單的單生產者單消費者說起,假設我們現在有一個正常的佇列,寫執行緒往這個佇列的head處push資料,讀執行緒往這個佇列的tail處pop資料。試想,如果head和tail不相同,也就是兩個執行緒操作的資料不是同一個,這個時候是不會產生衝突的,這意味著我們只需要在head == tail的時候做處理。這時候可以發現,head == tail的時候,正是佇列空的時候,也就是說這個時候讀執行緒是讀不到資料的,因此,讀執行緒和寫執行緒是不會產生衝突的,所以實現參考如下虛擬碼

bool push(T &data) {
    if (isFull()) { // head == tail + 1
        return false;
    }
    lockFreeQueue[tail] = data;
    // 注意編譯器或者CPU可能會為了提升效能將程式碼亂序,為了上下兩句程式碼不顛倒順序,這裡需要加上記憶體屏障
    tail++;
    return true;
}

bool pop(T &data) { 
    if (ifEmpty()) { // head == tail
        return
false; } data = lockFreeQueue[head]; // 記憶體屏障 head++; return true; }

多生產者單消費者/單生成者多消費者

  參考單生產者單消費者模型我們可以發現,讀和寫本身是無競爭的,競爭的是讀和讀之間,寫和寫之間。考慮單生產者多消費者問題,有沒有方式避免讀和讀之間的競爭呢?

MUTEX

  解決競爭最粗暴的方法,直接上鎖。

原子操作CAS

  可以利用CAS模擬加鎖解鎖,定義一個變數當鎖,然後將該變數置為0或者1代表未上鎖和已上鎖狀態,因為操作是原子的,所以保證每次只有一個執行緒可以搶佔到鎖。c提供了一個__sync_bool_compare_and_swap介面使用,c++也有相關原子變數庫。這種加鎖解鎖非常快,適合頻繁加鎖解鎖的場景

佇列分離

  如果真的不想加鎖,最簡單的方法就是,我根據讀執行緒數分配佇列數,也就說一個讀執行緒對應一個佇列,這樣,讀和讀就可以分離開來,也就不存在競爭了。然後寫依此去讀每個佇列,這樣可以達到完全無鎖。但是這會帶來其他問題,如果某個執行緒處理比較慢,他繫結的佇列裡的資訊無法及時清空,其他執行緒也無法幫他清空,可能會帶來某些資訊無法快速處理,導致超時。所以要根據實際情況選用這種模式。

佇列共享

  可以發現,將佇列分成多個的做法不是完全不可取的。在只有一個佇列的情況下,所有讀執行緒都會去競爭隊頭,在同一時間只會有一個執行緒可以搶到鎖,然後進行讀,再釋放鎖。如果我現在有多個佇列,然後每個執行緒都可以讀任意一個佇列,當然在讀之前還是要先搶佔佇列使用權,但是因為佇列數量很多,可以把衝突儘可能分散掉。如果此時佇列數量足夠多,衝突率會更低。

  但是這又帶來一個問題,如何給執行緒分配佇列?最簡單的方法是輪詢,也就是每個執行緒一開始都在0號佇列處,依次嘗試去獲取每個佇列的使用權,搶不到的話就去獲取下一個。這種做法簡單粗暴,也可以很明顯地看出衝突率十分高,因為每次都是取下一個佇列,一旦一個佇列搶佔成功,後面的佇列都要嘗試去獲取這個佇列的使用權,如果前面的佇列沒有及時釋放掉佇列使用權,其他佇列肯定會獲取失敗。

  我們想要每個執行緒都能獲取到任意一個佇列的使用權,又不想衝突率太高,可以用下面的方法。假設我現在有n個執行緒,k個佇列,假設n<k,且n與k互質,一開始第i個執行緒在第i個佇列處,處理完後移動到第(i + n) % k個佇列,以此類推。因為n和k互質,可以保證每個執行緒都能訪問到任意一個佇列,而且執行緒之間不容易發生競爭。如果k足夠大,基本上不會產生競爭。如果發生了競爭,可以採用自調節方式,下一個佇列移動到第(i + n + 1) % k個佇列處,這樣的做的原因是,可以認為如果發生了競爭,那麼後面也很容易競爭,所以改變軌跡是很有必要的。但是這種解決衝突的方法有一定侷限性,首先如果佇列數太多,那麼一個執行緒累計下來的資料,另一個執行緒要去處理到它們的時間就會延長,也可能帶來超時。所以其實衝突和及時處理本身就是矛盾的,二者無法完全避免。

共享帶來的問題

  如果你的讀寫十分佔cpu,可能需要每個執行緒分配一個核的時候,共享帶來的問題是不可忽視的。每個CPU都有各自的Cache,具體跟CPU架構有關,但至少L1是每個核一個的。為了保持Cache同步,CPU採用了MESI協議去保證。簡單來說就是每個核監聽匯流排,可以知道哪些資料被修改過了,對於髒資料及時同步,同步要經過資料匯流排。如果每個執行緒繫結一個核,佇列又是共享的,那CPU就要頻繁進行同步。同步是必不可少的,但是如果你只有一個寫執行緒,又有多個讀執行緒的話,寫執行緒會被大大影響,從而降低了效能。這是因為讀執行緒所在的核需要和寫執行緒所在的核進行同步,匯流排容易被佔滿,寫自然就慢下來了。這種情況很難去優化,只能建議在綁核的時候,寫執行緒儘可能和讀綁在同個CPU上,跨CPU帶來的消耗更大。或者可以利用超執行緒技術,超執行緒上的兩個核是共享Cache的,可以把執行緒兩兩繫結起來,但是這樣CPU算力會降低,因為超執行緒無法達到兩個核的算力。這種情況只能從降低同步量去解決了。

  降低同步量的方法很多,儘可能使用執行緒私有的變數,而不是全域性變數,這樣不會帶來同步。或者是一個執行緒連續處理多次同個佇列,降低移動的頻率,這樣同步數量也可以減少,但是會帶來上面講過的資料處理延遲。

  False sharing 問題也是不可忽視的,cache同步的時候,兩個連續的記憶體分別在兩個核上,且在同個cache line。cpu每次更新資料的最小單位都是cache line。假設這兩個資料都要頻繁更新,那他們會不斷向對方發生更新資料請求,這樣開銷十分巨大(具體參考MESI協議,這裡不細說)。解決方法就是進行Cache line對齊,每個變數都分配到不同cache line上就好。

總結

  在做這次lockFreeQueue的過程中,我對CPU有了更多認識,很多簡單的東西往往不是在演算法上做優化,而是要在瞭解了CPU是怎麼處理,OS是怎麼處理,編譯器是怎麼處理之後,去做一些常數的優化。其實我做的優化遠比上面講的要多,諸如分支預測,資料預拉取的操作,但是和lockFreeQueue本身沒有太大關係。本博文做記錄用,有問題或者有更好解決方法的可以留言。