c++ - 為什麼在x86上對自然對齊的變量進行整數賦值?




concurrency atomic (4)

如果32位或更小的對像在內存的“正常”部分內自然對齊,則除了80386sx之外的任何80386或兼容處理器都可以在單個操作中讀取或寫入對象的所有32位。 雖然平台以快速和有用的方式做某事的能力並不一定意味著平台有時不會出於某種原因以某種其他方式做到這一點,而我相信它可能在很多(如果不是全部)x86處理器上有一個內存區域,一次只能訪問8位或16位,我不認為英特爾曾經定義過任何條件,要求對“內存”的“正常”區域進行對齊的32位訪問會導致系統讀取或者寫一部分價值而不讀或寫整件事,我不認為英特爾有意為“正常”的記憶區域定義任何這樣的東西。

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

為什麼自然對齊確保原子性?


如果你問為什麼它的設計如此,我會說它是CPU架構設計的好產品。

早在486時代,就沒有多核CPU或QPI鏈接,所以原子性當時並不是一個嚴格的要求(DMA可能需要它?)。

在x86上,數據寬度為32位(或x86_64為64位),這意味著CPU可以一次性讀寫數據寬度。 並且存儲器數據總線通常與該數量相同或更寬。 結合對齊地址的讀/寫是一次完成的事實,自然沒有什麼能阻止讀/寫是非原子的。 你可以同時獲得速度/原子。


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

如果我們只考慮 - 你鏈接的文章 - 分配指令 ,那麼對齊保證原子性,因為MOV(賦值指令)在對齊數據上是原子設計的。

其他類型的指令,例如INC,需要被 鎖定 (x86前綴,在前綴操作期間為當前處理器提供對共享內存的獨占訪問),即使數據是對齊的,因為它們實際上是通過多個執行的steps(=指令,即load,inc,store)。


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

CPU經常執行諸如高速緩存訪問或內核之間的高速緩存行傳輸之類的功能,以2個2的冪大小的塊,因此小於高速緩存行的對齊邊界確實很重要。 (參見下面的@ BeeOnRope評論)。 有關CPU如何在內部實現原子加載或存儲的更多詳細信息,請參閱 x86 上的 Atomicity ,並且 “num num”中的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向前邁出了一大步。 現在你不再需要關心編譯器對plain int ; 只需使用 atomic<int> 。 如果你發現舊指南談論 int 原子性,它們可能早於C ++ 11。

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

注意:對於 atomic<T> 大於CPU可以原子方式執行(所以 .is_lock_free() 為false),請參閱 std :: atomic的鎖在哪裡? intint64_t / uint64_t 在所有主要的x86編譯器上都是無鎖的。

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

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

(但請注意,i386 gcc -m32 無法為C11 _Atomic 64位類型執行此操作,只將它們與4B對齊,因此 atomic_llong 實際上不是原子的 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和更新”。

來自“英特爾®64和IA-32架構軟件開發人員手冊,第3卷”, 我的筆記用斜體字表示 。 (另請參閱 x86 標籤wiki以獲取鏈接:所有捲的 當前版本 ,或直接鏈接到 2015年12月vol3 pdf的第256頁

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

第8.1.1節保證原子操作

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

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

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

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

奔騰處理器(以及更新的處理器)保證以下額外的內存操作將始終以原子方式執行:

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

本節繼續指出跨越緩存行(和頁面邊界)的訪問分割不保證是原子的,並且:

“可以使用多個存儲器訪問來實現訪問大於四字的數據的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 bug 33109

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

如果要在所有x86系統中使用原子128b,則必須使用 lock cmpxchg16b (僅在64位模式下可用)。 (並且它在第一代x86-64 CPU中 -mcx16 用。你需要使用 -mcx16 和gcc / clang 來發出它 。)

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

英特爾和AMD的手冊因未對齊可訪問 可緩存 內存而分歧 。 所有x86 CPU的通用子集都是AMD規則。 可緩存意味著回寫或直寫存儲器區域,而不是不可緩存或寫入組合,如PAT或MTRR區域所設置。 它們並不意味著緩存行必須在L1緩存中已經很熱。

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

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

    英特爾使用 MESIF ,它需要將臟數據傳播到大型共享包含L3緩存,該緩存充當一致性流量的後盾。 L3是包含每個核心L2 / L1高速緩存的標記,即使對於必須在L3中處於無效狀態的行,因為在每個核心的L1高速緩存中是M或E. Haswell / Skylake中L3和每核心高速緩存之間的數據路徑僅為32B寬,因此它必須緩衝或某些東西以避免在讀取兩半高速緩存行之間發生從一個核心寫入L3,這可能導致撕裂32B邊界。

手冊的相關部分:

P6系列處理器(以及更新的英特爾 處理器)保證以下額外的內存操作將始終以原子方式執行:

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

AMD64手冊7.3.2訪問原子性
在任何處理器模型上,可高速緩存,自然對齊的單個加載或高達四字的存儲都是原子的,未對齊的加載或小於四字的存儲完全包含在自然對齊的四字中

請注意,AMD保證任何小於qword的負載的原子性,但Intel僅支持2的2次冪。 32位保護模式和64位長模式可以將48位 m16:32 作為內存操作數加載到具有遠程 call cs:eip call cs:eip 。 (並且遠程調用會在堆棧上推送內容。)IDK如果計為單個48位訪問或單獨的16位和32位。

已經嘗試將x86內存模型形式化,最新 版本是2009年的x86-TSO(擴展版)文件 (來自 x86 標籤wiki的內存排序部​​分的鏈接)。 它沒有用,因為它們定義了一些用他們自己的符號來表達事物的符號,我沒有試過真正讀過它。 IDK,如果它描述了原子性規則,或者它只涉及內存 排序

原子讀 - 修改 - 寫

我提到了 cmpxchg8b ,但我只討論了加載和存儲,每個都是原子的(即沒有“撕裂”,其中一半的負載來自一個商店,另一半的負載來自不同的商店)。

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

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

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

另請參見: num ++是'int num'的原子嗎?

為什麼 lock mov [mem], reg 原子未對齊商店不存在 lock mov [mem], reg

從insn ref手冊(Intel x86手冊vol2), cmpxchg

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

在將內存控制器內置到CPU之前,此設計決策降低了芯片組的複雜性。 對於擊中PCI-express總線而不是DRAM的MMIO區域的 lock 指令,它仍然可以這樣做。 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 ,這是一個除了clobbering標誌之外的無操作並執行鎖定操作。 [esp] 在L1緩存中幾乎總是很熱,並且不會引起與任何其他核心的爭用。 這個成語可能仍然比MFENCE作為獨立的內存屏障更有效,特別是在AMD CPU上。

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

此設計決策的動機:

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

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

既然你提供了賞金,我認為你正在尋找一個長期回答所有有趣的話題。 讓我知道,如果有一些我沒有涵蓋的內容,您認為這將使這個Q&A對未來的讀者更有價值。

由於您 article 我強烈建議您閱讀更多Jeff Preshing的博文 。 它們非常出色,並幫助我將我所知道的部分組合在一起,了解C / C ++源代碼中的內存排序與不同硬件體系結構的asm,以及如何/何時告訴編譯器你想要的內容如果你不是直接寫asm。





atomic