簡體   English   中英

循環緩沖區隊列中的無鎖進度保證

[英]Lock-free Progress Guarantees in a circular buffer queue

有趣的是,我發現很多程序員錯誤地認為“無鎖”只是意味着“沒有互斥鎖的並發編程”。 通常,還有一個相關的誤解,即編寫無鎖代碼的目的是為了更好的並發性能。 當然,lock-free 的正確定義其實是關於進度保證的。 無鎖算法保證至少一個線程能夠向前推進,而不管其他線程在做什么。

這意味着無鎖算法永遠不會有代碼,其中一個線程依賴於另一個線程才能繼續。 例如,無鎖代碼不能出現線程 A 設置標志,然后線程 B 在等待線程 A 取消設置標志的同時繼續循環的情況。 像這樣的代碼基本上是實現一個鎖(或者我稱之為變相的互斥鎖)。

然而,其他情況更微妙,在某些情況下,老實說,我無法真正判斷算法是否符合無鎖條件,因為“取得進展”的概念有時對我來說似乎是主觀的。

一種這樣的情況是在(備受推崇的,afaik)並發庫liblfds中。 我正在研究 liblfds 中多生產者/多消費者有界隊列的實現——實現非常簡單,但我無法確定它是否應該符合無鎖的條件。

相關算法在lfds711_queue_bmm_enqueue.c中。 Liblfds 使用自定義原子和內存屏障,但算法很簡單,我可以用一段左右的時間來描述。

隊列本身是一個有界的連續數組(環形緩沖區)。 有一個共享的read_indexwrite_index 隊列中的每個槽都包含一個用戶數據字段和一個sequence_number值,它基本上就像一個紀元計數器。 (這避免了 ABA 問題)。

PUSH算法如下:

  1. 原子地加載write_index
  2. 嘗試使用嘗試將write_index設置為write_index + 1的 CompareAndSwap 循環在write_index % queue_size處保留隊列中的插槽。
  3. 如果 CompareAndSwap 成功,則將用戶數據復制到預留槽中。
  4. 最后,通過使其等於write_index + 1來更新 slot 上的sequence_index

實際的源代碼使用自定義原子和內存屏障,因此為了更清楚地了解此算法,我已將其簡要翻譯為(未經測試的)標准 C++ 原子以獲得更好的可讀性,如下所示:

bool mcmp_queue::enqueue(void* data)
{
    int write_index = m_write_index.load(std::memory_order_relaxed);

    for (;;)
    {
        slot& s = m_slots[write_index % m_num_slots];
        int sequence_number = s.sequence_number.load(std::memory_order_acquire);
        int difference = sequence_number - write_index;

        if (difference == 0)
        {
            if (m_write_index.compare_exchange_weak(
                write_index,
                write_index + 1,
                std::memory_order_acq_rel
            ))
            {
                break;
            }
        }

        if (difference < 0) return false; // queue is full
    }

    // Copy user-data and update sequence number
    //
    s.user_data = data;
    s.sequence_number.store(write_index + 1, std::memory_order_release);
    return true;
}

現在,想要從read_index處的槽中 POP 元素的線程將無法這樣做,直到它觀察到槽的sequence_number號等於read_index + 1

好的,所以這里沒有互斥體,並且算法可能表現良好(對於 PUSH 和 POP,它只有一個 CAS),但是這是無鎖的嗎? 我不清楚的原因是,如果觀察到隊列已滿或為空,那么當 PUSH 或 POP 總是失敗時,“取得進展”的定義似乎很模糊。

但對我來說值得懷疑的是,PUSH 算法本質上保留了一個插槽,這意味着在推送線程開始更新序列號之前,該插槽永遠不會被 POP'd。 這意味着想要彈出值的 POP 線程依賴於已完成操作的 PUSH 線程。 否則,POP 線程將始終返回false ,因為它認為隊列為 EMPTY。 對我來說,這是否真的屬於“取得進展”的定義似乎值得商榷。

通常,真正的無鎖算法涉及一個被搶占的線程實際上試圖幫助其他線程完成操作的階段。 因此,為了真正實現無鎖,我認為觀察正在進行的 PUSH 的 POP 線程實際上需要嘗試完成 PUSH,然后才執行原始的 POP 操作。 如果 POP 線程在 PUSH 正在進行時簡單地返回隊列為 EMPTY,則 POP 線程基本上被阻塞,直到 PUSH 線程完成操作。 如果 PUSH 線程死亡,或者進入休眠 1000 年,或者被調度到遺忘狀態,則 POP 線程除了不斷報告隊列為 EMPTY 之外什么也做不了。

那么這是否符合無鎖的定義? 從一個角度來看,您可以爭辯說 POP 線程總是可以取得進展,因為它總是可以報告隊列是 EMPTY(我猜這至少是某種形式的進展)。但對我來說,這並沒有真正取得進展,因為隊列被觀察為空的唯一原因是因為我們被並發的 PUSH 操作阻塞了。

所以,我的問題是:這個算法真的是無鎖的嗎? 還是索引預留系統基本上是變相的互斥鎖?

根據我認為最合理的定義,這個隊列數據結構並不是嚴格無鎖的 該定義類似於:

一個結構是無鎖的,前提是任何線程可以在任何時候無限期掛起,同時仍然讓其余線程可以使用該結構。

當然,這意味着一個合適的可用定義,但對於大多數結構來說,這相當簡單:結構應該繼續遵守它的契約,並允許按預期插入和刪除元素。

在這種情況下,已成功遞增m_write_increment但尚未寫入s.sequence_number的線程會使容器很快處於不可用狀態。 如果這樣的線程被殺死,容器最終會同時報告“full”和“empty”分別pushpop ,違反了固定大小隊列的約定。

這里一個隱藏的互斥鎖( m_write_index和相關聯的s.sequence_number的組合) - 但它基本上像每個元素互斥鎖一樣工作。 因此,只有在您循環訪問並且新的寫入者嘗試獲取互斥鎖時,寫入者才會發現失敗,但實際上所有后續寫入者實際上都未能將他們的元素插入隊列,因為沒有讀取者會看到它。

現在這並不意味着這是一個糟糕的並發隊列實現。 對於某些用途,它可能主要表現得好像它是無鎖的。 例如,這種結構可能具有真正無鎖結構的大部分有用性能屬性,但同時它缺乏一些有用的正確性屬性 基本上,無鎖一詞通常意味着一大堆屬性,其中只有一個子集通常對任何特定用途都很重要。 讓我們一一看一看,看看這個結構是怎么做的。 我們將它們大致分為性能和功能類別。

表現

無與倫比的性能

非競爭性或“最佳情況”性能對於許多結構都很重要。 雖然您需要一個並發結構來確保正確性,但您通常仍會嘗試設計您的應用程序,以便將爭用保持在最低限度,因此非競爭成本通常很重要。 一些無鎖結構通過減少非競爭快速路徑中昂貴的原子操作的數量或避免syscall來提供幫助。

這個隊列實現在這里做了一個合理的工作:只有一個“絕對昂貴”的操作: compare_exchange_weak和幾個可能很昂貴的操作( memory_order_acquire加載和memory_order_release存儲) 1 ,以及很少的其他開銷。

這與std::mutex之類的東西相比,這意味着一個原子操作用於鎖定,另一個用於解鎖,實際上在 Linux 上,pthread 調用也具有不可忽略的開銷。

所以我希望這個隊列在無競爭的快速路徑中表現得相當好。

競爭性能

無鎖結構的一個優點是,當結構受到嚴重競爭時,它們通常允許更好的擴展。 這不一定是固有優勢:一些具有多個鎖或讀寫鎖的基於鎖的結構可能表現出匹配或超過某些無鎖方法的縮放,但通常情況下,無鎖結構表現出更好的縮放一個簡單的一鎖一通的替代方案。

該隊列在這方面表現合理。 m_write_index變量由所有讀取器自動更新,將成為爭論點,但只要底層硬件 CAS 實現合理,行為應該是合理的。

請注意,隊列通常是一個相當糟糕的並發結構,因為插入和刪除都發生在相同的位置(頭部和尾部),因此爭用是結構定義中固有的。 將此與並發映射進行比較,其中不同元素沒有特定的有序關系:如果訪問不同的元素,這種結構可以提供有效的無爭用同時突變。

上下文切換免疫

與上述核心定義(以及功能保證)相關的無鎖結構的一個性能優勢是,對結構進行變異的線程的上下文切換不會延遲所有其他變異器。 在負載較重的系統中(尤其是當可運行線程>>可用內核時),線程可能會被切換數百毫秒或秒。 在此期間,任何並發的 mutator 都會阻塞並產生額外的調度成本(或者它們會自旋,這也可能產生不良行為)。 盡管這種“不幸的調度”可能很少見,但當它確實發生時,整個系統可能會導致嚴重的延遲峰值。

無鎖結構避免了這種情況,因為沒有“關鍵區域”可以將線程上下文切換出來並隨后阻止其他線程的前進。

這種結構在這方面提供了部分保護——其細節取決於隊列大小和應用程序行為。 即使在m_write_index更新和序列號寫入之間的關鍵區域中切換出一個線程,其他線程也可以繼續將元素push送到隊列中,只要它們不一直環繞到正在進行的元素從停滯的線程。 線程也可以pop元素,但僅限於進行中的元素。

雖然push行為對於大容量隊列可能不是問題,但pop行為可能是一個問題:如果隊列的吞吐量與線程切換上下文的平均時間和平均填充度相比較高,則隊列將即使在進行中元素之外添加了許多元素,所有消費者線程也會很快顯示為空。 這不受隊列容量的影響,而只是受應用程序行為的影響。 這意味着當這種情況發生時,消費者端可能會完全停止。 在這方面,隊列看起來一點也不無鎖!

功能方面

異步線程終止

由於無鎖結構的優勢,它們對於可能被異步取消或可能在關鍵區域異常終止的線程使用是安全的。 在任何時候取消線程都會使結構保持一致狀態。

如上所述,此隊列不是這種情況。

來自中斷或信號的隊列訪問

一個相關的優點是通常可以從中斷或信號中檢查或改變無鎖結構。 這在中斷或信號與常規進程線程共享結構的許多情況下很有用。

這個隊列主要支持這個用例。 即使在另一個線程處於臨界區時發生信號或中斷,異步代碼仍然可以將一個元素push送到隊列中(稍后只能通過消費線程看到),並且仍然可以從隊列中pop一個元素。

這種行為不像真正的無鎖結構那樣完整:想象一個信號處理程序有一種方法可以告訴剩余的應用程序線程(被中斷的線程除外)停頓,然后排空隊列中的所有剩余元素。 使用真正的無鎖結構,這將允許信號處理程序完全耗盡所有元素,但如果線程在臨界區被中斷或切換出去,這個隊列可能無法做到這一點。


1特別是,在 x86 上,這只會對 CAS 使用原子操作,因為內存模型足夠強大,可以避免其他操作需要原子操作或防護。 最近的 ARM 也可以相當有效地獲取和釋放。

我是 liblfds 的作者。

OP 在他對這個隊列的描述中是正確的。

它是庫中非無鎖的單一數據結構。

這在隊列的文檔中有所描述;

http://www.liblfds.org/mediawiki/index.php?title=r7.1.1:Queue_%28bounded,_many_producer,_many_consumer%29#Lock-free_Specific_Behaviour

“但必須理解,這實際上不是無鎖數據結構。”

這個隊列是 Dmitry Vyukov (1024cores.net) 的一個想法的實現,我只是在使測試代碼工作時才意識到它不是無鎖的。

到那時它正在工作,所以我把它包括在內。

我確實有一些想法要刪除它,因為它不是無鎖的。

大多數時候,人們在真正的意思是無鎖時使用無鎖。 無鎖意味着不使用鎖的數據結構或算法,但不能保證前進。 還要檢查這個問題 所以 liblfds 中的隊列是無鎖的,但正如 BeeOnRope 提到的那樣,它不是無鎖的。

如果 POP 調用立即返回 FALSE,則在順序下一次更新完成之前調用 POP 的線程不會被“有效阻塞”。 線程可以關閉並執行其他操作。 我會說這個隊列符合無鎖的條件。

但是,我不會說它符合“隊列”的條件——至少不是那種你可以在庫中發布為隊列或其他東西的隊列——因為它不能保證很多行為您通常可以從隊列中期待。 特別是,您可以 PUSH 和元素,然后嘗試並 FAIL 彈出它,因為其他一些線程正忙於推送較早的項目。

即便如此,這個隊列在一些針對各種問題的無鎖解決方案中仍然很有用。

然而,對於許多應用程序,我會擔心消費者線程可能會在生產者線程被搶占時被餓死。 也許 liblfds 對此做了一些事情?

“無鎖”是算法的一個屬性,它實現了一些功能 該屬性與方式無關,即程序如何使用給定的功能。

當談論mcmp_queue::enqueue函數時,如果底層隊列已滿,則返回 FALSE,它的實現(在問題帖子中給出)是lock-free

但是,以無鎖方式實現mcmp_queue::dequeue會很困難。 例如,這種模式顯然不是無鎖的,因為它在其他線程更改的變量上旋轉:

while(s.sequence_number.load(std::memory_order_acquire) == read_index);
data = s.user_data;
...
return data;

幾年前,我使用 Spin 對同一代碼進行了形式驗證,用於並發測試課程,它絕對不是無鎖的。

僅僅因為沒有明確的“鎖定”,並不意味着它是無鎖的。 在推理進度條件時,請從單個線程的角度考慮:

  • 阻塞/鎖定:如果另一個線程被取消調度並且這會阻塞我的進度,那么它就是阻塞的。

  • 無鎖/非阻塞:如果我最終能夠在沒有其他線程爭用的情況下取得進展,那么它至多是無鎖的。

  • 如果沒有其他線程可以無限期地阻止我的進度,那么它就是無等待的。

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM