[英]For purposes of ordering, is atomic read-modify-write one operation or two?
[英]Independent Read-Modify-Write Ordering
我通過Relacy運行了一堆算法來驗證它們的正確性,但我偶然發現了一些我並不真正理解的東西。 這是它的簡化版本:
#include <thread>
#include <atomic>
#include <iostream>
#include <cassert>
struct RMW_Ordering
{
std::atomic<bool> flag {false};
std::atomic<unsigned> done {0}, counter {0};
unsigned race_cancel {0}, race_success {0}, sum {0};
void thread1() // fail
{
race_cancel = 1; // data produced
if (counter.fetch_add(1, std::memory_order_release) == 1 &&
!flag.exchange(true, std::memory_order_relaxed))
{
counter.store(0, std::memory_order_relaxed);
done.store(1, std::memory_order_relaxed);
}
}
void thread2() // success
{
race_success = 1; // data produced
if (counter.fetch_add(1, std::memory_order_release) == 1 &&
!flag.exchange(true, std::memory_order_relaxed))
{
done.store(2, std::memory_order_relaxed);
}
}
void thread3()
{
while (!done.load(std::memory_order_relaxed)); // livelock test
counter.exchange(0, std::memory_order_acquire);
sum = race_cancel + race_success;
}
};
int main()
{
for (unsigned i = 0; i < 1000; ++i)
{
RMW_Ordering test;
std::thread t1([&]() { test.thread1(); });
std::thread t2([&]() { test.thread2(); });
std::thread t3([&]() { test.thread3(); });
t1.join();
t2.join();
t3.join();
assert(test.counter == 0);
}
std::cout << "Done!" << std::endl;
}
兩個線程爭先恐后地進入受保護區域,最后一個修改done ,從無限循環中釋放第三個線程。 該示例有點人為,但原始代碼需要通過標志聲明該區域以表示“完成”。
最初, fetch_add有acq_rel排序,因為我擔心交換可能會在它之前被重新排序,可能導致一個線程聲明標志,首先嘗試fetch_add檢查,並阻止另一個線程(通過增量檢查)成功修改日程安排。 在使用 Relacy 進行測試時,我想如果我從acq_rel切換到release ,我會看到我期望發生的活鎖是否會發生,令我驚訝的是,它沒有發生。 然后我對所有事情都使用了放松,再次,沒有活鎖。
我試圖在 C++ 標准中找到有關此的任何規則,但只設法挖掘了這些:
1.10.7此外,還有非同步操作的寬松原子操作和具有特殊特性的原子讀-修改-寫操作。
29.3.11原子讀-修改-寫操作應始終讀取在與讀-修改-寫操作相關的寫之前寫入的最后一個值(按修改順序)。
我是否可以始終依賴未重新排序的 RMW 操作 - 即使它們影響不同的內存位置 - 標准中是否有任何內容可以保證這種行為?
編輯:
我想出了一個更簡單的設置,可以更好地說明我的問題。 這是它的CppMem腳本:
int main()
{
atomic_int x = 0; atomic_int y = 0;
{{{
{
if (cas_strong_explicit(&x, 0, 1, relaxed, relaxed))
{
cas_strong_explicit(&y, 0, 1, relaxed, relaxed);
}
}
|||
{
if (cas_strong_explicit(&x, 0, 2, relaxed, relaxed))
{
cas_strong_explicit(&y, 0, 2, relaxed, relaxed);
}
}
|||
{
// Is it possible for x and y to read 2 and 1, or 1 and 2?
x.load(relaxed).readsvalue(2);
y.load(relaxed).readsvalue(1);
}
}}}
return 0;
}
我不認為該工具足夠復雜來評估這種情況,盡管它似乎表明這是可能的。 這是幾乎等效的 Relacy 設置:
#include "relacy/relacy_std.hpp"
struct rmw_experiment : rl::test_suite<rmw_experiment, 3>
{
rl::atomic<unsigned> x, y;
void before()
{
x($) = y($) = 0;
}
void thread(unsigned tid)
{
if (tid == 0)
{
unsigned exp1 = 0;
if (x($).compare_exchange_strong(exp1, 1, rl::mo_relaxed))
{
unsigned exp2 = 0;
y($).compare_exchange_strong(exp2, 1, rl::mo_relaxed);
}
}
else if (tid == 1)
{
unsigned exp1 = 0;
if (x($).compare_exchange_strong(exp1, 2, rl::mo_relaxed))
{
unsigned exp2 = 0;
y($).compare_exchange_strong(exp2, 2, rl::mo_relaxed);
}
}
else
{
while (!(x($).load(rl::mo_relaxed) && y($).load(rl::mo_relaxed)));
RL_ASSERT(x($) == y($));
}
}
};
int main()
{
rl::simulate<rmw_experiment>();
}
斷言從未被違反,因此根據 Relacy,1 和 2(或相反)是不可能的。
我還沒有完全理解你的代碼,但粗體問題有一個簡單的答案:
我是否可以始終依賴未重新排序的 RMW 操作 - 即使它們影響不同的內存位置
不,你不能。 非常允許在同一線程中對兩個寬松的 RMW 進行編譯時重新排序。 (我認為在大多數 CPU 上,兩個 RMW 的運行時重新排序在實踐中可能是不可能的。為此,ISO C++ 沒有區分編譯時與運行時。)
但請注意,原子 RMW 包括加載和存儲,並且這兩個部分必須保持在一起。 因此,任何類型的 RMW 都不能提前通過獲取操作,或稍后通過釋放操作。
此外,當然,作為釋放和/或獲取操作的 RMW 本身可以停止在一個或另一個方向上重新排序。
當然,C++ 內存模型並沒有根據訪問緩存一致共享內存的本地重新排序來正式定義,只是根據與另一個線程同步並創建發生之前/之后的關系。 但是,如果您忽略 IRIW 重新排序(2 個讀取器線程不同意對不同變量進行獨立存儲的兩個寫入器線程的順序),則幾乎可以用 2 種不同的方式對同一事物進行建模。
在您的第一個示例中,保證flag.exchange
始終在counter.fetch_add
之后執行,因為&&
短路 - 即,如果第一個表達式解析為 false,則永遠不會執行第二個表達式。 C++ 標准保證了這一點,因此編譯器不能對這兩個表達式重新排序(無論它們使用哪種內存順序)。
正如 Peter Cordes 已經解釋的那樣,C++ 標准沒有說明是否或何時可以根據原子操作對指令進行重新排序。 通常,大多數編譯器優化依賴於as-if :
本國際標准中的語義描述定義了一個參數化的非確定性抽象機器。 本國際標准對符合性實現的結構沒有要求。 特別是,他們不需要復制或模擬抽象機器的結構。 相反,需要一致的實現來模擬(僅)抽象機 [..] 的可觀察行為。
該規定有時被稱為“好像”規則,因為只要從可觀察到的行為可以確定的結果是好像要求已被遵守,實施就可以自由地無視本國際標准的任何要求的程序。 例如,如果一個實際的實現可以推斷出它的值沒有被使用並且沒有產生影響程序可觀察行為的副作用,那么它就不需要計算表達式的一部分。
這里的關鍵方面是“可觀察的行為”。 假設您在兩個不同的原子對象上有兩個松弛的原子負載A和B ,其中A在B之前排序。
std::atomic<int> x, y;
x.load(std::memory_order_relaxed); // A
y.load(std::memory_order_relaxed); // B
先序關系是先發生關系定義的一部分,因此人們可能會假設這兩個操作不能重新排序。 但是,由於這兩個操作是寬松的,因此無法保證“可觀察行為”,即,即使使用原始順序, x.load
( A ) 也可能返回比y.load
( B ) 更新的結果,因此編譯器可以自由地對它們重新排序,因為最終的程序將無法區分差異(即,可觀察到的行為是等效的)。 如果它不相等,那么您將遇到競爭條件! ;-)
為了防止這種重新排序,您必須依賴(線程間)發生在關系之前。 如果x.load
( A ) 將使用memory_order_acquire
,那么編譯器將不得不假設此操作與某些釋放操作同步,從而建立(線程間)發生在關系之前。 假設某個其他線程執行兩個原子更新:
y.store(42, std::memory_order_relaxed); // C
x.store(1, std::memory_order_release); // D
如果獲取-加載A看到由存儲-釋放D存儲的值,則這兩個操作彼此同步,從而建立發生在之前的關系。 由於y.store
在x.store
之前被x.store
,而x.load
在之前被排序,happens-before 關系的傳遞性保證y.store
發生在y.load
之前。 重新排序兩個加載或兩個存儲會破壞這個保證,因此也會改變可觀察的行為。 因此,編譯器無法執行此類重新排序。
一般來說,爭論可能的重新排序是錯誤的方法。 在第一步中,您應該始終確定所需的happens-before 關系(例如, y.store
必須在y.load
之前發生)。 下一步是確保在所有情況下正確建立這些先發生關系。 至少這就是我為我的無鎖算法實現處理正確性參數的方式。
關於 Relacy:Relacy 僅模擬內存模型,但它依賴於編譯器生成的操作順序。 因此,即使編譯器可以重新排序兩條指令,但選擇不重新排序,您將無法通過 Relacy 識別這一點。
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.