1. 程式人生 > >一個用 C++ 實現的快速無鎖佇列

一個用 C++ 實現的快速無鎖佇列

在程序間傳遞資料很煩人,真心煩人。一步做錯,資料就會損壞。(相較於其他讀寫方式)即使資料排列正確,也更易出錯。

一如既往的,有兩種方式處理這個問題:簡單的方式、麻煩的方式。

簡單的方式

使用你使用平臺提供的鎖(互斥、臨界區域,或等效)。這從概念上不難理解,使用上更簡單。你無需擔心排列問題,庫或OS會替你解決。使用鎖的唯一問題就是慢(這裡的“慢”是相對而言的:一般應用,它的速度足夠了)。

The catch

我一直都在尋找這樣一個音訊程式,這個音訊程式的回撥(也就是說當裝置需要更多的樣例來填補緩衝時程式便被呼叫)執行在另一個執行緒中。設計這樣音訊程式的目標是永遠也不要讓這個程式出現問題。--這就意味著你要儘可能的讓你的回撥程式碼以真實程式時間執行在一個常見的作業系統上。所以,系統計算速度越快越好,但是真正的難點在於如何確定所謂程式真是時間也就是起關鍵性作用的計算時間--也就是說,你程式碼花費的時間也能是5ms也可能突然的變成300ms。

這意味著什麼?這麼說吧,就好像啟動器不能分配堆記憶體空間--這是因為這個過程很可能關聯著一些系統任務的排程,而這些排程很可能需要一些無關緊要的時間去完成(比如,就好像你必須要話費一定的時間去等待網頁與磁碟之間進行的交換)。事實上,我們要儘量避免在核心上進行呼叫。然而,這還不是問題的關鍵。

問題的關鍵在於音訊程式的呼叫執行緒與主執行緒之間的同步問題。我們不能使用鎖,因為這回有一些不必要的消費(考慮到回撥函式的經常呼叫),這還會引起優先順序倒置--你沒有去等著一個高優先順序的音訊執行緒,而這個音訊執行緒正在等待一個關鍵性的部分通過後臺執行緒去釋放自己的空間,這就會使得音訊程式產生問題。

困難的方式

走進無鎖程式設計。

在競爭的情況下,無鎖程式設計是一種編寫執行緒安全程式碼的方式,保證系統作為一個整體推進。“無等待”程式設計進一步完善這點:程式碼被設定,這樣不管對方在做什麼,每個執行緒總可以推進。這也避免了使用昂貴的鎖。為什麼不是每個人都使用無鎖程式設計呢?嗯,它不那麼容易用對。任何情況下,不管每個執行緒在做什麼,你必須很小心,絕不能破壞資料。但更難的是順序相關的保證。考慮這個例子(a和b從0開始):

Thread A    Thread B
b = 42
a = 1
            print(a)
            print(b)

執行緒B可能會輸出的一組值是什麼呢?有可能是{0 0 },{1 42},{0 42}和{1 0}, {0 0}, {1 42}中的任何一組,{0 42}(其實是一個取決於時間的結果)可以說得通,但是{1 0}是怎麼出現的呢?這是因為只要在執行緒裡出現,編譯器都允許在載入和儲存時(或者甚至在移除和構造時)進行重組以提高效率。但是當另一個執行緒開始與第一個互動時,重組就會變得很明顯了。

情況變得糟糕了,你可以強行使編譯器不對特定的讀寫進行重組,但是CPU也會允許(並且一直會)重組指令,只要指令在執行,核心中就會出現再一次相同的情況。

記憶體屏障

幸運的是,你可以通過記憶體屏障強制某些順序保證始終下調到CPU級別。不幸的是,要正確使用它們也很棘手。事實上,無所編碼的一個主要問題是很容易出現一些這樣的漏洞,只能通過特定執行緒操作的非確定性交錯的方式來重現。這意味著無鎖演算法可能存在一個95%的時間工作正常而另外5%的時間會失敗的漏洞。或者它可能在開發者的CPU上總是正常工作,但在另一個CPU上偶爾失敗。

這就是無鎖程式設計通常被認為很難的原因:你很容易得到一些看上去正確,但需要更多努力和思考來創造一些能保證在任何情況下都工作的事物。幸好,有一些工具能幫助驗證這些實現。其中一個工具叫Relacy:它的工作方式是,在各種可能的排列執行緒交織中執行(你寫的)單元測試,這實在太酷了。

回到記憶體屏障:在CPU的最底層,每個核都擁有自己的快取,並且這些快取必須彼此之間保持同步(一種最終統一)。這是由CPU核心之間的一種內部資訊機制來完成的(這樣看來很慢?確實是的。所以這種機制經過高度優化,猜一猜它是怎麼實現的,就是更多的快取!)。因為兩個核心在同事執行程式碼,所以會導致一些有意思的的記憶體載入/儲存順序(比如上面的例子)。記憶體屏障做的事情就是,在這個資訊機制的頂層建立一些更強壯的順序確保機制。我發現的最強大的記憶體屏障是acquire and release(這裡翻譯沒啥意思,後面也會用這個名次)。“release” 記憶體屏障告訴CPU,如果屏障後的寫操作對其他核變為可見,那麼屏障前的寫操作也必須保持可見。這種轉變是在其他核讀取資料(這些資料是“寫屏障”之後寫入的)之後,執行讀屏障的時候進行的。換句話說,如果一個執行緒B可以看見另一個執行緒A在一個寫屏障之後寫入的新值,那麼在執行一個讀屏障之後(線上程B上),所有在寫屏障之前A執行緒上進行的寫操作都會對B執行緒可見。一個不錯的功能: “acquire and release” 語義,類似於x86上的停止操作指令, 就是每個寫操作都隱含release語義,每個讀操作都隱含acquire語義。但是你仍然需要手動新增屏障,因為 “acquire and release” 語義不允許編譯器重新排列記憶體載入順序(並且它也會生成在其他處理器架構上同樣正確的彙編程式碼,即使這些處理器沒有強壯的記憶體排序模型)。

一個等待釋放的單一的生產者,單一的消費者佇列

自從我顯然發現不可抗拒的突然轉向時(譯者:且這麼理解),我,當然,建立了自己的無鎖資料結構,我為了一個單一的生產者、單一的消費者體系結構盡力想求得一個等待釋放的佇列(意味著只有兩個執行緒參與)。如果你立刻需要一個安全使用多執行緒的資料結構,你需要找到另一種實現(MSVC++帶有一個)。我選擇限制是因為它很大程度上操作要比釋放全部併發執行緒簡單,以及這就是我所有我需要的。

我看了一些目前的書籍(特別是liblfds),但是我不太滿意記憶體管理(我想要一個不分配任何記憶體給所有關鍵執行緒的佇列,使得它適合實時程式設計),所以,我忽略了關於只做無鎖程式設計的大量建議除非你已經是這一領域的專家(如何成為一個這樣的專家呢?),並能夠成功地實現一個佇列!

設計如下:

一個連續的環形緩衝區(buffer)被用來儲存佇列中的元素。這樣允許記憶體被分配在最前面,並且可能提供更好的快取使用。我把這個緩衝區叫做“區塊”(block)。

為了使得佇列能夠動態增長,並且當區塊變的太小時(總之不是無鎖的)不需要複製所有已經存在的元素到新的區塊裡,許多的區塊(有獨自的大小)會被連結在一起形成環形相關聯的列表。這樣就形成了佇列的佇列。

元素正在被插入的區塊叫做“尾區塊”。元素正在被消耗的區塊叫做“頭區塊”。相同地,在每一個區塊裡有一個“頭"索引和一個“尾”索引。頭索引指示了下一個將被讀取的滿的位置,尾索引指示了下一個將被插入的空的位置。如果這兩個索引是相等的,那麼這個區塊是空的(一個位置會被清空當佇列是滿的,這樣為了避免當一個滿的區塊和一個空的區塊都有相同頭和尾索引時產生的不明確性)。

為了保持一致性,兩個執行緒操作同一資料結構,我們可以利用生產執行緒在佇列中總是執行在一個方向上的實際情況和同樣可以用來說明的消費者執行緒,這意味著,即使一個變數值在給定的執行緒中是過時的,我們也知道它可能的所在的範圍。例如,當我們從一個阻塞出隊,我們可以檢查尾部索引的值(由其它執行緒擁有)來比較緊靠著前面的索引(該執行緒自己出隊,並因此總是最新的),我們可以為尾部索引從CPU快取獲得一箇舊值,其後入隊執行緒可以增加更多元素,但是,我們知道尾部絕不會倒退——更多元素會被增加,只要尾部不等於前面我們檢查過的,保證至少有一個元素出隊。

使用這些擔保,和一些記憶體分界線去阻止像被增加在元素前面(或者被認為是前加)事實上是被增加到佇列尾部,設計一個簡單演算法來安全地入隊和出隊的元素在所有可能的執行緒交織下是可能的,這是虛擬碼:

# Enqueue
If room in tail block, add to tail
Else check next block
    If next block is not the head block, enqueue on next block
    Else create a new block and enqueue there
    Advance tail to the block we just enqueued to

# Dequeue
Remember where the tail block is
If the front block has an element in it, dequeue it
Else
    If front block was the tail block when we entered the function, return false
    Else advance to next block and dequeue the item there

單個固定尺寸的塊的入隊和出隊演算法是比較簡單的:

1 #向一個塊的入隊(假設我們已經檢查了一個塊中有空間)
2 複製/移動元素進入塊的連續儲存空間
3 隊尾下標增長(需要重新設定隊尾)
4
5 #從一個塊的出隊(假設我們已經檢查了非空)
6 從一個塊的連續儲存空間複製/移動元素到一個輸出引數
7 隊尾下標增長(需要重新設定隊尾)
顯然,這掩蓋了一些東西(比如可能存在的記憶體屏障),但是如果你希望檢查底層錯誤細節的話, 實際程式碼也不會非常複雜。 

再思考這個資料結構,它對於區分消費者執行緒或生產者執行緒所擁有的變數(例如:寫入獨佔變數)是非常有幫助的。對於一個給定的執行緒,他所擁有的變數永遠不會過時。一個執行緒擁有的變數被另一個執行緒讀取到的可能只是一箇舊值,但是通過小心的使用記憶體屏障,但我們從一個並不擁有這個變數的執行緒讀取時,我們能夠保證其餘的資料內容至少是新的。 

允許佇列可能被任意執行緒建立或銷燬(兩個相互獨立的生產者和消費者執行緒),一個完整的記憶體屏障(memory_order_seq_cst)用於建構函式的結尾和解構函式的開始; 這樣可以有效的迫使所有CPU核心有效同步。顯然,生產者和消費者必須在 解構函式可以安全呼叫之前已經停止使用佇列。

給我程式碼

如果沒有可靠的(已被測試的)實現,設計又有什麼用呢?:-)

我已經 在GitHub釋出了我的實現。 自由的fork它吧!它由兩個頭部組成,一個是給佇列的,還有一個取決於是否包含一些輔助引數。

它具有幾個優異的特性:

  • 與 C++11相容 (支援移動物件而不是做拷貝)
  • 完全通用 (任何型別的模板化容器) -- 就像std::queue,你從不需要自己給元素分配記憶體 (這將你從為了管理正在排隊的元素而去寫鎖無關的記憶體管理單元的麻煩中解脫出來)
  • 以連續的塊預先分配記憶體
  • 提供 atry_enqueue方法,該方法保證不去分配記憶體 (佇列以初始容量起動)
  • 也提供了一個enqueue方法,該方法能夠根據需要動態的增長佇列的大小
  • 不採用比較-交換迴圈;這意味著 enqueue和dequeue是O(1)複雜度 (不計算記憶體分配)
  • 對於x86裝置, 記憶體屏障編譯為空指令,這意味著enqueue與dequeue僅僅只是簡單的loads和stores序列 (以及 branches)
  • 在 MSVC2010+ 和 GCC 4.7+下編譯 (而且應該工作於任何支援 C++11 的編譯器)

應注意的是,此程式碼只能工作於能處理對齊的整數和原生指標長度的負載/儲存原子的CPU;幸運的是,這包括所有的現代處理器(包括 ARM,x86 / x86-64,和PowerPC)。它不能工作於 DEC Alpha(這玩意記憶體排序能力保證最弱)。

我釋出的程式碼和演算法遵循簡化的BSD授權協議。你需要自己承擔使用風險;特別是,無鎖程式設計是一個專利的雷區,這程式碼很可能違反了專利(我還沒查驗)。需要提出的是,我是自己胡亂寫出來的演算法和實現,與任何現有的無鎖佇列無關。

效能測試和無誤較正

除了折騰在相當長的一段時間的設計,我(X86)測試了一個簡單的穩定性測試使用數十億隨機操作的演算法。 當然,這有助於鼓舞信心,但不能證明什麼的正確性。 為了確保它是正確的,我的測試也使用了Relacy,跑了一個簡單的測試來測試所有可能的交錯。沒有發現錯誤;但是,事實證明這個簡單的測試是不全面的,因為通過使用一組不同的隨機執行,我發現了一個錯誤(當然我最後修正了這些)。

我只在x86-64架構的機器上測試此佇列,記憶體佔用是相當寬裕(少)的。如有人樂意在其他架構機器上測試這些程式碼,告訴我吧。快速穩定性的測試程式碼我放在了這兒 。

在效能方面,它是非常快的,真的非常的塊。在我的測試中,能夠達到約每秒大於12Million組的併發入隊/出隊的操作(如果佇列中沒有資料出隊執行緒獲取資料之前必須等待入隊執行緒)。雖然在我實現我的佇列之後,我發現另一個釋出在 Intel的網站上的單消費者/單生產者模板佇列(作者是Relacy);他實現的佇列速度大致是我的兩倍,但是他並沒有實現我所實現的全部功能,並且他僅工作在X86平臺(這種條件下,“兩倍快”意味著這兩種不同的入隊/出隊實現在時間上相差非常小)。 

更新於16天前 

我花了一下時間修正我的實驗,分析和優化程式碼,使用 Dmitry的單生產者/單消費者自由鎖佇列(釋出在 Intel網站)作為比較參照。目前我的實現相對更快一些,特別是涉及到多元素入隊的時候(我的實現使用連續的塊替代分開的元素連結方式)。注意不同的編譯器會給出不同的結果,甚至相同的的編譯器在不同的硬體平臺上也顯著的表現出速度有所不同。64位的版本通常比32位版本的快。因為某些原因,我的佇列實現在Linode上,使用GCC編譯器會更快。這裡是完整測試結果:

001 32-bit, MSVC2010, on AMD C-50 @ 1GHz
002 ------------------------------------
003 |        Min        |        Max        |        Avg
004 Benchmark         |   RWQ   |  SPSC   |   RWQ   |  SPSC   |   RWQ   |  SPSC   | Mult

相關推薦

一個 C++ 實現快速佇列

在程序間傳遞資料很煩人,真心煩人。一步做錯,資料就會損壞。(相較於其他讀寫方式)即使資料排列正確,也更易出錯。 一如既往的,有兩種方式處理這個問題:簡單的方式、麻煩的方式。 簡單的方式 使用你使用平臺提供的鎖(互斥、臨界區域,或等效)。這從概念上不難理解,使用上更簡單。你無需擔心排列問題,庫

C#實現一個普通計算器——棧和佇列的應用

這是大二上學期課設的一道題,我當時剛好學習了C#,C#窗體應用程式的介面程式碼和業務程式碼是分開的,這比其他同學使用的C++MFC要方便很多,分享給你,希望對你有幫助。閱讀本文並加以實現的話,需要有一定的C#程式設計基礎,當然,程式碼裡面計算中綴表示式的方法還是

一讀一寫佇列c++實現

限制一個執行緒讀,一個執行緒寫,不加鎖的佇列,使用單鏈表實現,測試環境:centos 5.9  [[email protected] test]# cat  test.cpp     #include <iostream> #include <

單生產者,單消費者佇列實現c

根據上面連結所說的原理實現的單生產者,單消費者無鎖佇列 bool __sync_bool_compare_and_swap (type *ptr, type oldval,type newval, ...) 函式提供原子的比較和交換,如果*ptr == oldval

聊聊高並發(三十二)實現一個基於鏈表的Set集合

target 方向 刪除 元素 min 集合 date 變量 find Set表示一種沒有反復元素的集合類,在JDK裏面有HashSet的實現,底層是基於HashMap來實現的。這裏實現一個簡化版本號的Set,有下面約束: 1. 基於鏈表實現。鏈表節點依照對象的h

C# 實現一個簡單的 Rest Service 供外部調

message [] operation rem adk www span method title 用 C# 實現一個簡單的 Restful Service 供外部調用,大體總結為4點: The service contract (the methods it o

c實現一個跳動的小球

#include<stdio.h> #include<stdlib.h> int main() {  int x=1,y=1,dirx=1,diry=1;  for(;;)  {   int line,col;   fo

c實現一個壓力測試工具

#include <stdlib.h> #include <stdio.h> #include <assert.h> #include <unistd.h> #include <sys/types.h> #include <sys/e

lockFreeQueue 佇列實現與總結

無鎖佇列 介紹   在工程上,為了解決兩個處理器互動速度不一致的問題,我們使用佇列作為快取,生產者將資料放入佇列,消費者從佇列中取出資料。這個時候就會出現四種情況,單生產者單消費者,多生產者單消費者,單生成者多消費者,多生產者多消費者。我們知道,多執行緒往往會帶來資料不一致的情況,一般需要靠加鎖解決問題。

Go語言佇列元件的實現 (chan/interface/select)

1. 背景 go程式碼中要實現非同步很簡單,go funcName()。 但是程序需要控制協程數量在合理範圍內,對應大批量任務可以使用“協程池 + 無鎖佇列”實現。 2. golang無鎖佇列實現思路 Channel是Go中的一個核心型別,你可以把它看成一個管道,通過它併發核心單元就可以傳送或者接

C++實現一個二叉樹類

/**//** 昨天參加宜搜的筆試,要求用C++寫一二叉樹類,實現插入,刪除,定位功能,輾轉再三,* 卻無從下手,甚急,終基礎不好,今天再想,通過層次遍歷二叉樹,再進行實現相關功能* 實也不難. 以下程式碼*//**//** FileName:BTree.cpp* Description:binary tre

Linux平臺上C++實現多執行緒互斥

     在上篇用C++實現了Win32平臺上的多執行緒互斥鎖,這次寫個Linux平臺上的,同樣參考了開源專案C++ Sockets的程式碼,在此對這些給開源專案做出貢獻的鬥士們表示感謝!     下邊分別是互斥鎖類和測試程式碼,已經在Fedora 13虛擬機器上測試通過。

C++實現一個日期類

最近在複習C++的時候發現日期類是一個非常有用的類,在現實中是非常實用的(雖然我不知道為什麼這麼實用的類,庫裡沒有)以下是我自己實現的日期類的程式碼,因為大部分都是運算子的過載,所以理解起來應該並不難 #include <iostream> #include &

C++實現多執行緒Mutex(Win32)

    本文目的:用C++和Windows的互斥物件(Mutex)來實現執行緒同步鎖。     準備知識:1,核心物件互斥體(Mutex)的工作機理,WaitForSingleObject函式的用法,這些可以從MSDN獲取詳情; 2,當兩個或更多執行緒需要同時訪問一個共享資

多執行緒佇列實現

一、什麼是多執行緒無鎖佇列? 多執行緒無鎖佇列還是有鎖的,只不過是用了cpu層面的CAS原子操作,用到這個操作,只需要在取佇列元素和新增佇列元素的時候利用CAS原子操作,就可以保證多個執行緒對佇列元素的有序存取; 二、什麼是CAS操作? CAS = Compare &am

c語言資料結構應用-陣列佇列佇列)在多執行緒中的使用

一、背景 上篇文章《c語言資料結構實現-陣列佇列/環形佇列》講述了陣列佇列的原理與實現,本文編寫一個雙執行緒進行速度測試 二、相關知識 多執行緒程式設計介面: 1) 建立執行緒 pthread_create 函式 SYNOPSIS #include <

佇列--基於linuxkfifo實現

一直想寫個無鎖的佇列,來提高專案後臺的效率。 偶然看到linux核心的kfifo.h 實現原理。於是自己仿照了這個實現,目前linux應該是可以對外提供介面了。 #ifndef _NO_LOCK_QUEUE_H_ #define _NO_LOCK_QUEUE_H_ #i

C++程式設計資料,佇列

1. Lamport's Lock-Free Ring Buffer        [Lamport, Comm. of ACM, 1977]      也就常說的單生產者-單消費者 的ringbuffer, 限制就是隻能一個讀執行緒(消費者),一個寫程序(生產者)。

C++實現,輸入一個日期,輸出它是一年中的第幾天。

操作程式碼: #include<iostream> using namespace std; int main() {

C++ 佇列 ABA

實驗環境:vs2013  新建一個無stdafx.h預編譯頭的控制檯程式,然後複製以下程式碼 1、連結串列實現無鎖佇列 2、陣列實現無鎖佇列 1、連結串列 注意: Enqueue函式中有使用new分配記憶體,本人在windows下使用VS2013編譯,這裡的new是執行