簡體   English   中英

真正測試std::atomic是否無鎖

[英]Genuinely test std::atomic is lock-free or not

由於std::atomic::is_lock_free()可能無法真正反映現實 [ ref ],我正在考慮編寫一個真正的運行時測試。 然而,當我深入研究它時,我發現這不是我認為的一項微不足道的任務。 我想知道是否有一些聰明的想法可以做到這一點。

除了性能之外,該標准不保證任何您可以分辨的方式; 這或多或少是重點。

如果您願意引入一些特定於平台的 UB,您可以執行一些操作,例如將atomic<int64_t> *轉換為volatile int64_t*並查看當另一個線程讀取對象時是否觀察到“撕裂”。 何時將 volatile 與多線程一起使用? - 通常從不,但真正的硬件在運行線程的內核之間具有一致的緩存,因此簡單的 asm 加載/存儲基本上就像輕松原子。)

如果這個測試成功(即普通的 C++ 類型自然是原子的,只有volatile ),那就告訴你任何理智的編譯器都會非常便宜地使其無鎖。 但如果它失敗了,它不會告訴你太多。 該類型的無鎖原子可能僅比加載/存儲的普通版本稍微貴一點,或者編譯器可能根本不會使其無鎖。 例如,在 32 位 x86 上,無鎖int64_t效率高,開銷很小(使用 SSE2 或 x87),但volatile int64_t*將使用兩個單獨的 4 字節整數加載或存儲大多數編譯器編譯它的方式產生撕裂。

在任何特定平台/目標架構上,您都可以在調試器中單步執行代碼並查看運行的 asm 指令。 (包括進入 libatomic 函數調用,如__atomic_store_16 )。 這是唯一100%可靠的方法。 (另外查閱 ISA 文檔以檢查不同指令的原子性保證,例如是否保證 ARM 加載/存儲對,在什么條件下。)

(有趣的事實: 帶有靜態鏈接的 libatomic 的 gcc7可能總是對 x86-64 上的 16 字節對象使用鎖定,因為它沒有機會在動態鏈接時進行運行時 CPU 檢測並在支持它的 CPU 上使用lock cmpxchg16b ,與 glibc 用於為當前系統選擇最佳 memcpy / strchr 實現的機制相同。)


您可以便攜地尋找性能差異(例如,多個讀取器的可擴展性),但 x86-64 lock cmpxchg16b不能擴展1 多個讀取器相互競爭,不像 8 字節和更窄的原子對象,其中純 asm 加載是原子的並且可以使用 lock cmpxchg16b在執行之前獲得對緩存行的獨占訪問權; 在實現.load()失敗時濫用原子加載舊值的副作用比編譯為常規加載指令的 8 字節原子加載糟糕得多。

這就是 gcc7 決定停止為 16 字節對象的is_lock_free()返回 true 的部分原因,如GCC 郵件列表消息中描述的關於您正在詢問的更改

另請注意,32 位 x86 上的 clang 使用lock cmpxchg8b來實現std::atomic<int64_t> ,就像 64 位模式下的 16 字節對象一樣。 所以你會看到它也缺乏並行讀取擴展。 ( https://bugs.llvm.org/show_bug.cgi?id=33109 )


使用鎖定的std::atomic<>實現通常仍然不會通過在每個對象中包含lock字節或字來使對象變大。 它會改變 ABI,但無鎖與鎖定已經是 ABI 的區別。 我認為該標准允許這樣做,但是即使在無鎖的情況下,奇怪的硬件也可能需要對象中的額外字節。 無論如何sizeof(atomic<T>) == sizeof(T)無論如何都不會告訴你任何事情。 如果它更大,則很可能是您的實現添加了互斥鎖,但不檢查 asm 就無法確定。 (如果大小不是 2 的冪,它可能會加寬它以進行對齊。)

(在 C11 中,在對象中包含鎖的范圍要小得多:即使初始化最少(例如靜態為 0),並且沒有析構函數,它也必須工作。編譯器/ABI 通常希望它們的 C stdatomic.h原子與他們的 C++ std::atomic 。)

通常的機制是使用原子對象的地址作為鎖的全局哈希表的鍵 兩個對象混疊/碰撞並共享同一個鎖是額外的爭用,但不是正確性問題。 這些鎖僅從庫函數中獲取/釋放,而不是在持有其他此類鎖時,因此不會造成死鎖。

您可以通過在兩個不同進程之間使用共享內存來檢測這一點(因此每個進程都有自己的鎖哈希表)。 C++11 atomic<T> 是否可以與 mmap 一起使用?

  • 檢查std::atomic<T>的大小是否與T相同(因此鎖不在對象本身中)。

  • 從不共享任何地址空間的兩個獨立進程映射共享內存段。 如果在每個進程中將其映射到不同的基地址,則無關緊要。

  • 存儲一個進程中的全 1 和全零等模式,同時從另一個進程中讀取(並尋找撕裂)。 與我上面對volatile建議相同。

  • 還要測試原子增量:讓每個線程做 1G 增量並檢查結果是否每次都是 2G。 即使純加載和純存儲自然是原子的(撕裂測試),諸如fetch_add / operator++類的讀-修改-寫操作也需要特殊支持: num++ 可以是 'int num' 的原子嗎?

從 C++11 標准來看,意圖是這對於無鎖對象仍然應該是原子的。 它也可能適用於非無鎖對象(如果它們將鎖嵌入對象中),這就是為什么您必須通過檢查sizeof()來排除這種情況。

為了通過共享內存促進進程間通信,我們的意圖是無鎖操作也是無地址的。 也就是說,通過兩個不同地址對同一內存位置的原子操作將進行原子通信。 實現不應依賴於任何每個進程的狀態。

如果您看到兩個進程之間的撕裂,則該對象不是無鎖的(至少不是 C++11 預期的方式,也不是您在普通共享內存 CPU 上期望的方式。)

如果進程不必共享包含原子對象2 的1 頁以外的任何地址空間,我不確定為什么無地址很重要。 (當然,C++11 根本不需要實現使用頁面。或者,實現可以將鎖的哈希表放在每個頁面的頂部或底部?在這種情況下,使用依賴於頁偏移量上方的地址位完全是愚蠢的。)

無論如何,這取決於許多關於計算機如何工作的假設這些假設在所有普通 CPU 上都是正確的,但 C++ 沒有。 如果您關心的實現是在普通操作系統下的主流 CPU 上,例如 x86 或 ARM,那么這種測試方法應該是相當准確的,並且可能是僅讀取 asm 的替代方法。 在編譯時自動執行並不是很實用的事情,但是可以像這樣自動執行測試並將其放入構建腳本中,這與閱讀 asm.xml 不同。


腳注 1:x86 上的 16 字節原子

沒有 x86 硬件文檔支持使用 SSE 指令進行 16 字節原子加載/存儲 在實踐中,許多現代 CPU 確實有原子movaps加載/存儲,但在 Intel/AMD 手冊中並沒有像奔騰及更高版本上的 8 字節 x87/MMX/SSE 加載/存儲那樣保證這一點。 並且無法檢測哪些 CPU 有/沒有原子 128 位操作(除了lock cmpxchg16b ),因此編譯器編寫者無法安全地使用它們。

參見SSE 指令:哪些 CPU 可以進行原子 16B 內存操作? 對於令人討厭的極端情況:在 K10 上的測試表明,對齊的 xmm 加載/存儲顯示同一套接字上的線程之間沒有撕裂,但不同套接字上的線程很少出現撕裂,因為 HyperTransport 顯然只提供 8 字節對象的最小 x86 原子性保證。 (IDK if lock cmpxchg16b在這樣的系統上更貴。)

如果沒有供應商的公開保證,我們也永遠無法確定奇怪的微架構極端情況。 在一個線程寫入模式和另一個讀取模式的簡單測試中沒有撕裂是很好的證據,但在某些特殊情況下,CPU 設計人員決定以與正常情況不同的方式處理,總是有可能出現不同的情況。


只讀訪問只需要指針的指針 + 計數器結構可能很便宜,但當前的編譯器需要union黑客來讓它們只對對象的前半部分進行 8 字節的原子加載。 如何使用 c++11 CAS 實現 ABA 計數器? . 對於 ABA 計數器,您通常會用 CAS 更新它,因此缺少 16 字節的原子純存儲不是問題。

64 位模式下的 ILP32 ABI(32 位指針)(如Linux 的 x32 ABI或 AArch64 的 ILP32 ABI)意味着指針+整數只能容納 8 個字節,但整數寄存器仍然是 8 個字節寬。 這使得使用指針+計數器原子對象比在指針為 8 字節的完整 64 位模式中更有效。


腳注 2:無地址

我認為術語“無地址”是一個獨立的聲明,不依賴於任何每個進程的狀態。 據我了解,這意味着正確性不依賴於對同一內存位置使用相同地址的兩個線程。 但是,如果正確性還取決於它們共享相同的全局哈希表(IDK 為什么將對象的地址存儲在對象本身中會有所幫助),那么只有在同一個對象中可能有多個地址的情況下才重要過程。 這在 x86 的實模式分段模型上可能的,其中 20 位線性地址空間使用 32 位段:偏移量尋址。 (16 位 x86 的實際 C 實現向程序員公開分段;將其隱藏在 C 的規則后面是可能的,但不是高性能。)

虛擬內存也是可能的:同一物理頁面到同一進程內不同虛擬地址的兩個映射是可能的,但很奇怪。 這可能會或可能不會使用相同的鎖,這取決於哈希函數是否使用頁面偏移量上方的任何地址位。 (地址的低位,表示頁面內的偏移量,對於每個映射都是相同的。即這些位的虛擬到物理轉換是無操作的,這就是為什么VIPT 緩存通常被設計為利用這一點在不走樣的情況下獲得速度。)

因此,非無鎖對象可能在單個進程中是無地址的,即使它使用單獨的全局哈希表而不是向原子對象添加互斥鎖。 但這將是一個非常不尋常的情況; 在線程之間共享所有地址空間的同一進程中,使用虛擬內存技巧為同一個變量創建兩個地址是非常罕見的。 更常見的是進程之間共享內存中的原子對象。 (我可能誤解了“無地址”的含義;可能它的意思是“無地址空間”,即不依賴於其他共享地址。)

我認為你真的只是想檢測這個特定於 gcc 的特殊情況,其中is_lock_free報告錯誤,但底層實現(隱藏在libatomic函數調用后面)仍在使用cmpxchg16b 您想知道這一點,因為您認為這樣的實現是真正無鎖的。

在這種情況下,作為一個實際問題,我只會編寫您的檢測函數來硬編碼您知道以這種方式運行的 gcc 版本范圍。 目前,在停止內聯cmpxchg16b的更改之后的所有版本顯然仍然在cmpxchg16b使用無鎖實現,因此今天的檢查將是“開放式”(即 X 之后的所有版本)。 在此之前is_lock_free返回 true(您認為正確)。 在對 gcc 進行一些假設的未來更改后,使庫調用使用鎖, is_lock_free() == false答案將變為真正正確,您將通過記錄它發生的版本來關閉您的檢查。

所以這樣的事情應該是一個好的開始:

template <typename T>
bool is_genuinely_lock_free(std::atomic<T>& t) {
#if     __GNUC__ >= LF16_MAJOR_FIRST && __GNUC_MINOR__ >= LF16_MINOR_FIRST && \
        __GNUC__ <= LF16_MAJOR_LAST  && __GNUC_MINOR__ <= LF16_MINOR_LAST
    return sizeof(T) == 16 || t.is_lock_free();
#else
    return t.is_lock_free();
#endif
}

這里LF16宏定義了版本范圍,其中gcc為 16 字節對象返回is_lock_free的“錯誤”答案。 請注意,由於此更改的后半部分(使__atomic_load_16和朋友使用鎖),您今天只需要檢查的前半部分。 您需要確定當is_lock_free()開始為 16 字節對象返回 false 時的確切版本:Peter 提供的討論此問題的鏈接是一個好的開始,您可以在Godbolt 中進行一些檢查 - 盡管后者並沒有提供您所需要的一切需要,因為它不會反編譯__atomic_load16類的庫函數:您可能需要為此深入研究libatomic源。 宏檢查也可能與libstdc++libatomic版本而不是編譯器版本相關聯(盡管典型安裝中的 AFAIK 將所有這些版本綁定在一起)。 您可能希望向#if添加更多檢查以將其限制為 64 位 x86 平台。

我認為這種方法是有效的,因為真正無鎖的概念並不是真正明確定義的:在這種情況下,您已經決定要考慮 gcc 無鎖中的cmpxchg16b實現,但是如果其他灰色區域在其他未來出現對於您是否認為它是無鎖的,您需要再次判斷。 因此,對於非 gcc 情況,硬編碼方法似乎與某種類型的檢測大致相同,因為在任何一種情況下,未知的未來實現都可能觸發錯誤的答案。 對於 gcc 情況,它看起來更健壯,而且絕對更簡單。

這個想法的基礎是,得到錯誤的答案不會是一個破壞世界的功能問題,而是一個性能問題:我猜你正在嘗試進行這種檢測以在替代實現之間進行選擇,其中一個更快在“真正的”無鎖系統上,以及當std::atomic基於鎖時更適合的其他系統。

如果您的需求更強,並且您真的想要更健壯,為什么不組合方法:使用這種簡單的版本檢測方法,並將其與運行時/編譯時檢測方法相結合,該方法會按照 Peter 的回答中的建議檢查撕裂行為或反編譯。 如果兩種方法都同意,請將其用作答案; 但是,如果他們不同意,則指出錯誤並進行進一步調查。 這也將幫助您了解 gcc 更改實現以使 16 字節對象鎖定已滿的點(如果有的話)。

暫無
暫無

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

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