1. 程式人生 > >無鎖資料結構(1):簡介

無鎖資料結構(1):簡介

希望本文能成為無鎖(lock free)資料結構系列文章一個好的開端。我很樂意與社群分享我的經歷,這個系列就什麼是無鎖資料結構、如何實現以及 STL 容器概念是否適用於無鎖容器,何種情形下適合應用無鎖資料結構做一些分享。

談論無鎖資料結構,必然要談論諸如原子操作、程式語言中的記憶體模型、安全記憶體回收以及在此基礎上的編譯和優化、現代CPU設計等內容。所有話題或多或少都會在這一系列文章中得到討論。

我大膽地討論這些話題,不是因為覺得自己是這些領域及其權威的專家。倘若我沒有這些概念,我無法建立並維護這個libcds庫——這是一個包含無鎖容器和安全記憶體回收演算法的開源C++庫。Cds代表併發資料結構,字首”lib”代表“library(庫)”。

我開始構建此庫,是在2006年。

當時我在一家超級大公司為某個電信執行商做軟體開發。我們極其困難地在各種硬體平臺(the zoo of hardware platforms)上開發伺服器應用,很快就出現了效能問題(必然會出現該問題)。該問題用平行資料處理的方式得到了解決。通常,並行涉及共享資料,訪問該資料必然要求同步。某天,在一次討論中同事問我:“你從來就沒有聽說過無鎖佇列?”,那會我對此一無所知。我谷歌搜尋之後,發現關於無鎖佇列虛擬碼的文章寥寥無幾。來來回回讀了好幾遍,一無所獲。 就在“一無所獲”這種情形下,我鼓舞自己說:“你們這些蠢貨,我才是全世界最聰明的”。接著嘗試簡化演算法,並嘗試在常識和這些演算法之間尋求一種平衡。和段錯誤(segmentation fault)奮戰了一個月之後,我以前的常識都沒有用。那時真是一無所獲,即使IT領域獲得某種程度的成功,但是我完全不知道它的機理。但它確實在某種程度上是可以實現的,不然那些聰明傢伙絕不會寫這些文章,其他聰明的傢伙也絕不會去引用這些文章。追溯這些論文,我讀了大量的文章,從CPU設計、軟體開發者指南開始,到關於無鎖演算法實現基本方法的綜述結束。

一次偶然的機會,我用C++在這個專案做了一些開發,實現了一些原語。不過在2006-2007年那段時間,原語啥都不是;C++標準庫仍然沿用所謂的C++ox優化方式,STL中並沒有原子性原語,介面也只是一個輪廓,編譯器時不時地對我的原子原語惡作劇。特別是在臨界區竟然出現不能執行的程式碼。直到2008年,libcds庫開始有了一個模糊的輪廓。第一次平臺測試給了我很大的鼓勵,甚至是極大的鼓舞(快了50倍),從此我沉浸在無鎖的世界。2010年,我在SourceForge上釋出了此庫的0.5.0版本。截至今天(2014年3月)版本庫是1.4.0,目前正在開發的版本是1.5.0。

現在,我打算對無鎖資料結構做一個總體的概括。程式設計師設計開發軟體專案最大的難點在於,如何最有效地利用平臺的所有資源,特別是伺服器。現代計算機,即使很小的智慧機亦或者平板電腦,都是一個多核處理裝置。效能調優最主要的方法便是並行程式設計,執行緒並行處理一些共享資料。因此我們的主要任務便是如何通過並行的形式,高效地訪問共享資料。

(譯者注:同步劣勢:一、並行的對立面,即殺死並行操作;二 、弱分散式,加劇惡化多個連線響應)

上個世紀80年代,一種叫做結構程式設計的方式很流行,通過此方式認為可以編寫出好的程式。結構程式設計的忠實擁護者Niklaus Wirth,Pascal語言的作者,寫過一本暢銷書《演算法+資料結構=程式》。有趣的是,這個古老的等式正是現代API型別執行緒——Win32 API 的弱點,該API由作業系統建立而成。該API提供了一種並行程式設計方式(就是執行緒),但它並沒有提供一種可實現共享存取的並行資料結構構建方式。恰恰相反,Win32 API通過同步原語的方式保障資料安全,同步是程式並行的一大瓶頸。顧名思義,同步就是並行的對立面:當並行演算法與連續資料結構結合在一起時,它需要提供同步原語才能運作——比如臨界區、互斥鎖、條件變數。結果,所有執行緒在佇列中等待以獲取資料結構,殺死了並行操作。有些同步原語是作業系統核心物件,呼叫此物件代價是很昂貴的:上下文切換或許是必須的,切換到核心執行級別,支援訪問被同步原語資料保護的等待佇列。所有你需要做的是,僅僅改變指示符(designator)的含義,比如去執行一兩個彙編函式。負載可能很高,事實上也確實如此。畢竟,作業系統核心物件是一個數量有限的資源。

同步的另一個缺點是弱分散式。一旦訪問資料的執行緒增加到一定數量,就會成為程式的一個瓶頸。如果並行的級別不斷增高,超出了可容納的合適比例,就會加劇惡化多連線的響應。

Wirth的等式“演算法+資料結構=程式”,我只用在libcds資料結構中。然而在我的庫中,不會有並行排序演算法或者並行for-each演算法。本庫只僅包含幾種競爭性資料結構——queue、list、map、set等。對無鎖資料做必要的演算法支援,這些演算法都是記憶體安全回收(safe memory reclamation)型別的;通常這些資料結構實現很少。最初決定階段:一般來說,實現某個有意思的佇列或者map演算法很少,我也不知道那種更好一些。因為,“好”與“壞”是一個相對的概念,取決於有限的計算機硬體所對應的有限任務。其次,直到你實現了某種演算法,並於其它演算法做過比較,你才知道它不是不更好的。既然演算法都實現了並且都除錯了,為何不放在庫中,讓使用者多一種選擇呢?

在教育領域,對共享資料提供併發訪問的競爭資料結構研究有下面幾個方面:

  • 無鎖資料結構;
  • 細顆粒度的演算法;
  • 事務記憶體

目前還沒有嵌入式的事務性記憶體。不過事務性記憶體是一個巨大的研究課題,終極目標是未來能夠實現它。基於事務性記憶體的演算法表明,簡單地說,記憶體支援原子性事務的原子性提交或者回滾。顯然,這樣的記憶體應該在硬體中實現。研究者承認,目前的軟體實現還沒有充分具備這樣的能力。不過英特的Haswel處理器設計已經在其程式碼指令中支援事務,可以說基於事務性記憶體規則演算法的全盛時期就要到來了。

細顆粒度的演算法是一種偏離同步方法的演算法,通常被認為並不是基於作業系統提供的同步原語應用,而是基於“輕量級的”原子性原語,比如自旋鎖。在此類原語之上構建的資料結構,可以並行讀取,甚至併發寫入。在此基礎上,同步應用於節點、頁、桶(bucket)級別的資料結構中,同時被構建在作業系統相關的演算法中。在相對輕量級連線中,細粒度容器可以和無鎖容器相媲美。因此,libcds庫並沒有輕視此型別的資料結構。

(譯者注:自旋鎖適用於任何鎖持有時間少於將一個執行緒阻塞和喚醒所需要的時間的場合,即減少上下文切換、資料結構更新)

我所提到的資料結構不需要外部同步訪問,它是無鎖資料結構。它是一種非官方的、純技術性定義,反映的是容器的內部構件以及在此之上的各種操作。重點強調“外部”的目:應該明確一點,沒有處理器的支援,幾乎是無法構建無鎖資料結構的。無鎖容器中的這種支援,不是由訪問容器序列化方法的同步機制提供的,而是原子性修改機制提供的。此機制已注入了容器的方法中,亦或者是容器組成(節點、桶、頁)級別的內部同步機制提供的。

(譯者注:缺少處理器的支援,幾乎是無法構建無鎖資料結構的)

無鎖物件(lock-free object)的正式定義如下 [Her91]:判斷一個共享物件是否為無鎖型別(非阻塞物件),就看它是否能確保一些執行緒在有限的系統步驟中完成某個操作,並且與其他執行緒的操作結果無關(即便其它執行緒操作沒有成功)。一個更加嚴格的非等待物件(wait-free object)是這樣定義的:判斷某個物件是否為非等待,就看每個執行緒是否是在有限的步驟中完成了在該物件上的操作。無鎖的條件是至少保證一個執行緒完成任務,而更苛刻的非等待條件則是要保證所有的執行緒都能成功完成任務。線性化(linearizability)在競爭資料結構上也有理論性的定義[Her90],作為一種標準,在驗證無鎖演算法正確性方面,發揮著重要作用。簡而言之,演算法是否為線性化的,就看演算法完成之後的操作結果是否顯而易見,不言自明。舉個例子來說,只要插入函式完成,列表插入操作的結果就顯而易見的。聽起來很白痴,但沒有人能想出某個演算法做了一個列表插入,卻不是線性化。再譬如,各種型別的快取可能違反這種特性:我們先將一個新元素放入快取中而非直接插入,接著命令其它執行緒“將該快取中的此元素插入列表中”,直到此元素插入進去。或者只有當快取中有相當數量的元素時,我們才做一次插入。那麼插入函式執行完畢,我們依舊不能保證此元素在列表中。可以確定的是,此元素遲早會被插入到列表中。

這些定義廣泛地用於科學研究領域。本篇非科技類文章,因此我用無鎖這個狹義的術語定義競爭性容器類。此類構建無需傳統同步模板應用,甚至同步。那麼無鎖演算法的特點是什麼?我認為第一明顯的特徵是其複雜性。請問如何在單項鍊表基礎之上實現常規佇列?下面是一個非常簡單的程式碼實現:

C++
1234567891011121314151617181920212223242526 structNode{Node*m_pNext;};classqueue{Node*m_pHead;Node*m_pTail;public:queue():m_pHead(NULL),m_pTail(NULL){}voidenqueue(Node*p){p->m_pNext=m_pTail;m_pTail=p;if(!m_pHead)m_pHead=p;}Node*dequeue(){if(!m_pHead)returnNULL;Node*p=m_pHead;m_pHead=p->m_pNext;if(!m_pHead)m_pTail=NULL;returnp;}};

甚至可以寫得更簡短一點,這就是無鎖 Michael&Scott 佇列經典演算法實現。它看起來就像入隊、出對方法(和壓棧、彈出的意思相同)。(程式碼是libcds庫類cds::intrusive::MSQueue簡化版)

C++
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172 boolenqueue(value_type&val){node_type*pNew=node_traits::to_node_ptr(val);typenamegc::Guard guard;back_off bkoff;node_type*t;while(true){t=guard.protect(m_pTail,node_to_value());node_type*pNext=t->m_pNext.load(memory_model::memory_order_acquire);if(pNext!=null_ptr<node_type*>()){// Tail is misplaced, advance itm_pTail.compare_exchange_weak(t,pNext,memory_model::memory_order_release,CDS_ATOMIC::memory_order_relaxed);continue;}node_type*tmp=null_ptr<node_type*>();if(t->m_pNext.compare_exchange_strong(tmp,pNew,memory_model::memory_order_release,CDS_ATOMIC::memory_order_relaxed)){break;}bkoff();}++m_ItemCounter;m_pTail.compare_exchange_strong(t,pNew,memory_model::memory_order_acq_rel,CDS_ATOMIC::memory_order_relaxed);returntrue;}value_type*dequeue(){node_type*pNext;back_off bkoff;typenamegc::templateGuardArray<2>guards;node_type*h;while(true){h=guards.protect(0,m_pHead,node_to_value());pNext=guards.protect(1,h->m_pNext,node_to_value());if(m_pHead.load(memory_model::memory_order_relaxed)!=h)continue;if(pNext==null_ptr<node_type*>())returnNULL;// empty queuenode_type*t=m_pTail.load(memory_model::memory_order_acquire);if(h==t){// It is needed to help enqueuem_pTail.compare_exchange_strong(t,pNext,memory_model::memory_order_release,CDS_ATOMIC::memory_order_relaxed);continue;}if(m_pHead.compare_exchange_strong(h,pNext,memory_model::memory_order_release,CDS_ATOMIC::memory_order_relaxed)){break;}bkoff();}--m_ItemCounter;dispose_node(h);returnpNext;}

這是一個很複雜的演算法,相同的單向連結串列。不過即使大體比較一下,也能看出無鎖佇列的一些特徵。在無鎖佇列中,我們可以找到如下描述:

  • 無限迴圈:稍後我們會嘗試執行這個操作,這是一個實現了原子性操作compare_exchange的典型模式;
  • 區域性變數的安全性(guards),需藉助於無鎖演算法中安全記憶體收回方法。本例中,為風險指標(Hazard Pointers)方法;
  • 採用C++11標準的原子性原語:load、compare_exchange以及記憶體柵欄(memory fences)memory_order_xxx;
  • helping :一種廣泛存在於無鎖演算法中的方法,特別是在一個執行緒幫助其它執行緒去執行任務場景中;
  • 補償策略(functor bkoff): 這不是必須的,但可以在連線很多的情況下緩解處理器的壓力,尤其是多個執行緒逐個地呼叫佇列時。

我不打算在本文中,進一步就這些事情展開廣泛的討論,開啟新的話題。讓我們保留這份好奇心,我會在接下來的文章中逐一闡述。

接下來的一篇文章將集中關注無鎖資料結構的基礎概念:原子性和原子性原語。

最後,給大家推薦一些有用的資料,其中不泛競爭性程式設計基本議題的廣泛討論。

截至目前我所知道的兩本不錯的著作:

  1. Nir Shavit, Maurice Herlihy The Art of Multiprocessor programming。此書中,世界級著名無鎖作者描述了大量並行演算法及其實現方法。所有的例子都是用Java實現的,免去了C++記憶體回收帶來的麻煩,採用Java實現,只需考慮記憶體模型以及其它的。倘若你的技術棧是C++,那就必須獨自用C++實現了。儘管如此,此書還是很有幫助。
  2. Anthony Williams C++ Concurrency in Action。此書中,世界級著名C++作者解答了C++多執行緒程式設計的諸多問題,描述了基於並行演算法實現的新C++標準以及其它工具。強烈推薦大家讀一讀。

連結:

打賞支援我翻譯更多好文章,謝謝!

打賞譯者

打賞支援我翻譯更多好文章,謝謝!

任選一種支付方式