調試經驗 | C++ memory order和一個相關的穩定性問題

1 引言

在網上看了不少memory order的文章,結果越看越糊塗。本覺得懂了,結果碰到問題仍是不懂。反反覆覆,最終才造成一套能夠邏輯自洽的解釋。記錄在此,既但願減小後來者被錯誤文檔誤導的痛苦,也但願有高手能夠不吝賜教指點一二。android

在闡述memory order以前,首先須要介紹memory model,中文翻譯爲「內存模型」。關於這個概念,網上有很多文不對題的解釋,尤爲是Java的memory model。很多文章將將它理解爲JVM中各塊內存區域的分佈和做用,實際上是張冠李戴了。c++

  • Java memory model: Java內存模型,它是對於語言的描述,屬於一個抽象的概念。Java語言經過JVM跑在不一樣的操做系統和硬件平臺上,而不一樣硬件平臺對於代碼的優化策略是不一樣的。過分優化雖然可以得到更好的性能,但也會下降程序的可編程性,使得併發的程序結果與預期不符。所以一方面爲了限制底層的優化策略(告訴他們什麼能夠作,什麼不能夠作),另外一方面讓程序員能夠明確的獲知併發程序將來的運行狀況,最終語言的設計者在兩者之間定下一份「契約」,雙方都按照這份「契約」來進行本身關於併發的全部操做。這份「契約」就叫作內存模型。
  • JVM memory structure: JVM內存結構,它是對於虛擬機的描述,屬於一個具體的概念。它描述了JVM運行後內存中各塊區域的做用及其中存儲的數據類型。

2 C++ Memory Order

2.1 Memory Order的基本概念

C++內存模型和Java內存模型同樣,都屬於語言層面的抽象規範。而memory order則屬於其中的子概念,於C++11中正式被引入。它定義了一個原子操做與其附近全部其餘與memory交互的操做之間的重排(reorder)限制。爲了方便理解,其定義能夠被拆分爲如下幾個小點來細化:程序員

  1. memory order限定了兩個內存操做之間是否能夠被重排。一條CPU指令能夠是與內存有關的讀寫操做,也能夠是跳轉,數值運算之類的與內存無關的操做。因爲共享數據的競爭狀態只受到內存操做的影響,因此memory order也只限制內存操做之間的重排。
  2. memory order是與原子對象綁定的一個屬性,因此其限定的兩個內存操做中必然有一個是與之綁定的原子對象的讀寫操做。至於另一個操做對象是原子的仍是非原子的,答案是都有可能。
  3. memory order限定了操做是否能夠被重排,所以是針對單一線程的限定。至於兩個線程間的數據可見性特色,那是由於memory order對各自線程的重排操做作了限定後帶來的「附加好處」,而不是它自身定義的內容。

對於C++而言,普通開發者是不須要也不會接觸到memory order這個概念的,由於他們被保護得很好。這裏的「保護」指的是:編程

  1. 普通開發者對於併發時的數據競爭其實經過Mutex和Lock就已經足夠解決了。
  2. 當開發者使用Atomic對象時,它默認的memory order便是最爲嚴格的sequential consistent,所以能夠充分保證原子對象之間不會發生重排操做。

既然如此,那memory order這個概念爲何還會誕生?緣由也有兩個:markdown

  1. 普通開發者只知道用Mutex和Lock,但Mutex和Lock又是如何實現的呢?又或者說假設有大牛開發者想要實現一套屬於本身的Mutex庫呢?
  2. 雖然Mutex和Lock的開銷對於普通開發者可有可無,但對於某些追求極致性能的場合,這類開銷也會變得面目可憎。所以有些大牛開發者開始追求無鎖化編程,他們本身能夠處理好各類重排的可能,而且但願在語言方面放開對重排的限制。memory order越弱,指令可被優化的程度就越高。

2.2 Memory Order的詳細劃分

Memory order總共有6種類型,但這裏我只準備介紹4種。memory_order_consume和memory_order_acq_rel被排除在外的緣由以下:併發

  1. Hans Bohem,一直到2017年的ISO C++ Concurrency Study Group (WG21/SG1)主席,在CppCon 2016上的報告中明確指出memory_order_consume的設計尚有缺陷,建議你們不要使用。
  2. memory_order_acq_rel用在RMW(Read-Modify-Write)的操做上,但其語義本質就是memory_order_acquire和memory_order_release的結合。

接下來首先出場的是memory_order_acquire和memory_order_release。memory_order_acquire只用於原子化的load(讀操做)操做,而memory_order_release只用於原子化的store(寫操做)操做。其寫法一般以下所示:函數

std::atomic<int> x;
x.load(std::memory_order_acquire);
x.store(1, std::memory_order_release);
複製代碼

Memory_order_acquire

memory_order_acquire禁止了該load操做以後的全部讀寫操做(不論是原子對象仍是非原子對象)被重排到它以前去運行。oop

Memory_order_release

memory_order_release禁止了該store操做以前的全部讀寫操做(不論是原子對象仍是非原子對象)被重排到它以後去運行。性能

在同一個原子化對象上使用這兩種memory order將會獲得一個額外的好處,即兩個線程在知足某種條件時將會擁有特定的數據可見性。這句話比較拗口,下面用圖示來展開說明。優化

當flag.load在時間上晚發生於flag.store時,Thread 1上flag.store以前的全部操做對於Thread 2上flag.load以後的全部操做都是可見的。若是flag.load發生的時間早於flag.store,那麼兩個線程間則不擁有任何數據可見性。

爲了保證flag.load在時間上晚發生於flag.store,咱們能夠經過if邏輯來進行選擇。所以,下面的寫法將會永遠assert經過。

接着咱們考慮當全部Atomic對象的讀都採用memory_order_acquire,寫都採用memory_order_release時,兩個不一樣Atomic對象的操做之間是否會發生重排。

讀寫操做之間的關係總共有四種:讀讀,讀寫,寫寫,寫讀。對於前三種操做關係,memory_order_acquire和memory_order_release均可以保證兩條針對原子對象的操做不發生重排。但針對最後一種操做關係「寫讀」則沒法保證。緣由是前面一條指令是store,memory_order_release只能保證store以前的指令不重排到store以後,卻沒法禁止位於其後的load指令重排到它前面;後面一條指令是load,memory_order_acquire只能保證load以後的指令不重排到load以前,卻沒法禁止位於其前的store指令重排到它後面。最終store指令將有可能重排到load指令以後,這種沒法禁止的重排關係咱們簡稱爲SL。

Memory_order_acquire和memory_order_release是程度中等的memory order,比他們強一些的就叫作memory_order_seq_cst(sequential consistent),它只比memory_order_release/memory_order_acquire多一個功能,便可以禁止SL的重排。而memory_order_release則至關於自廢武功,兩個不一樣原子對象間的操做能夠隨便重排,它只保證針對同一個原子對象的操做不發生重排。

3 ART Mutex的問題

3.1 ART Mutex原理簡介

ART虛擬機實現了本身的Mutex,其中最關鍵的函數即是Mutex::ExclusiveLock和Mutex::ExclusiveUnlock。Android Q以前的Mutex實現代碼以下。

Mutex::ExclusiveLock:

其中最關鍵的操做是①和②:

①表示該Mutex多了一個競爭者,因爲是原子化對象的++操做,所以採用默認的memory order: memory_order_seq_cst。

②是一個RMW(Read-Modify-Write)操做,它會讀取state_的值,並和1進行比較。若是相等則將此線程掛起;若是不相等則直接返回0,讓該線程從新判斷是否能夠得到Mutex。因爲是默認的load操做,所以也採用memory_order_seq_cst。

Mutex::ExclusiveUnlock:

其中最關鍵的操做是③和④:

③也是一個RMW操做,它會讀取state_的值,並和cur_state進行比較。若是相等,則令state_等於0;若是不相等,則返回false。因爲是默認操做,因此load和store都採用memory_order_seq_cst。

④讀取num_contenders的值,可是傳入了memory_order_relaxed,代表對該操做作了最弱的重排限制。

3.2 老版本的Mutex爲何有問題?

上面的代碼在多數狀況下都沒有問題,可是按照以下的順序執行便會出錯。

Thread 1執行解鎖的操做,Thread 2執行上鎖的操做。

因爲③是RMW操做,實質上能夠拆分爲多條指令,③.a和③.b表示其中的load和store操做。CompareAndSet雖然是原子化操做,可是它只保證在執行過程當中該原子對象的值不會被外界改動。至於其餘指令是否能夠重排到③.a和③.b之間,則由具體的memory order決定。

操做④能夠被重排在③.a和③.b之間的緣由:

雖然③.a和③.b的memory order爲memory_order_seq_cst,可是當重排的另外一個操做不是memory_order_seq_cst修飾的原子化操做時,memory_order_seq_cst便退化成了memory_order_acquire或memory_order_release(取決於操做是load仍是store)。所以③.a只能限定④不重排到它以前,而③.b對④則沒有任何重排限制。所以,④能夠被重排到③.a和③.b之間。

從Thread 1的視角看,①和②都是針對原子對象的操做,所以兩者的執行順序必須等同代碼順序,也即①在②以前執行。另外①②在③.a和③.b中間執行並不影響③的原子性,所以也是被容許的。

一旦程序按照這樣的順序執行,便會致使Thread 2釋放鎖但不喚醒Thread 1,以致於Thread 1一直睡下去。而究其緣由,這一切都是由④重排到③.a和③.b之間致使的。

3.3 Mutex的問題該如何修復?

如今咱們從新思考③.b和④之間的關係。

③.b是一個原子化對象的store操做,④是一個原子化對象的load操做。這是一個典型的SL(store load)情形,而限制他們被重排只有一種方法:將兩者都用memory_order_seq_cst修飾。

所以解決的方案也比較簡單,也即將num_contenders_的memory order改成memory_order_seq_cst。

固然,這麼更改之後會對性能產生必定的影響。由於Mutex在虛擬機中被大量的使用,它任何小小的改動都會影響深遠。正是由於這個緣由,Google在Android Q上對Mutex的實現進行了優化,將num_contenders_和state_合併爲一個原子化對象,這樣就不存在兩個不一樣原子化對象操做之間的重排關係了。合併後的原子化對象叫作state_and_contenders_,其最低位的0或1表明state_,而高位的數字除以2便表明num_contenders_。

具體的change在這裏,感興趣的夥伴能夠繼續研究。

相關文章
相關標籤/搜索