簡體   English   中英

C++ 標准:可以將寬松的原子存儲提升到互斥鎖之上嗎?

[英]C++ standard: can relaxed atomic stores be lifted above a mutex lock?

標准中是否有任何措辭可以保證原子的寬松存儲不會超過互斥鎖的鎖定? 如果沒有,是否有任何措辭明確表示編譯器或 CPU 這樣做是 kosher 的?

例如,以下面的程序為例(它可能對foo_has_been_set使用 acq/rel 並避免鎖定,和/或使foo本身成為原子。它是為了說明這個問題而編寫的。)

std::mutex mu;
int foo = 0;  // Guarded by mu
std::atomic<bool> foo_has_been_set{false};

void SetFoo() {
  mu.lock();
  foo = 1;
  foo_has_been_set.store(true, std::memory_order_relaxed);
  mu.unlock();
}

void CheckFoo() {
  if (foo_has_been_set.load(std::memory_order_relaxed)) {
    mu.lock();
    assert(foo == 1);
    mu.unlock();
  }
}

如果另一個線程同時調用SetFooCheckFoo是否可能在上述程序中崩潰,或者是否有某種保證無法將foo_has_been_set的存儲提升到編譯器和 CPU 對mu.lock的調用mu.lock

這與一個較舊的問題有關,但我並不是 100% 清楚那里的答案適用於此。 特別是,該問題答案中的反例可能適用於對SetFoo兩個並發調用,但我對編譯器知道有一個對SetFoo調用和對SetFoo一個調用的情況CheckFoo 那能保證安全嗎?

我正在尋找標准中的特定引用。

我想我已經找到了保證程序不會崩潰的特定偏序邊。 在下面的答案中,我引用了標准草案的N4659 版本

寫入線程 A 和讀取線程 B 所涉及的代碼是:

A1: mu.lock()
A2: foo = 1
A3: foo_has_been_set.store(relaxed)
A4: mu.unlock()

B1: foo_has_been_set.load(relaxed) <-- (stop if false)
B2: mu.lock()
B3: assert(foo == 1)
B4: mu.unlock()

我們尋求證明如果 B3 執行,那么 A2 發生在 B3 之前,如[intro.races]/10 中所定義。 通過[intro.races]/10.2 ,足以證明 A2 線程間發生在 B3 之前。

因為對給定互斥鎖的鎖定和解鎖操作以單一全序 ( [thread.mutex.requirements.mutex]/5 ) 發生,所以我們必須先有 A1 或 B2。 兩種情況:

  1. 假設 A1 發生在 B2 之前。 然后通過[thread.mutex.class]/1[thread.mutex.requirements.mutex]/25 ,我們知道 A4 將與 B2 同步。 因此,通過[intro.races]/9.1 ,A4 線程間發生在 B2 之前。 由於 B2 在 B3 之前被排序,通過[intro.races]/9.3.1我們知道 A4 線程間發生在 B3 之前。 由於 A2 在 A4 之前被排序,通過[intro.races]/9.3.2 ,A2 線程間發生在 B3 之前。

  2. 假設 B2 發生在 A1 之前。 那么按照上面的邏輯,我們知道B4和A1同步了。 因此,由於 A1 在 A3 之前被排序,通過[intro.races]/9.3.1 ,B4 線程間發生在 A3 之前。 因此,由於 B1 在 B4 之前被排序,通過[intro.races]/9.3.2 ,B1 線程間發生在 A3 之前。 因此,通過[intro.races]/10.2 ,B1 發生在 A3 之前。 但是根據[intro.races]/16 ,B1 必須從 A3 之前的狀態中獲取其值。 因此負載將返回 false,並且 B2 將永遠不會運行。 換句話說,這種情況不可能發生。

因此,如果 B3 完全執行(情況 1),則 A2 發生在 B3 之前,並且斷言將通過。

互斥保護區域內的任何內存操作都無法從該區域“逃脫”。 這適用於所有內存操作,原子和非原子。

在第 1.10.1 節中:

獲取互斥鎖的調用將對包含互斥鎖的位置執行獲取操作相應地,釋放相同互斥鎖的調用將對那些相同的位置執行釋放操作

此外,在第 1.10.1.6 節中:

對給定互斥鎖的所有操作都以單一的總順序發生。 每次互斥量獲取都會“讀取上次互斥量釋放所寫入的值”。

而在 30.4.3.1

互斥對象有助於防止數據競爭,並允許執行代理之間的數據安全同步

這意味着,獲取(鎖定)互斥鎖設置了一個單向屏障,以防止在獲取(在受保護區域內)之后排序的操作向上移動穿過互斥鎖。

釋放(解鎖)互斥鎖設置了一個單向屏障,防止在釋放之前(受保護區域內)排序的操作向下移動穿過互斥鎖解鎖。

此外,由互斥鎖釋放的內存操作與獲取相同互斥鎖的另一個線程同步(可見)。

在你的榜樣, foo_has_been_set在檢查CheckFoo 。如果它讀取true ,你知道,值1已被分配給fooSetFoo ,但還沒有與之同步。 隨后的互斥鎖將獲取foo ,同步完成並且斷言無法觸發。

該標准不直接保證,但您可以在 [thread.mutex.requirements.mutex]. 的行之間閱讀它:

為了確定數據競爭的存在,這些行為表現為原子操作([intro.multithread])。
單個互斥鎖上的鎖定和解鎖操作應出現在單個總順序中。

現在第二句話看起來像是一個硬保證,但事實並非如此。 單個總順序非常好,但這僅意味着獲取和釋放一個特定 mutex有一個明確定義的單個總順序。 就其本身而言,這並不意味着任何原子操作或相關非原子操作的效果應該或必須在與互斥鎖相關的某個特定點全局可見。 或者,隨便。 唯一能保證的是代碼執行的順序(特別是一對函數的執行, lockunlock ),沒有說數據可能會或可能不會發生什么,或者其他什么。
然而,人們可以從字里行間看出,這正是“行為為原子操作”部分的意圖。

從其他地方,也很清楚這是確切的想法,並且實現應該以這種方式工作,而沒有明確說明它必須 例如,[intro.races] 讀作:

[注意:例如,獲取互斥鎖的調用將對包含互斥鎖的位置執行獲取操作。 相應地,釋放相同互斥鎖的調用將在相同位置執行釋放操作。

請注意不幸的小而無害的詞“注意:” 注釋不規范。 所以,雖然很明顯,這是它打算如何理解(互斥鎖=獲取;解鎖=釋放),這實際上不是一個保證。

我認為最好的,雖然不直接的保證來自 [thread.mutex.requirements.general] 中的這句話:

互斥對象有助於防止數據競爭,並允許執行代理之間的數據安全同步。

所以這就是互斥鎖的作用(不說具體如何)。 它可以防止數據競爭。 句號。

因此,無論人們想出什么微妙之處,也無論寫了什么或沒有明確表示,使用互斥鎖可以防止數據競爭(......任何類型,因為沒有給出特定類型)。 就是這么寫的。 因此,總而言之,只要您使用互斥鎖,即使排序松散或根本沒有原子操作,您也很高興。 加載和存儲(任何類型的)不能移動,因為那樣你就不能確定沒有數據競爭發生。 然而,這正是互斥鎖所防止的。
因此,不用說,這說明互斥鎖必須是完全屏障。

答案似乎在於http://eel.is/c++draft/intro.multithread#intro.races-3

兩個相關的部分是

[...] 此外,還有寬松的原子操作,它們不是同步操作 [...]

[...] 對 A 執行釋放操作會強制其他內存位置上的先前副作用對稍后對 A 執行消耗或獲取操作的其他線程可見。 [...]

雖然寬松的訂單原子不被視為同步操作,但在這種情況下,這就是關於它們的所有標准。 由於它們仍然是內存位置,因此它們受其他同步操作控制的一般規則仍然適用。

因此,總而言之,該標准似乎沒有任何特別的內容來防止您描述的重新排序,但是目前的措辭會自然而然地阻止它。

編輯:糟糕,我鏈接到草案。 涵蓋此內容的 C++11 段落是 1.10-5,使用相同的語言。

CheckFoo()不會導致程序崩潰(即觸發assert() ),但也不能保證assert()會被執行。

如果CheckFoo()開始處的條件觸發(見下文),則foo的可見值將為 1,因為mu.unlock()中的SetFoo()mu.lock()中的CheckFoo()之間mu.unlock()內存障礙和同步.

我相信其他答案中引用的互斥鎖描述涵蓋了這一點。

但是,不能保證 if 條件( foo_has_been_set.load(std::memory_order_relaxed)) )永遠為真。 寬松的內存順序不能保證,只保證操作的原子性。 因此在沒有其他一些障礙也不能保證當放寬店SetFoo()將在可見CheckFoo()但如果它是可見的也只會是因為被執行的商店,然后按照mu.lock()必須的在mu.unlock()之后mu.unlock()並且在它之前的寫入可見。

請注意,此參數依賴於foo_has_been_set僅從false設置為true 如果有另一個名為UnsetFoo()函數將其設置回 false:

void UnsetFoo() {
  mu.lock();
  foo = 0;
  foo_has_been_set.store(false, std::memory_order_relaxed);
  mu.unlock();
}

這是從另一個(或第三個)線程調用的,然后不能保證在沒有同步的情況下檢查foo_has_been_set將保證foo已設置。

要清楚(並假設foo_has_been_set永遠不會取消設置):

void CheckFoo() {
  if (foo_has_been_set.load(std::memory_order_relaxed)) {
    assert(foo == 1); //<- All bets are off.  data-race UB
    mu.lock();
    assert(foo == 1); //Guaranteed to succeed.
    mu.unlock();
  }
}

實際上,在任何長時間運行的應用程序的任何真實平台上,可能不可避免的是,relax 存儲最終會被另一個線程看到。 但是,除非存在其他障礙來保證,否則沒有關於是否或何時會發生的正式保證。

正式參考:

http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2013/n3690.pdf

請參閱第 13 頁末尾和第 14 頁開頭的注釋,特別是注釋 17 - 20。它們本質上是確保“輕松”操作的連貫性。 它們的可見性是放松的,但發生的可見性將是連貫的,並且“發生在之前”這個短語的使用符合程序排序的總體原則,特別是獲取和釋放互斥鎖的障礙。 注釋 19 特別相關:

前面的四個一致性要求有效地禁止編譯器將原子操作重新排序為單個對象,即使這兩個操作都是寬松加載。 這有效地使大多數硬件提供的緩存一致性保證可用於 C++ 原子操作。

臨界區中重新排序當然是可能的:

void SetFoo() {
  mu.lock();
  // REORDERED:
  foo_has_been_set.store(true, std::memory_order_relaxed);
  PAUSE(); //imagine scheduler pause here 
  foo = 1;
  mu.unlock();
}

現在的問題是CheckFoo -能的讀取foo_has_been_set落入鎖? 通常這樣的讀取可以(事情可能會落入鎖中,只是不會出),但是如果 if 為假,則永遠不應該使用鎖,所以這將是一個奇怪的順序。 有沒有說“投機鎖”是不允許的? 或者 CPU 能否在讀取foo_has_been_set之前推測 if 為真?

void CheckFoo() {
    // REORDER???
    mu.lock();
    if (foo_has_been_set.load(std::memory_order_relaxed)) {
        assert(foo == 1);
    }
    mu.unlock();
}

該排序可能不正確,只是因為“邏輯順序”而不是內存順序。 如果mu.lock()被內聯(並成為一些原子操作)是什么阻止它們被重新排序?

我不是太擔心當前的代碼,但我擔心的是使用這樣的任何真正的代碼。 這太接近錯誤了。

即,如果 OP 代碼是真正的代碼,您只需將 foo 更改為 atomic,然后去掉其余部分。 所以真正的代碼一定是不同的。 更復雜? ...

暫無
暫無

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

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