1. 程式人生 > >關於C11標準中原子操作,看這篇就夠了!

關於C11標準中原子操作,看這篇就夠了!

6月18日,由CocoaChina主辦,Intel獨家贊助的以“那些開發領域的新玩意”為主題的CVP系列開發者沙龍圓滿落幕。沙龍上,CocoaChina社群名版主zenny_chen帶來了《C11標準中原子操作簡介》為主題的精彩分享。


以下為演講實錄:

zenny_chen:很高興跟大家分享C11標準,有很多的朋友可能不知道什麼是C11標準,C11標準其實就是iSO/IEC C語言國際標準委員會在2011年釋出的C語言官方標準,將其命名為ISO/IEC 9899:2011,我們將它簡稱為C11。不過我今天講的這個C11標準原子操作,其實在C++程式語言中也是完全適用的,它們的API是完全一樣的,所以C11標準中的原子使用範圍比較多,此外像OpenCL 2.0也支援,以及Apple最新的Metal API也支援C11原子操作。


我這次為什麼要選擇C11標準原子操作呢?我們知道大資料、機器學習,以及現在VR、AR技術都非常火熱,今年又被各大媒體稱為VR元年,這些領域包括VR、人工智慧、機器學習等其實都是屬於高效能運算領域。在高效能平行計算領域中,我們往往會面對擁有多個核心的處理器的多執行緒平行計算,甚至利用像Intel Xeon phi眾核加速器或GPU來做資料密集處理。在多核心多執行緒平行計算當中,有那麼多執行緒將同時對資料進行處理,其中就會碰到一個非常重要的問題,就是資料一致性問題!當多個執行緒對同一個資料物件進行操作的時候,我們要保證對此資料修改結果的一致性,所以如何既能準確無誤、又能夠把系統負荷降到最低以保證資料一致性,那麼這就要靠我們現代化的原子操作去達到這個目的。

那麼傳統的資料同步的方法實際上都是基於鎖,比如互斥體、訊號量、臨界區等等。而原子操作形式跟基於鎖的同步方法會有一些不太一樣。我們現在來看一個對比,假設執行緒A和B全都是執行在一個多核處理器上(比如雙核處理器上),並且兩個執行緒同時進行,順序圖上我們可以看到執行緒A先對一個共享儲存單元進行操作,先載入儲存單元裡面的值,然後對它計算,之後再進行儲存;而與此同時,執行緒B也是線上程A載入後稍晚那麼一丁點,也在對這個共享儲存進行載入操作,做它的計算,最後儲存。這兩個執行緒做下來之後,比如說我們沒有用原子操作,載入、計算和儲存這三個操作之間是有縫隙的,所以這會導致像執行緒A,比如假定執行緒A已完成載入計算儲存三個操作並將這個值寫入,執行緒B也是完成了這個過程,那麼共享儲存單元最後的計算結果就是執行緒B的計算結果,不是執行緒B基於執行緒A操作之後的結果,這就會導致資料不完整性。


舉個直觀的例子:我現在共享儲存單元裡面的數字是1,執行緒A先把這個D載入進來,我做++操作就變成2,最後我把這個C11標準寫到共享儲存單元裡;與此同時,執行緒B在另外一個核上同時執行,載入進來也是1,計算++操作不是+1,而是+10,這個值也儲存進去,由於執行緒A先完成儲存,然後是執行緒B完成儲存,最終兩個結束以後,共享儲存單元值不是12,而是11,所以這就導致資料不一致性。如果執行緒B這個是快,執行緒A慢,最終的結果是2,也不是12。原子操作就不一樣了。如果是採用現代化原子操作,載入、計算和儲存就作為一整個不可分割且不可被打斷的一個操作,此時如果這邊是一個原子加法操作,執行緒A是+1操作,執行緒B是原子+10操作,那麼當兩個執行緒全都完成執行後,結果肯定就是12;如果執行緒A快,執行緒B慢,執行緒A在對共享儲存單元做原子訪問請求的時候,執行緒B如果同時要對它修改,那麼會臨時阻塞住,直到執行緒A對共享儲存單元的操作完成以後,儲存器控制器才會允許執行緒B的原子操作的修改請求,這樣我們能夠保證共享儲存單元操作的資料和執行。

這邊已經講了幾個資料同步問題的傳統方法,比如說像互斥體,就是一個資源同時只能有一個執行緒進行訪問,這個時候我們會用互斥體。還有一個訊號量semaphore,大家學過作業系統應該比較熟,普遍用於生產者消費者問題,我可能會有多個資源多個執行緒都可以共享,生產者將訊號量加1,消費者減1,比如減到小於0當前執行緒就被阻塞,當生產者再生產出一個資源,向那些阻塞的執行緒發出訊號,讓他們重新啟用。還有一個就是臨界區,就是類似於Objective-C中的一個@synchronized { }語句塊。傳統同步的特點就是對於單核單處理,多執行緒,尤其是微控制器上面比較簡單的處理器,一般處理器不需要提供原子操作,因為只要通過簡單的開關中斷操作即可對鎖物件做原子性的修改操作。單核單處理器同時只能有一個執行緒進行執行,它的執行引擎是單個的,所以不會存在兩個執行緒同時執行的情況。

對於多核多處理器環境下的資料同步,如果我要用開關中斷來實現原子性是不切合實際的。現在做應用的同學應該比較熟悉,比如說當前執行緒就管當前執行緒做的事情,當前執行緒要對其他執行緒進行干擾,比如防止對它進行排程,這個顯然是開銷非常大的,而且也是不切實際,那麼這個時候就只能通過原子操作進行多核多執行緒的資料同步。

我們再看一下上面的這張圖。如果我們要執行緒A和執行緒B在單核單處理器的情況下,那麼只要載入的時候加一個鎖,如果執行緒A先執行,這個鎖在它訪問時是開的,在要用這個資源之前就把這個鎖關上,然後做載入、計算和儲存,即便我當前在做載入完成了或者計算完成的時候,執行緒A被作業系統排程出去了,把執行緒B排程進來,B這邊也會面對這把鎖,當它要訪問下面資源的時候就會被鎖住,同時當前執行緒會被掛起,等到A執行的時候,最後儲存結果完了以後,把鎖開啟,執行緒B就可以從這裡繼續執行下去。而多個處理器,就像我們剛才講的,我這邊執行緒A對執行緒B是沒法進行任何干擾的,這兩個執行緒是完全同時執行的,所以這個時候你要用傳統的鎖是無法同步的,在多核處理器環境下,鎖的實現也需要基於原子操作。

下面我們就介紹一下C11標準中的原子物件型別。請看Keynote:


各位在使用原子操作的時候,尤其在GPU上面一定要當心,一般像GPU或者規範裡面都會有寫,它只能支援哪些資料型別,比如Metal裡面只支援int,不支援其他的。現在基本上原子物件都是大家看到的,全都是屬於整數類型範疇裡面的。另外上述的int對應的無符號都是支援的,比如,無符號原子整型就是atomic-uint等等。

原子物件的初始化有兩種方式,並且這兩種方式是應用於兩種不同場合的。請看Keynote:


這裡,大家要注意原始物件初始化過程本身是不保證對原子物件的原子性操作,我們要用原子物件做多核多執行緒的一個同步之前,就是說先要把它初始化,然後再分派多個執行緒對它進行操作。

下面談一下原子物件的載入與儲存,請看Keynote:


我們要把一個原子物件的值載入到一個普通的,即非原子物件中,我們不能直接用=操作符,而是應該用下面的atomic_load函式,它是將原子物件復賦值給一個普通的變數,如果要把一個普通的變數儲存到一個原子物件當中去,我們也是用atomic_store,將一個普通變數的值儲存到原子物件當中去。另外,對於atomic_store,我們對原子物件初始化的時候不要用這個,應該用前面提到的方法初始化。

講好了載入與儲存以後,這裡簡單介紹一下基本的算術邏輯操作。


C11標準是規定了五種可用以原子物件的算術邏輯操作,加、減、或、異或、與,在使用這五個操作的時候,也就是運算元型別不能是atomic_bool型別,對這種型別不支援。這五種操作運算對應到C11原子操作介面,都是屬於巨集函式,因為每一個編譯器實現都會不太一樣,分別是atomic_fetch作為字首,add就是加法,sub就是減法,or就是或,xor就是異或,and就是與。比如:我假定原子物件初始化為100,結果加10,原子物件本身是110沒有問題,但是我本身函式返回的值是100,也就是我在加這個原子物件之前的原子物件的值,100,這裡大家要當心,這個實現其實是有好處的。

下面來講一個高階的!因為上面的加法減法屬於比較上層的介面,原子操作就是採用無鎖,也就是我們要實現無鎖資料同步演算法的時候,最最本質就是要使用的一個東西就是原子條件原語(CAS)。


C11支援了一個CAS,各個硬體上支援的原子操作型別和形式會不同,X86處理器上,像上文提到的五種操作型別,都能夠直接支援,非常簡單,只要在這些指令之前加上字首look就可以,很多處理器是不直接支援原子加法原子減法操作,但是這個時候我們可以通過更底層,更根源的原子操作指令實現。  

比如x86處理器以及ARMv8.1架構等處理器直接提供了CAS指令作為原子條件原語。而ARMv7、ARMv8處理器則使用了另一種LL-SC,這兩種原子條件原語都可以作為Lock-free演算法工具。比如8086時代就有我們大家用的微機原理實驗,大家可能會用到一個XCHG,這條指令本身是具有原子性的,也就是我這邊寫的交換指令SWAP。DSP用的比較多的就是Bit test and set,這些條件原語比起CAS與LL_SC要低檔一些,只能用於同步鎖。比如我要實現多核多處理器環境下的我們可以用這些原子操作進行,但是他們本身是沒有辦法作為一個Lock-free的原子物件進行操作。在C11標準當中,就只提供的CAS這種,巨集函式介面名為atomic_compare_exchange_strong以及atomic_compare_exchange_weak,第一種是保證資料比較交換是成功還是失敗,結果馬上就會出來,而這個weak往往針對通過LL-SC指令模擬CAS,裡面會產生一些副作用,我做一次比較和交換的時候,我這個結果確實已經交換成功了,但是返回結果可能是失敗的,當然我們也可通過一次迴圈再一次迭代,然後直到它成功返回為止。

那麼我們再介紹巨集函式的時候,我以strong為例,函式原形是這個樣子,返回bool類形,這個函式的語義就是我先比較object的原子物件指標所指的原子物件,與expected所指的內容物件是否相同,如果這兩個指標所指的內容相同,我將desired的值儲存到object,並且最終返回,我這次修改操作是成功的。否則,也就是expected和object兩個內容不相同,這個時候會將object所指的值複製到expected,並且返回true為,我們使用介面的時候我們的操作步序往往是先將atomic原子物件指標的值先拿出來,放到一個普通的變數當中去,我們再去寫我object原子物件值的時候我們要用desired,寫進去的時候,我先比較expercted為和object是否相同,如果相同就說明我在做原子,從載入到做的過程當中外部沒有干擾,也就是我沒有存在另外一個執行緒也使用原子操作,對我當前的object物件進行修改,這個時候兩個內容是完全相同的,這個時候顯示成功。如果我先用desired對oject的值進行修改的時,這個值被其他執行緒修改了,也就是我在做atomic_load與atomic_compare_exchange_strong之間有一個縫隙,正好被另外一個執行緒抓住把柄,它在當前執行緒執行atomic_compare_exchange_strong前先修改了object的值,這樣就會出現兩個值不同,這個時候就會返回false。

下面我就現場給大家大家展示一些DEMO例項(此處略)。