[英]For purposes of ordering, is atomic read-modify-write one operation or two?
考慮一個原子讀-修改-寫操作,例如x.exchange(..., std::memory_order_acq_rel)
。 為了對其他對象的加載和存儲進行排序,這是否被視為:
具有獲取-釋放語義的單個操作?
或者,作為獲取加載后跟發布存儲,額外保證對x
的其他加載和存儲將觀察到它們兩者或兩者都不觀察?
如果它是#2,那么盡管同一線程中的其他操作在加載之前或存儲之后不能被重新排序,但它留下了它們可以在兩者之間重新排序的可能性。
作為具體示例,請考慮:
std::atomic<int> x, y;
void thread_A() {
x.exchange(1, std::memory_order_acq_rel);
y.store(1, std::memory_order_relaxed);
}
void thread_B() {
// These two loads cannot be reordered
int yy = y.load(std::memory_order_acquire);
int xx = x.load(std::memory_order_acquire);
std::cout << xx << ", " << yy << std::endl;
}
thread_B
是否有可能變為 output 0, 1
?
如果x.exchange()
被替換為x.store(1, std::memory_order_release);
那么thread_B
當然可以 output 0, 1
。 exchange()
中的額外隱式負載是否應該排除這種情況?
cppreference聽起來像是 #1 是這種情況,而0, 1
是被禁止的:
具有此 memory 順序的讀-修改-寫操作既是獲取操作又是釋放操作。 沒有 memory 當前線程中的讀取或寫入可以在此存儲之前或之后重新排序。
但是我在標准中找不到任何明確的內容來支持這一點。 實際上,該標准對原子讀取-修改-寫入操作幾乎沒有提及,除了 N4860 中的 31.4 (10) 這只是一個明顯的屬性,即讀取必須讀取寫入之前寫入的最后一個值。 因此,盡管我不想質疑 cppreference,但我想知道這是否真的正確。
我也在研究它是如何在 ARM64 上實現的。 gcc 和thread_A
本質上都將 thread_A 編譯為
ldaxr [x]
stlxr #1, [x]
str #1, [y]
(參見 godbolt。 )根據我對 ARM64 語義的理解和一些測試(加載y
而不是存儲),我認為str [y]
可以在stlxr [x]
之前變得可見(當然不在ldaxr
之前)。 這將使thread_B
可以觀察到0, 1
。 因此,如果#1 為真,那么 gcc 和 clang 似乎都是錯誤的,我不敢相信。
最后,據我所知,將memory_order_acq_rel
替換為seq_cst
不會改變此分析的任何內容,因為它僅添加了與其他seq_cst
操作相關的語義,而我們這里沒有任何語義。
我發現C++ memory model 中有哪些確切的規則可以防止在獲取操作之前重新排序? 如果我理解正確的話,它似乎同意#2 是正確的,並且可以觀察到0, 1
。 我仍然很感激確認,以及檢查 cppreference 引用是否真的錯誤或者我是否誤解了它。
不是語言標准層面的答案,而是一些證據表明,在實踐中,答案可以是“二”。 正如我在問題中所猜測的那樣,即使 RMW 是seq_cst
也會發生這種情況。
我無法觀察到存儲在原始問題中被重新排序,但這里有一個示例,它顯示了原子seq_cst
RMW 的存儲在隨后的relaxed
負載下被重新排序。
下面的程序是彼得森算法的一個實現,它改編自 LWimsey 的示例,在What's are actual example where acquire release memory order 與順序一致性不同? . 如那里所解釋的,該算法的正確版本涉及
me.store(true, std::memory_order_seq_cst);
if (other.load(std::memory_order_seq_cst) == false)
// lock taken
存儲后負載變得可見是必不可少的。
如果 RMW 是用於排序語義的單個操作,我們希望這樣做是安全的
me.exchange(true, std::memory_order_seq_cst);
if (other.load(std::memory_order_relaxed) == false) {
// Ensure critical section doesn't start until we know we have the lock
std::atomic_thread_fence(std::memory_order_seq_cst);
// lock taken
}
理論上,由於交換操作已獲得語義,因此負載必須在交換完成后變得可見,特別是在me
的true
存儲變得可見之后。
但實際上在 ARMv8-a 上,使用 gcc 或 clang 時,這樣的代碼經常會失敗。 實際上, exchange
似乎確實由acquire-load 和release-store 組成,並且other.load
可能在release-store 之前變得可見。 (雖然不是在exchange
的獲取負載之前,但這在這里無關緊要。)
clang 生成如下代碼:
mov w11, #1
retry:
ldaxrb wzr, [me]
stlxrb w12, w11, [me]
cbnz w12, retry
ldrb w11, [other]
參見https://godbolt.org/z/fhjjn7 ,程序集 output 的第 116-120 行。 (gcc 是相同的,但隱藏在庫 function 中。)通過 ARM64 memory 排序語義,release-store stlxrb
可以通過以下加載和存儲重新排序。 它是獨家的事實並沒有改變這一點。
為了使重新排序更頻繁地發生,我們安排存儲的數據依賴於錯過緩存的先前加載,我們通過使用dc civac
驅逐該行來確保這一點。 我們還需要將兩個標志me
和other
放在單獨的緩存行上。 否則,據我所知,即使線程 A 在存儲之前加載,線程 B 也必須等待開始其 RMW,直到 A 的存儲完成之后,特別是在 A 的存儲可見之前不會加載。
在多核 Cortex A72 (Raspberry Pi 4B) 上,斷言通常在幾千次迭代后失敗,這幾乎是瞬時的。
代碼需要使用-O2
構建。 我懷疑如果為swpalb
可用的 ARMv8.2 或更高版本構建,它將無法工作。
// Based on https://stackoverflow.com/a/41859912/634919 by LWimsey
#include <thread>
#include <atomic>
#include <cassert>
// size that's at least as big as a cache line
constexpr size_t cache_line_size = 256;
static void take_lock(std::atomic<bool> &me, std::atomic<bool> &other) {
alignas(cache_line_size) bool uncached_true = true;
for (;;) {
// Evict uncached_true from cache.
asm volatile("dc civac, %0" : : "r" (&uncached_true) : "memory");
// So the release store to `me` may be delayed while
// `uncached_true` is loaded. This should give the machine
// time to proceed with the load of `other`, which is not
// forbidden by the release semantics of the store to `me`.
me.exchange(uncached_true, std::memory_order_seq_cst);
if (other.load(std::memory_order_relaxed) == false) {
// taken!
std::atomic_thread_fence(std::memory_order_seq_cst);
return;
}
// start over
me.store(false, std::memory_order_seq_cst);
}
}
static void drop_lock(std::atomic<bool> &me) {
me.store(false, std::memory_order_seq_cst);
}
alignas(cache_line_size) std::atomic<int> counter{0};
static void critical_section(void) {
// We should be the only thread inside here.
int tmp = counter.fetch_add(1, std::memory_order_seq_cst);
assert(tmp == 0);
// Delay to give the other thread a chance to try the lock
for (int i = 0; i < 100; i++)
asm volatile("");
tmp = counter.fetch_sub(1, std::memory_order_seq_cst);
assert(tmp == 1);
}
static void busy(std::atomic<bool> *me, std::atomic<bool> *other)
{
for (;;) {
take_lock(*me, *other);
std::atomic_thread_fence(std::memory_order_seq_cst); // paranoia
critical_section();
std::atomic_thread_fence(std::memory_order_seq_cst); // paranoia
drop_lock(*me);
}
}
// The two flags need to be on separate cache lines.
alignas(cache_line_size) std::atomic<bool> flag1{false}, flag2{false};
int main()
{
std::thread t1(busy, &flag1, &flag2);
std::thread t2(busy, &flag2, &flag1);
t1.join(); // will never happen
t2.join();
return 0;
}
從標准的角度來看,RMW 操作是單個操作。 我不確定它是否在任何地方明確說明,但它的名稱(單數)和一些相關的措辭似乎暗示了這一點。
純粹從標准的角度來看,您的代碼可以打印0, 1
。
首先,該標准不是在操作重新排序方面措辭,而是在發布和獲取操作之間的同步關系方面。
由於y.load(acquire)
沒有匹配的 release-or-stronger 存儲(沒有任何同步),它和y.load(relaxed)
一樣好。
由於x.exchange(1, acq_rel)
只有一個匹配的負載來同步,但沒有存儲,它的“獲取”部分沒有做任何有用的事情(實際上是放松的),所以它可以用x.store(1, release)
。
然后我們只有x
上存儲和加載之間的潛在同步,但是由於在存儲之前和加載之后(在各自的線程中)沒有任何操作,所以這個同步也沒有做任何事情。
所以兩個負載都可以返回0
或1
。
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.