無鎖編程介紹

來源:互聯網
上載者:User

原文地址:http://preshing.com/20120612/an-introduction-to-lock-free-programming

無鎖編程是一項挑戰,不僅僅是因為自身的複雜性所致,還與初次探索該課題的困難性相關。

我很幸運,我第一次介紹無鎖(lock-free,也稱為lockless)編程,是BruceDawson的出色而全面的白皮書《無鎖編程注意事項》。和大多數人一樣,我有機會將Bruce的建議用到無鎖代碼的編寫和調試中,例如在Xbox360平台上的開發。

從那時起,寫下了很多好的素材,包括抽象的理論、執行個體的正確性的證明以及一些硬體的細節。在腳註中會有一些引用。有時,來自一個源中的資訊可能會和其它源是正交的。例如,一些材料假設了順序一致性,這就迴避了困擾c/c++無鎖代碼的記憶體排序問題。新的C++11的原子庫標準提供了新的工具,這會挑戰現有的無鎖演算法。

在本文中,我會重新介紹無鎖編程,首先對其進行定義,然後從眾多資訊提煉出少數重要的概念。我會使用流程圖來展示各個概念間的關係,然後我們將涉足一些細節問題。任何學習無鎖編程的程式員,都應該能理解如何在多線程代碼中使用互斥量,理解一些進階同步機制,例如訊號和事件等。

無鎖編程是什麼

人們常常將無鎖編程描述成不使用互斥量(一種鎖的機制)的程式。這是事實,但又完全是。被廣泛接受的基於學術報告的定義含有更廣義的含義。從本質上來說,無鎖編程是描述一些代碼的一個屬性,它並沒有過多描述代碼是如何寫的。

基本上,你的代碼的一些部分符合如下條件,即可被認為是無鎖的。反之,如果你的代碼一些部分不符合下述條件,則被認為不是無鎖的。

在該情境中,“無鎖”中的“鎖”並不是直接指互斥量,而是指“鎖定”整個應用的所有可能的方式,不論是死結、活鎖甚至可能是線程調度的方式都是你最大的敵人。最後一點聽起來似乎很好笑,卻是至關重要的一點。首先,共用互斥量被排除了,因為一旦一個線程擷取了互斥量,你最大的敵人將永遠無法再次調度那個線程。當然,真實的作業系統不是那樣做的,只是我們是如此定義的。

下面這個簡單的例子沒有使用互斥量,但仍然不是無鎖的。一開始,X=0.作為一個給讀者的練習題,請考慮如何調度兩個線程,才能使得兩個都不退出迴圈?

while (X == 0){    X = 1 - X;}

沒人期待整個大型的應用是完全無鎖的。通常,我們可以從整個程式碼程式庫中識別出一系列無鎖操作。例如,在一個無鎖隊列中有少許的無鎖操作,像push、pop,或許還有isEmpty等等。

《多處理器編程藝術》的作者Herlihy和Shavit,趨向於將這些動作表達成類的方法,並提出了一個簡單的無鎖的定義(PPT第150頁):“在一個無限的執行過程中,會不停地有調用結束”。換句話說,程式能不斷地調用這些無鎖操作的同時,許多調用的也是不斷地在完成。從演算法上來說,在這些操作的執行過程中,系統鎖定是不可能的。無鎖編程的一個重要的特性就是,如果掛起一個單獨的線程,不會阻礙其它線程執行。作為一組線程,他們使用特定的無鎖操作來完成這個特性。這揭示了無鎖編程在中斷處理常式和即時系統方面的價值。因為在這些情況下,特定的操作必須在特定的時間內完成,不論程式的狀態如何。

最後的精確描述:設計用於阻塞的操作不會是無鎖演算法失去其資格。例如在無鎖隊列的操作中,當隊列為空白時,隊列的彈出操作會有意地阻塞。但其它的代碼路徑仍然被認為是無鎖的。

無鎖編程技術

事實證明,當你試圖滿足無鎖編程的無阻塞條件時,會出現一系列技術:原子操作、記憶體屏障、避免ABA問題,僅列舉幾例。從這裡開始,事情很快變得棘手了。

那麼,這些技術間是如何相互關聯的?我將它們放在下面的流程圖中進行展示。下文將逐一闡述。

原子的Read-Modify-Write操作

所謂原子操作是指,通過一種看起來不可分割的方式來操作記憶體:線程無法看到原子操作的中間過程。在現代的處理器上,有很多操作本身就是的原子的。例如,對簡單類型的對齊的讀和寫通常就是原子的。

Read-Modify-Write(RMW)操作更進一步,它允許你按照原子的方式,操作更複雜的事務。當一個無鎖的演算法必須支援多個寫時原子操作會尤其有用,因為多個線程試圖在同一個地址上進行RMW時,它們會按“一次一個”的方式排隊執行這些操作。我已經在我的部落格中涉及了RMW操作了,如實現輕量級互斥量、遞迴互斥量和輕量級日誌系統。

RMW操作的例子包括:Win32上的_InterlockedIncrement,iOS上的OSAtomicAdd32以及C++11中的std::atomic<int>::fetch_add。需要注意的是,C++11的原子標準不保證其在每個平台上的實現都是無鎖的,因此最好要清楚你的平台和工具鏈的能力。你可以調用std::atomic<>::is_lock_free來確認一下。

不同的CPU系列支援RMW的方式也是不同的。例如,PowerPC和ARM提供load-link/store-conditional指令,這實際上是允許你實現你自訂的底層RMW操作。常用的RMW操作就已經足夠了。

正如上面流程圖所表述那樣,即使在單一處理器系統上,原子的RMW操作也是無鎖編程的必要部分。沒有原子性的話,一個線程的事務會被中途打斷,這可能會導致一個錯誤的狀態。

Compare-And-Swap 迴圈

或許,最常討論的RMW操作是compare-and-swap(CAS)。在Win32上,CAS是通過如_InterlockedCompareExchange等一系列指令來提供的。通常,程式員會在一個事務中使用Compare-And-Swap迴圈。這個模式通常包括:複製一個共用的變數至本地變數,做一些特定的工作(改動),再試圖使用CAS發布這些改動。

void LockFreeQueue::push(Node* newHead){    for (;;)    {        // Copy a shared variable (m_Head) to a local.        Node* oldHead = m_Head;        // Do some speculative work, not yet visible to other threads.        newHead->next = oldHead;        // Next, attempt to publish our changes to the shared variable.        // If the shared variable hasn't changed, the CAS succeeds and we return.        // Otherwise, repeat.        if (_InterlockedCompareExchange(&m_Head, newHead, oldHead) == oldHead)            return;    }}

這樣的迴圈仍然有資格作為無鎖的,因為如果一個線程檢測失敗,意味著有其它線程成功—儘管某些架構提供一個較弱的CAS變種。無論何時實現一個CAS迴圈,都必須十分小心地避免ABA問題。

順序一致性

順序一致性意味著,所有線程就記憶體操作的順序達成一致。這個順序是和操作在程式原始碼中的順序是一致的。在順序一致性的要求下,像我之前示範的那樣的有意的記憶體重排序不再可能了。

達到順序一致性,一個簡單的(但顯然不切合實際的)方式就是禁用編譯器最佳化並強制你的所有線程在單個處理器上運行。一個處理器不會看到自己的記憶體效果失序,有些程式設計語言,即使是在多處理器環境上運行最佳化過的代碼也能提供順序一致性。在C++11中,你可以具有預設記憶體保序特性的atomic類型變數。在Java中,將變數標識為volatile。下面這個是用C++11風格重寫的例子。

std::atomic X(0), Y(0);int r1, r2;void thread1(){    X.store(1);    r1 = Y.load();}void thread2(){    Y.store(1);    r2 = X.load();}

    因為在C++11中atomic類型具有順序一致性,因此r1=r2=0的結果是不可能的。為了達到此目標,編譯器添加了額外的指令,通常是記憶體屏障或RMW操作。相對於讓程式員直接處理記憶體保序的情況,這些額外的指令或許會使得程式效率降低。

記憶體保序

    正如前面流程圖所建議的那樣,任何時候做多核(或者任何對稱式多處理器)的無鎖編程,如果你的環境不能保證順序一致性,你都必須考慮如何來防止記憶體重新排序。

    在目前的架構中,增強記憶體保序性的工具通常分為三類,防止編譯器重新排序和處理器重新排序:
1、一個輕型的同步或屏障指令,以後會詳述;
2、一個完全的記憶體屏障指令,如之前所述;
3、記憶體操作提供擷取或釋放語義。
    擷取語義防止它之後的操作被重新排序,按編程順序進行;釋放語義防止之前的操作被重新排序。這些語義非常適合生產者-消費者關係,一個線程發布一些資訊,另一個線程讀取。之後會詳細論述。

不同的處理器有不同的記憶體模型

    在記憶體重新排序方面,不同的CPU族有不同的方式。每個CPU廠商都會在其文檔中描述這些規則,相應的硬體嚴格遵守。例如,PowerPC和ARM處理器能自行修改儲存在記憶體上的相關指令的順序,而x86/64族的處理器(Intel和AMD)則不這麼做。我們說前者有更寬鬆的記憶體模型。
    有個模板來抽象這些平台特有的細節,尤其是C++11為我們提供了一個標準的方式來編寫可移植的無鎖代碼。但現在,我認為大多數無鎖編程者都至少要有一些平台差異方面的認識。如果只有一個重要的差異要記住的話,那就是,在x86/64指令層級,每個從記憶體載入(的操作)都帶有擷取語義,每個存入記憶體(的操作)都帶有釋放語義。至少對於non-SSE指令和non-write-combined記憶體是這樣的。因此,過去常常發現在x86/64平台上可以工作的無鎖代碼,在其他處理器上卻無法正常工作。
如果你對硬體如何處理記憶體重新排序的細節感興趣,我建議看看附錄C“Is Pararllel Programming Hard”。在任何情況下,請記住,記憶體重新排序也可能是因為編譯器指令重新排序。
    本文中,我沒有敘述很多實踐方面的無鎖編程,例如:何時做?實際需要多少?我也沒有提及驗證你的無鎖演算法的重要性。我希望本文的介紹能為一些讀者提供一個關於無鎖概念的基本認識,這樣你在進行進一步閱讀時,就不必感到太奇怪。像往常一樣,如果你發現任何不準確之處,請告之。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.