天天看點

敲黑闆!原子變量與記憶體模型是什麼鬼!

數十款阿裡雲産品限時折扣中,

趕緊點選這裡

,領劵開始雲上實踐吧!

演講嘉賓簡介:

陶雲峰,阿裡雲進階技術專家,上海交通大學理論計算機科學博士,專注資料存儲、分布式系統與計算等領域,寫了20多年程式。2000年參加ACM/ICPC大賽,實作亞洲隊伍進World Final前十的突破。

本次直播視訊精彩回顧,戳這裡!  本節課代碼及講義下載下傳,戳這裡!

本次分享主要包括以下内容:

1. 原子變量

2. 記憶體模型

3. spin lock

一、 原子變量

Atomic,即原子變量,是可以跨線程共享的變量。在下面這個例子中,有一個全局變量sExit,線程1中進行循環,隻要sExit為true則退出。線程2對是sExit進行操作,用于控制線程1的退出。在這個例子中,可能發生一種情形,雖然線程2執行了,但線程1仍然有可能不退出。對線程1來說,sExit看起來通路的是一個變量,但對CPU和編譯器來說,可能會把它放到cache甚至寄存器中。是以,當線程2寫記憶體時,線程1中仍不會發生改變。另一方面,也有可能線程2在寫sExit,寫到了寄存器或者cache中時,線程1也會無法退出。

敲黑闆!原子變量與記憶體模型是什麼鬼!

如果sExit加上關鍵字volatile,則線程1能退出,但理論上這段代碼是有問題的。volatile的本意是用來通路外部裝置,而非用于線程同步。以前的計算機中,一般通路外部裝置的接口會全部映射到一塊記憶體位址中,通路這塊位址時就相當于通路外部裝置。現在的驅動程式還是可以這樣做。而一般應用層的程式是不可以這樣操作的。對于線程同步問題來說,volatile能保證訪存,是以這個程式能正常運作。但volatile不保證原子性,也不對指令亂序加以限制。由于bool型隻有一個位元組,沒有比其更細的粒度,是以在這個場景下原子性不會有問題。而指令亂序問題,由于案例代碼不完整,是以無法得知。

敲黑闆!原子變量與記憶體模型是什麼鬼!

那麼,在什麼情況下會出現問題呢?如下面這個例子。由于不知道線程1和線程2哪個會先運作,是以線程1的x有多種可能的值。{1,2}或{3,4}是比較好了解的。但也有可能出現{3,2},由于線程2在指派時,實際的指派指令為兩條,當線程2中的a完成指派後,先執行了線程1。同時,{1,4}也是一種可能的情況。這是由于,線程2運作時,兩條指派指令操作的記憶體單元是不一樣的,是允許亂序執行的,即先執行b的指派操作。那麼就會出現{1,4}這樣的結果。

敲黑闆!原子變量與記憶體模型是什麼鬼!

如果将volatile改為atomic,就隻會出現{1,2}或{3,4}這樣的結果。

敲黑闆!原子變量與記憶體模型是什麼鬼!

前文中講解過mutex和各類鎖操作,那麼如果這裡利用mutex進行互斥,在臨界區中進行指派和讀值,是否就可以不用atomic了呢?從程式的邏輯上看,是對的。通過這兩種方式都可以使程式達到相同的執行流。但這兩種方法存在差別,使用mutex可能會陷入作業系統核心,将目前的線程挂入作業系統核心中的排程器的working隊列中,而atomic是純粹用CPU提供的硬體指令完成的,與作業系統無關。現代的mutex,比如高版本gcc提供的mutex,已經高度優化了,在無競争的情況下,性能和atomic一樣,事實上它也是利用atomic實作的。但在有激烈競争的情況下,mutex會陷入核心,一次核心調用大約在百納秒的量級上。而使用atomic利用CPU硬體指令隻需幾納秒。但atomic會霸占CPU,如果需要霸占CPU的線程很多,就會導緻程式整體性能的下降。

二、 記憶體模型

記憶體模型是和原子變量搭配使用的。這裡介紹三種記憶體模型。

1、 最終一緻性

第一種是最終一緻性。如下面這個例子。

敲黑闆!原子變量與記憶體模型是什麼鬼!

定義了一個int型的原子變量cnt,線程不斷進行加法操作。當所有線程結束時列印cnt的值。在這個例子中,隻要保證最終的加法結果正确即可,可以不關心執行順序。是以就可以使用“最終一緻性“記憶體模型——relaxed。這裡,relaxed保證了原子變量的一緻性,也保證了任何一個線程對原子變量的改變最終一定會被其他線程看到。但它有兩點無法保證 :第一,不保證一個線程對原子變量的改變,會以相同的順序被其他線程看到。(比如,先改a,再改b,其他線程看到的順序可能是先b後a。)第二,不保證一個線程對原子變量的改變,和其他訪存操作,會以相同的順序被其他線程看到。最終一緻性是最弱的一種一緻性條件。

2、 順序一緻性

下面介紹一種最強的一緻性條件,順序一緻性。在下面這個例子中,使用任何更弱一些的一緻性模型都是無法得到正确結果的。

敲黑闆!原子變量與記憶體模型是什麼鬼!

在這個例子中,定義了三個原子變量。其中x, y是初值為false的布爾型變量。線程1,2将這兩個變量置成true。線程3,4是對偶的。線程3,如果x為true則退出循環,此時,如果y是true,就進行++z。線程4,如果y為true則退出循環,此時,如果x是true,就進行++z。在順序一緻性模型中,可以保證最終z是大于0的。這是由于,線程1和線程2的訪存一定是有先後的。假設線程1先做,線程2後做,那麼線上程4中,當y為true時,x一定是true,一定會執行++z。反之,線程3一定會執行++z.。順序一緻性還保證了一個線程内有一個順序一緻性的原子變量訪存,就能保證其他非原子的訪存操作,既不能亂序到原子變量前,也不能把前面的指令跨過原子變量往後重排。順序一緻性是鎖記憶體總線的,并且強制各個CPU同步cache。它的性能較差,但安全性比較有保障。

3、 release-acquire

第三種記憶體模型release-acquire。以下面這段代碼為例。

敲黑闆!原子變量與記憶體模型是什麼鬼!

有原子變量ptr,有普通變量data。線程1中有局部變量指針p,儲存到ptr中,在此之前将data指派為42。線程2中,不斷從ptr中加載指針,一旦指針不是NULL,那麼一定能看到data==42。這是因為如果能讀到同一個原子變量的寫存,也一定能讀到在其之前的訪存,包括之前的原子訪存和普通訪存。這就是release-acquire模型。線程3是使用了relaxed記憶體模型的情況。最終輸出的data可能不為42。

release-acquire這個名稱怎麼來的呢?沒錯,互斥鎖。acquire代表獲得鎖,release代表釋放鎖。下面展示了一種互斥鎖的實作。

敲黑闆!原子變量與記憶體模型是什麼鬼!

定義了一個原子變量mtx。搶鎖時将true原子地賦給mtx。exchange函數會在把變量目前的值傳回的同時,将 新設的值寫入變量。如果傳回的值是false則說明目前無人持有鎖。如果是true就說明目前有人持有鎖 ,無法進臨界區,此時需要進行循環等待。出臨界區的時候隻需要将mtx賦為false。

下面簡單介紹一下現代CPU的架構(如下圖),以便讓大家更好的了解release-require為何是一個高效的記憶體模型。

敲黑闆!原子變量與記憶體模型是什麼鬼!

大家知道CPU的運算速度很快,而相對而言通路一次記憶體的速度要差一個數量級。是以,CPU一般不直接向記憶體要計算數,而是向L1 cache要。同時,CPU通常是多核的,這也就意味着,當計算單元完成運算寫入L1 cache時,L1 cache 需要将結果通知給其他核心,并寫入記憶體。是以,CPU寫入cache時也比較慢,是以會先寫入store buffer中,然後從store buffer寫入L1 cache,再由L1 cache通知其他核心。這一過程也相對較慢,是以核心也并不馬上做,而是把其他核心發送過來的更新通知放進一個隊列裡,即invalidate queue。當store buffer為空時表示其他核心都已經知道了自己寫入的内容。這一過程就是release,即等待其他CPU都知道目前核心寫的内容。而acquire就是等待invalidate queue隊列為空的過程,意味着目前CPU知道了其他CPU的所有寫操作。release-acquire是一個與目前CPU設計非常相符的,是以,它非常高效。

三、 spin lock

最後,介紹spin lock案例。

1、 TAS

第一個實作是Test-and-Set,即TAS。通過原子地對變量指派,并傳回老值進行上鎖。通過将false寫入,進行放鎖。邏輯上看,這段代碼沒有問題,但實際上它是不可用的,一旦出現争搶會使程式運作非常緩慢,并且CPU負載很高。

敲黑闆!原子變量與記憶體模型是什麼鬼!
2、TTAS

下面對TAS進行了改進,Test-Test-and-Set,即TTAS。

敲黑闆!原子變量與記憶體模型是什麼鬼!

在上鎖時,先通過relaxed讀mtx的值。一旦mtx為false,就用acquire的方式對mtx進行exchange,以保證正确性。由于relaxed是不需要清store buffer和invalidate queue的,是以它的效率相對較高。Relaxed讀到的其實是一個髒值。絕大多數情況下,invalidate queue是不空的,但queue中的值不是由于mtx寫造成的。是以,清理queue的過程會使得性能下降。此時,通過髒值來判斷mtx是否值得去搶鎖。換句話說,通過relaxed方式,減少了對mtx的争搶。是以,TTAS的性能會優于TAS。通常情況下,TTAS是可以實際使用的,但當競争更激烈的時候,它的性能還是不夠好。

3、backoff

下面介紹backoff退避鎖。使用前面的方法,通過relaxed判斷是否值得搶,可以一定程度上提高性能。但在這種情況下,一旦放鎖發生了,其他人幾乎會在同一時間進行争搶,性能還是會很差。這時,就有這樣一種設想,能否将争搶的過程打散。在放鎖的時候,隻安排少數人去搶鎖,這就是退避 。退避的原理是,如果搶不到鎖,就等待一會兒。如下面這段代碼所示。

敲黑闆!原子變量與記憶體模型是什麼鬼!

一旦嘗試搶鎖沒有成功,就進行退避,将原limit翻倍,并在舊limit和新limit之間取一個随機數,等待随機數對應的時間,然後通過調用sleep_for,進行等待。

下面我們對退避的性能做一個估算。

假設初始limit為10ms,臨界區耗時1ms,有100個線程搶鎖。

正确的做法:每次limit翻倍,實際退避時間在limit/2到limit之間随機。

1.第一輪隻有一個線程搶到鎖,其餘線程退避;

2.第二輪有5個線程搶到鎖;

3.第三輪有10個線程搶到鎖;

4. ……

5.最多需要6輪,最多耗時310ms

錯誤的做法:在limit之内取随機,然後每次對這個随機數翻倍。

2.第二輪有10個線程搶到鎖;

5.最多需要10輪,最多耗時10s+

退避鎖是一中實作簡單,實用的鎖。在多線程和分布式系統中都有非常廣泛的應用。在分布式系統中,可以認為鎖的臨界區就是web伺服器的busy狀态,例如web伺服器一秒鐘可以handle一萬個請求,當有一萬零一個請求時,就傳回busy狀态,可以使用退避的方式解決,讓web伺服器以最大的吞吐量來處理所有的請求。但在單個程序内,退避鎖并不是最好的解決方案。‘

4、CLH

下面介紹CLH。它的實作稍複雜些,mtx是一個原子變量的原子變量。代碼不再詳細介紹了。

敲黑闆!原子變量與記憶體模型是什麼鬼!

下面通過幾張圖檔介紹一下CLH的原理。

首先,mtx指針指向另一個布爾型的原子變量,初值為false。

敲黑闆!原子變量與記憶體模型是什麼鬼!

這時,線程1要搶鎖,它先new一個布爾型的原子變量,值為true。

敲黑闆!原子變量與記憶體模型是什麼鬼!

然後将兩個指針交換,線程1發現mtx原來的指針指向的是false,就搶到了鎖。

敲黑闆!原子變量與記憶體模型是什麼鬼!

這時,出現了線程2要搶鎖。它先new一個布爾型的原子變量,值為true。

敲黑闆!原子變量與記憶體模型是什麼鬼!

然後和mtx交換指針,線程2發現mtx原來的指針指向的是true,說明有線程占着鎖。

敲黑闆!原子變量與記憶體模型是什麼鬼!

線程1完成工作,放鎖,直接将藍色的原子變量改為false。 為什麼線程1可以直接修改藍色的原子變量呢?因為這個變量原本就是他配置設定出來的,它知道位址。這時,線程2看到了false,即搶到了鎖。

敲黑闆!原子變量與記憶體模型是什麼鬼!

CLH鎖本質上是一個隊列。讓同時搶鎖的線程排隊,并由前一個通知後一個,減少了争搶,同時避免了sleep造成的無效的等待。

最後,總結一下本次分享的主要内容。

敲黑闆!原子變量與記憶體模型是什麼鬼!

本文由雲栖志願小組馬JY整理,編輯百見