簡體   English   中英

為什么在 x86 上自然對齊的變量原子上的整數賦值是原子的?

[英]Why is integer assignment on a naturally aligned variable atomic on x86?

我一直在閱讀 這篇關於原子操作的 文章,它提到 32 位整數賦值在 x86 上是原子的,只要變量自然對齊。

為什么自然對齊可以保證原子性?

“自然”對齊意味着與它自己的類型寬度對齊 因此,加載/存儲永遠不會跨越任何比其自身更寬的邊界(例如頁面、緩存行,或者用於不同緩存之間數據傳輸的更窄的塊大小)。

CPU 通常以 2 的冪大小的塊的形式在內核之間執行緩存訪問或緩存線傳輸等操作,因此比緩存線小的對齊邊界很重要。 (見下面@BeeOnRope 的評論)。 另請參閱x86上的 原子性以獲取有關 CPU 如何在內部實現原子加載或存儲的更多詳細信息,以及“int num”的 num++ 可以是原子的嗎? 有關如何在內部實現原子 RMW 操作(如atomic<int>::fetch_add() / lock xadd atomic<int>::fetch_add()更多信息。


首先,這假設int是用單個存儲指令更新的,而不是分別寫入不同的字節。 這是std::atomic保證的一部分,但普通的 C 或 C++ 沒有。 不過,通常情況會如此。 x86-64 System V ABI不禁止編譯器訪問非原子的int變量,即使它確實要求int為 4B,默認對齊為 4B。 例如, x = a<<16 | b 如果編譯器需要, x = a<<16 | b可以編譯為兩個獨立的 16 位存儲。

數據競爭在 C 和 C++ 中都是未定義行為,因此編譯器可以並且確實假設內存不是異步修改的。 對於保證不會中斷的代碼,請使用 C11 stdatomic或 C++11 std::atomic 否則,編譯器只會在寄存器中保留一個值, 而不是每次讀取時都重新加載,就像volatile一樣,但具有實際保證和語言標准的官方支持。

在 C++11 之前,原子操作通常是用volatile或其他東西來完成的,並且“在我們關心的編譯器上工作”的健康劑量,所以 C++11 是向前邁出的一大步。 現在你不再需要關心編譯器對普通int做了什么; 只需使用atomic<int> 如果您發現舊指南談論int原子性,它們可能早於 C++11。 什么時候在多線程中使用 volatile? 解釋了為什么這在實踐中有效,並且帶有memory_order_relaxed atomic<T>是獲得相同功能的現代方法。

std::atomic<int> shared;  // shared variable (compiler ensures alignment)

int x;           // local variable (compiler can keep it in a register)
x = shared.load(std::memory_order_relaxed);
shared.store(x, std::memory_order_relaxed);
// shared = x;  // don't do that unless you actually need seq_cst, because MFENCE or XCHG is much slower than a simple store

旁注:對於比 CPU atomic<T>做的大的atomic<T> (所以.is_lock_free()是假的),請參閱std::atomic 的鎖在哪里? . intint64_t / uint64_t在所有主要的 x86 編譯器上都是無鎖的。


因此,我們只需要討論像mov [shared], eax這樣的指令的行為。


TL;DR:x86 ISA 保證自然對齊的存儲和加載是原子的,高達 64 位寬。 所以編譯器可以使用普通的存儲/加載,只要它們確保std::atomic<T>具有自然對齊。

(但要注意的是i386的gcc -m32沒有這樣做,對於C11 _Atomic內部結構64位類型,其中只有對准4B,所以atomic_llong可以在某些情況下,非原子。 https://gcc.gnu.org/bugzilla /show_bug.cgi?id=65146#c4 )。 帶有std::atomic g++ -m32很好,至少在 g++5 中是這樣,因為https://gcc.gnu.org/bugzilla/show_bug.cgi?id=65147在 2015 年通過對<atomic>的更改而修復標題。 不過,這並沒有改變 C11 的行為。)


IIRC,有SMP 386系統,但是直到486才建立當前的內存語義。這就是為什么手冊上說“486和更新”。

摘自“Intel® 64 and IA-32 Architectures Software Developer Manuals, volume 3”,我的筆記用斜體表示 (另請參閱標簽 wiki 以獲取鏈接:所有卷的當前版本,或直接鏈接到2015 年 12 月 vol3 pdf 的第 256 頁

在 x86 術語中,“字”是兩個 8 位字節。 32 位是雙字或 DWORD。

###Section 8.1.1 保證原子操作

Intel486 處理器(以及之后的更新處理器)保證以下基本內存操作將始終以原子方式執行:

  • 讀取或寫入一個字節
  • 讀取或寫入在 16 位邊界上對齊的字
  • 讀取或寫入在 32 位邊界上對齊的雙字(這是“自然對齊”的另一種說法)

我加粗的最后一點是您問題的答案:這種行為是處理器成為 x86 CPU(即 ISA 的實現)所需的一部分。


本節的其余部分為較新的 Intel CPU 提供了進一步的保證: Pentium 將此保證擴展到 64 位

Pentium 處理器(以及后來的處理器)保證以下額外的內存操作將始終以原子方式執行:

  • 讀取或寫入在 64 位邊界上對齊的四字(例如 x87 加載/存儲doublecmpxchg8b (這是 Pentium P5 中的新功能))
  • 對適合 32 位數據總線的未緩存內存位置的 16 位訪問。

該部分繼續指出跨緩存行(和頁面邊界)拆分的訪問不能保證是原子的,並且:

“可以使用多次內存訪問來實現訪問大於四字的數據的 x87 指令或 SSE 指令。”


AMD 的手冊同意英特爾關於對齊的 64 位和更窄的加載/存儲是原子的

因此整數、x87 和 MMX/SSE 加載/存儲高達 64b,即使在 32 位或 16 位模式(例如movqmovsdmovhpspinsrqextractps等)如果數據對齊,也是原子的。 gcc -m32使用movq xmm, [mem]std::atomic<int64_t>類的東西實現原子 64 位加載。 Clang4.0 -m32不幸的是使用lock cmpxchg8b錯誤 33109

在某些具有 128b 或 256b 內部數據路徑(在執行單元和 L1 之間,以及在不同緩存之間)的 CPU 上,128b 甚至 256b 向量加載/存儲是原子的,但任何標准都不能保證這一點,也不能在運行時輕松查詢,不幸的是,對於實現std::atomic<__int128>或 16B structs 的編譯器

如果您想在所有 x86 系統上使用原子 128b,則必須使用lock cmpxchg16b (僅在 64 位模式下可用)。 (並且它在第一代 x86-64 CPU 中-mcx16用。您需要將-mcx16與 GCC/Clang 一起使用, 以便它們發出它。)

即使在內部執行原子 128b 加載/存儲的 CPU 也可以在具有以較小塊運行的一致性協議的多插槽系統中表現出非原子行為:例如AMD Opteron 2435 (K10),線程在單獨的插槽上運行,並與 HyperTransport 連接


Intel 和 AMD 的手冊在未對齊訪問可緩存內存方面存在分歧 所有 x86 CPU 的通用子集是 AMD 規則。 可緩存意味着回寫或直寫內存區域,而不是不可緩存或寫組合,如 PAT 或 MTRR 區域設置的那樣。 它們並不意味着緩存行必須在 L1 緩存中已經很熱。

  • 英特爾 P6 及更高版本保證可緩存加載/存儲的原子性,只要它們位於單個緩存行(64B 或 32B 在像 Pentium III 這樣的非常老的 CPU 中)。
  • AMD 保證適合單個 8B 對齊塊的可緩存加載/存儲的原子性。 這是有道理的,因為我們從多路Opteron上的 16B 存儲測試中知道,HyperTransport 僅以 8B 塊傳輸,並且在傳輸時不會鎖定以防止撕裂。 (看上面)。 我猜lock cmpxchg16b必須特別處理。

可能相關:AMD 使用MOESI在不同內核中的緩存之間直接共享臟緩存行,因此一個內核可以從其緩存行的有效副本中讀取,而對它的更新則來自另一個緩存。

英特爾使用MESIF ,它需要臟數據傳播到大型共享包容性 L3 緩存,該緩存充當一致性流量的支持。 L3 包含標簽,包括每核 L2/L1 緩存,即使對於由於在每核 L1 緩存中為 M 或 E 而必須在 L3 中處於無效狀態的行也是如此。 在 Haswell/Skylake 中,L3 和每核緩存之間的數據路徑只有 32B 寬,因此它必須緩沖或避免從一個內核寫入 L3 發生在讀取緩存線的兩半之間,這可能會導致撕裂32B邊界。

手冊的相關部分:

P6 系列處理器(以及后來的Intel處理器)保證以下額外的內存操作將始終以原子方式執行:

  • 對適合緩存線的緩存內存的未對齊 16 位、32 位和 64 位訪問。

AMD64 手冊 7.3.2 訪問原子性
可緩存、自然對齊的單個加載或最多四字的存儲在任何處理器模型上都是原子的,就像完全包含在自然對齊的四字內的小於四字的未對齊加載或存儲一樣

請注意,AMD 保證任何小於 qword 的負載的原子性,但 Intel 僅保證 2 的冪大小。 32 位保護模式和 64 位長模式可以使用far- call或 far- jmp將 48 位m16:32作為內存操作數加載到cs:eip (並且遠調用將內容推入堆棧。)IDK,如果這算作單個 48 位訪問或單獨的 16 位和 32 位。

已經有人嘗試將 x86 內存模型形式化,最新的一個是 2009 年的 x86-TSO(擴展版)論文(鏈接來自標簽維基的內存排序部分)。 由於他們定義了一些符號來用他們自己的符號來表達事物,因此它不是有用的 skimmable,我還沒有嘗試真正閱讀它。 IDK,如果它描述了原子性規則,或者它只關心內存排序


原子讀-修改-寫

我提到了cmpxchg8b ,但我只是在談論負載和存儲分別是原子的(即沒有“撕裂”,其中一半負載來自一個存儲,另一半負載來自不同的存儲)。

為了防止該內存位置的內容在加載和存儲之間被修改,您需要lock cmpxchg8b ,就像您需要lock inc [mem]以使整個 read-modify-write 成為原子一樣。 另請注意,即使沒有lock cmpxchg8b執行單個原子加載(以及可選的存儲),通常將其用作具有預期 = 期望的 64b 加載也是不安全的。 如果內存中的值恰好符合您的預期,您將獲得該位置的非原子讀-修改-寫。

lock前綴甚至可以使跨緩存行或頁面邊界的未對齊訪問原子化,但您不能將它與mov一起使用來使未對齊存儲或加載原子化。 它僅適用於內存目標讀取-修改-寫入指令,如add [mem], eax

lockxchg reg, [mem]是隱含的,所以不要將xchg與 mem 一起使用來節省代碼大小或指令數,除非性能無關緊要。僅在需要內存屏障和/或原子交換時才使用它,或者當代碼大小是唯一重要的事情時,例如在引導扇區中。)

另請參閱: 對於“int num”,num++ 可以是原子的嗎?


為什么lock mov [mem], reg對於原子未對齊存儲不存在

從指令參考手冊(英特爾 x86 手冊 vol2), cmpxchg

該指令可以與LOCK前綴一起使用,以允許以原子方式執行該指令。 為了簡化處理器總線的接口,目標操作數接收一個寫周期而不考慮比較的結果。 如果比較失敗,則寫回目標操作數; 否則,源操作數將寫入目標。 處理器永遠不會在不產生鎖定寫入的情況下產生鎖定讀取。)

在將內存控制器內置到 CPU 中之前,這種設計決策降低了芯片組的復雜性。 對於命中 PCI-express 總線而不是 DRAM 的 MMIO 區域上的lock ed 指令,它仍然可以這樣做。 lock mov reg, [MMIO_PORT]產生對內存映射 I/O 寄存器的寫入和讀取只會令人困惑。

另一種解釋是確保您的數據具有自然對齊並不是很難,並且與僅確保您的數據對齊相比, lock store會很糟糕。 將晶體管用於速度如此之慢以至於不值得使用的東西是愚蠢的。 如果你真的需要它(並且不介意讀取內存),你可以使用xchg [mem], reg (XCHG 有一個隱式的 LOCK 前綴),它甚至比假設的lock mov還要慢。

使用lock前綴也是一個完整的內存屏障,因此它會帶來超出原子 RMW 的性能開銷。 即 x86 不能做寬松的原子 RMW(不刷新存儲緩沖區)。 其他 ISA 可以,因此在非 x86 上使用.fetch_add(1, memory_order_relaxed)可以更快。

有趣的事實:在mfence存在之前,一個常見的習慣用法是lock add dword [esp], 0 ,它是除了破壞標志和執行鎖定操作之外的無操作。 [esp]在 L1 緩存中幾乎總是很熱,不會引起與任何其他內核的爭用。 作為獨立的內存屏障,這個習慣用法可能仍然比 MFENCE 更有效,尤其是在 AMD CPU 上。

xchg [mem], reg可能是在 Intel 和 AMD 上實現順序一致性存儲的最有效方法,與mov + mfence相比。 Skylake 上的mfence至少會阻止非內存指令的亂序執行,但xchg和其他lock操作不會。 gcc 以外的編譯器確實使用xchg進行存儲,即使它們不關心讀取舊值。


這個設計決定的動機:

沒有它,軟件將不得不使用 1 字節鎖(或某種可用的原子類型)來保護對 32 位整數的訪問,與共享原子讀訪問相比,這是非常低效的,例如由定時器中斷更新的全局時間戳變量. 它可能在硅片中基本上是免費的,以保證總線寬度或更小的對齊訪問。

為了使鎖定成為可能,需要某種原子訪問。 (實際上,我猜硬件可以提供某種完全不同的硬件輔助鎖定機制。)對於在其外部數據總線上進行 32 位傳輸的 CPU,將其作為原子性單位才有意義。


由於您提供了賞金,我假設您正在尋找一個很長的答案,其中包含所有有趣的附帶主題。 如果您認為我沒有涵蓋的內容會使本問答對未來的讀者更有價值,請告訴我。

由於您 在問題中鏈接了一個我強烈建議您閱讀更多 Jeff Preshing 的博客文章 它們非常出色,並幫助我將我所知道的部分整合到了對不同硬件架構的 C/C++ 源代碼與 asm 中的內存排序的理解中,以及如何/何時告訴編譯器你想要什么(如果你不是)直接寫asm。

如果 32 位或更小的對象在內存的“正常”部分內自然對齊,則任何 80386 或除 80386sx 以外的兼容處理器都可以在單個操作中讀取或寫入對象的所有 32 位。 雖然平台能夠以快速且有用的方式做某事並不一定意味着該平台有時不會出於某種原因以其他方式做某事,雖然我相信在許多 x86 處理器上(如果不是全部)都可以有一次只能訪問 8 位或 16 位的內存區域,我認為英特爾從未定義任何條件,要求對“正常”內存區域進行對齊的 32 位訪問會導致系統讀取或在不讀取或寫入整個內容的情況下寫入部分值,我認為英特爾無意為“正常”內存區域定義任何此類內容。

自然對齊意味着類型的地址是類型大小的倍數。

例如,一個字節可以位於任何地址,short(假設 16 位)必須是 2 的倍數,int(假設 32 位)必須是 4 的倍數,而 long(假設 64 位)必須是是 8 的倍數。

如果您訪問的數據不是自然對齊的,CPU 將引發故障或讀取/寫入內存,但不會作為原子操作。 CPU 采取的行動將取決於架構。

例如,圖像我們有下面的內存布局:

01234567
...XXXX.

int *data = (int*)3;

當我們嘗試讀取*data ,組成值的字節分布在 2 個 int 大小的塊中,1 個字節在塊 0-3 中,3 個字節在塊 4-7 中。 現在,僅僅因為塊在邏輯上彼此相鄰並不意味着它們在物理上是。 例如,塊 0-3 可能位於 cpu 緩存行的末尾,而塊 3-7 位於頁面文件中。 當 cpu 訪問塊 3-7 以獲得它需要的 3 個字節時,它可能會看到該塊不在內存中並發出信號表示它需要將內存調入。這可能會阻塞調用進程,而操作系統將內存重新分頁。

在內存被調入之后,但在你的進程被喚醒之前,另一個進程可能會出現並向地址 4 寫入一個Y然后你的進程被重新調度並且 CPU 完成讀取,但現在它已經讀取了 XYXX,而不是您期望的 XXXX。

如果你問它為什么這樣設計,我會說它是CPU架構設計的一個很好的副產品。

回到 486 時代,沒有多核 CPU 或 QPI 鏈接,因此原子性在當時並不是真正嚴格的要求(DMA 可能需要它?)。

在 x86 上,數據寬度為 32 位(或 x86_64 為 64 位),這意味着 CPU 可以一次性讀取和寫入數據寬度。 並且內存數據總線通常與這個數字相同或更寬。 結合在對齊地址上的讀/寫是一次性完成的事實,自然沒有什么可以阻止讀/寫是非原子的。 您同時獲得速度/原子。

為了回答您的第一個問題,如果變量存在於其大小的倍數的內存地址處,則該變量自然是對齊的。

如果我們只考慮 - 正如您鏈接的文章所做的那樣 -賦值指令,那么對齊保證原子性,因為 MOV(賦值指令)在對齊數據上是原子設計的。

其他類型的指令,例如 INC,需要被LOCK ed(一個 x86 前綴,它在前綴操作期間為當前處理器提供對共享內存的獨占訪問),即使數據是對齊的,因為它們實際上是通過多個步驟(=指令,即加載、公司、存儲)。

暫無
暫無

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

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