Compare-And-Swap(CAS)詳解

在C++11中關於CAS的操作其中有兩個,分別是compare_exchange_weak和compare_exchange_strong,CAS爲什麼還有強弱之分呢?

實現CAS的操作包括兩個:

  • 實現原子性CAS原語
  • 實現LL/SC對(Load-Linked/Store-Conditiona)

爲什麼會存在LL/SC對的使用,而不直接實現CAS原語呢?

要說明LL/SC對的使用,不得不說一個問題,ABA問題。

ABA

在多線程場景下CAS會出現ABA問題,關於ABA問題這裏簡單科普下,例如有2個線程同時對同一個值(初始值爲A)進行CAS操作,這三個線程如下

線程1,期望值爲A,欲更新的值爲B
線程2,期望值爲A,欲更新的值爲B

線程1搶先獲得CPU時間片,而線程2因爲其他原因阻塞了,線程1取值與期望的A值比較,發現相等然後將值更新爲B,然後這個時候出現了線程3,期望值爲B,欲更新的值爲A,線程3取值與期望的值B比較,發現相等則將值更新爲A,此時線程2從阻塞中恢復,並且獲得了CPU時間片,這時候線程2取值與期望的值A比較,發現相等則將值更新爲B,雖然線程2也完成了操作,但是線程2並不知道值已經經過了A->B->A的變化過程。

Load-Linked/Store-Conditional(LL/SC對)

在多線程程序中,爲了實現對共享變量的互斥訪問,一般都會用spinlock實現,而spinlock需要一個TestAndSet的原子操作。而這種原子操作是需要專門的硬件支持才能完成的,在MIPS中,是通過特殊的Load,Store操作LL(Load Linked,鏈接加載)以及SC(Store Conditional,條件存儲)完成的。

LL 指令的功能是從內存中讀取一個字,以實現接下來的 RMW(Read-Modify-Write) 操作;SC 指令的功能是向內存中寫入一個字,以完成前面的 RMW 操作。LL/SC 指令的獨特之處在於,它們不是一個簡單的內存讀取/寫入的函數,當使用 LL 指令從內存中讀取一個字之後,比如 LL d, off(b),處理器會記住 LL 指令的這次操作(會在 CPU 的寄存器中設置一個不可見的 bit 位),同時 LL 指令讀取的地址 off(b) 也會保存在處理器的寄存器中。接下來的 SC 指令,比如 SC t, off(b),會檢查上次 LL 指令執行後的 RMW 操作是否是原子操作(即不存在其它對這個地址的操作),如果是原子操作,則 t 的值將會被更新至內存中,同時 t 的值也會變爲1,表示操作成功;反之,如果 RMW 的操作不是原子操作(即存在其它對這個地址的訪問衝突),則 t 的值不會被更新至內存中,且 t 的值也會變爲0,表示操作失敗。

SC 指令執行失敗的原因有兩種:

  • 在 LL/SC 操作序列的過程中,發生了一個異常(或中斷),這些異常(或中斷)可能會打亂 RMW 操作的原子性。
  • 在多核處理器中,一個核在進行 RMW 操作時,別的核試圖對同樣的地址也進行操作,這會導致 SC 指令執行的失敗。

在一般實現中,處理器有兩個專門的域給LL和SC指令,即上文中的“不可見的bit位”以及保存ll操作地址的“寄存器”。再LL之後,處理器會監測各種事件,當發生異常或者有別的處理器對該地址發了invalid請求時,會將不可見的bit位重置,從而導致後面的SC失敗。

通過LL/SC對實現的CAS並不是一個原子性操作,但是它確實執行了原子性的CAS,目標內存的單元內容要麼不變,要麼發生原子性變化。

compare_exchange_weak和compare_exchange_strong

由於通過LL/SC對實現的CAS並不是一個原子性操作,於是在該CAS的執行過程中,可能會被中斷,例如:線程X在執行LL行後,OS決定將X調度出去,等OS重新調度恢復X之後,SC將不再響應,這時CAS將返回false,CAS失敗的原因不在數據本身(數據沒變化),而是其他外部事件(線程被中斷了)。

正是因爲如此,C++11標準中添入兩個compare_exchange原語-弱的和強的。也因此這兩原語分別被命名爲compare_exchange_weak和compare_exchange_strong。即使當前的變量值等於預期值,這個弱的版本也可能失敗,比如返回false。可見任何weak CAS都能破壞CAS語義,並返回false,而它本應返回true。而Strong CAS會嚴格遵循CAS語義。

那麼,何種情形下使用Weak CAS,何種情形下使用Strong CAS呢?通常執行以下原則:

  • 倘若CAS在循環中(這是一種基本的CAS應用模式),循環中不存在成千上萬的運算(循環體是輕量級和簡單的,本例的無鎖堆棧),使用compare_exchange_weak。否則,採用強類型的compare_exchange_strong。
False sharing(僞共享)

現代處理器中,cache是以cache line爲單位的,一個cache line長度L爲64-128字節,並且cache line呈現長度進一步增加的趨勢。主存儲和cache數據交換在 L 字節大小的 L 塊中進行,即使緩存行中的一個字節發生變化,所有行都被視爲無效,必需和主存進行同步。存在這麼一個場景,有兩個變量share_1和share_2,兩個變量內存地址比較相近被加載到同一cahe line中,cpu core1 對變量share_1進行操作,cpu core2對變量share_2進行操作,從cpu core2的角度看,cpu core1對share_1的修改,會使得cpu core2的cahe line中的share_2無效,這種場景叫做False sharing(僞共享)。

由於LL/SC對比較依賴於cache line,當出現False sharing的時候可能會造成比較大的性能損失。加載連接(LL)操作連接緩存行,而存儲狀態(SC))操作在寫之前,會檢查本行中的連接標誌是否被重置。如果標誌被重置,寫就無法執行,SC返回 false。考慮到cache line比較長,在多核cpu中,cpu core1在一個while循環中變量share_1執行CAS修改,而其他cpu corei在對同一cache line中的變量share_i進行修改。在極端情況下會出現這樣的一個livelock(活鎖)現象:每次cpu core1在LL(share_1)後,在準備進行SC的時候,其他cpu core修改了同一cache line的其他變量share_i,這樣使得cache line發生了改變,SC返回false,於是cpu core1又進入下一個CAS循環,考慮到cache line比較長,cache line的任何變更都會導致SC返回false,這樣使得cup core1在一段時間內一直在進行一個CAS循環,cpu core1都跑到100%了,但是實際上沒做什麼有用功。

爲了杜絕這樣的False sharing情況,我們應該使得不同的共享變量處於不同cache line中,一般情況下,如果變量的內存地址相差住夠遠,那麼就會處於不同的cache line,於是我們可以採用填充(padding)來隔離不同共享變量,如下:

struct Foo {
int volatile nShared1;
char _padding1[64]; // padding for cache line=64 byte
int volatile nShared2;
char _padding2[64]; // padding for cache line=64 byte
};

上面,nShared1和nShared2就會處於不同的cache line,cpu core1對nShared1的CAS操作就不會被其他core對nShared2的修改所影響了。

上面提到的cpu core1對share_1的修改會使得cpu core2的share_2變量的cache line失效,造成cpu core2需重新加載同步share_2;同樣,cpu core2對share_2變量的修改,也會使得cpu core1所在的cache line實現,造成cpu core1需要重新加載同步share_1。這樣cpu core1的一個修改造成cpu core2的一個cache miss,cpu core2的一個修改造成cpu core1的一個cache miss的反覆現象就是所謂的Cache ping-pong問題,出現大量Cache ping-pong意味着大量的cache miss,會造成巨大的性能損失。我們同樣可以採用填充(padding)來隔離不同共享變量來解決cache ping-pong。

參考

https://www.jiqizhixin.com/articles/2019-01-22-12

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章