home · archive · links · projects

理解Memory Order

C++11和C11不約而同地都在新標準中添加了memory order相關的內容,這是伴隨多核處理器和越來越大的處理器緩存而產生的需求。在多核處理器中,多個核心間雖然共享主內存(這裏不考慮NUMA),但是緩存卻是互相獨立的。如此,現代的CPU變得像是一個分佈式系統了。而memory order,就是這個分佈式系統中的一個非常重要的同步機制。

新標準中的memory order包括:

其實還有一個consume,但是主流編譯器都沒有針對它的實現,都是自動當成 acquire處理的。所以consume可以忽略不計。

首先從acquire和release開始介紹。這兩個操作的名字來自於互斥鎖的獲取和釋放。這裏,可以用Git來輔助理解。計算機的主內存可以看成是中央的Git倉庫,比如GitHub、GitLab之類;而各個CPU的緩存則可以看成是分佈在各地的開發者的本地Git倉庫。

當一個CPU核心上的線程讀寫內存的時候,它可能其實讀寫的是緩存,這些讀寫操作(load/store)不一定會立刻同步到主內存中。此時,另一個CPU核心上的另一個線程可能也在讀寫同一段內存,這就會導致發生不一致。如果用Git類比,就是發生了衝突。在Git操作中,我們可以手動解決衝突,但是瞬息萬變的CPU自然不會有這種機制,一旦發生了衝突,只會根據先來後到的順序發生覆蓋,可能會產生競態,嚴重的話甚至可能導致進程崩潰。

而acquire和release就類似於pull/push。其中,acquire類似於git pull,會將 acquire操作前的主內存狀態都拉取下來,保證當前cache的狀態是最新的。而 release則類似於git push,會將release操作前對緩存的修改都同步到主內存當中。而acq-rel,則如其名,適用於先讀再寫的原子操作,在讀取前拉取,在寫入後同步。這裏,我們就可以通過同步和原子操作保證關鍵的操作不會發生衝突,保證不會有競態。

不過,在x86/x64這樣的架構當中,其實對所有內存中的變量的操作自動就是 acquire-release的,這被稱爲強內存模型;相反,ARM架構下就沒有這種保證了,這類架構被稱爲弱內存模型。可是,即使在x86/x64中,也不可以掉以輕心,編譯器可能會對程序進行一些激進的優化,Load/Store操作可能會有重排。而 acquire和release則會告訴編譯器,在這裏重排是不允許的。例如,acquire因爲需要保證該操作之前的所有讀寫都會被同步到主內存,所以acquire之後的讀寫操作不可以被重排到acquire之前;與之類似,release操作之前的讀寫操作也不可以被重排到release之後。

使用acuqire-release的一個典型場景是智能指針的引用計數。在智能指針離開作用域的時候,引用計數會減去1;如果計數歸零,就需要析構並回收內存。這裏就有必要用到acquire-release。首先,因爲可能需要回收內存,所以要保證其他CPU核心上的操作都在本線程可見,以保證回收行爲正確;其次,回收完之後,也要讓其他的線程都知道這件事情;同時,這裏要禁止編譯器隨意優化,重排讀寫順序。所以,acquire-release就是必要的。

而sequentially-consistent比acquire-release的行爲還要嚴謹,不僅僅會同步到主內存,還會保證所有的CPU核心上的緩存都得到更新,實現了和單核類似的效果,而代價是速度緩慢。爲了減少開發者的困惑,seq-cst是C++11中,原子操作的默認行爲。相比之下,release不會保證所有的線程都看到當前線程的修改,其它線程只有在acquire的時候才能保證一定會看到release之前的修改。

而relaxed則完全不涉及同步,只是保證了當前進行原子操作的變量的讀寫是原子的。relaxed常用於引用計數中的加1,因爲獲取引用絕對不會觸發析構和free 操作,所以絕對不會導致double free和leak,只需要對計數變量本身的操作是原子的就可以了。

不過,原子操作和memory order依然是危險的操作,需要慎之又慎。如果條件允許,最好還是能用通信取代共享;如果不可以,絕大多數情況下,mutex和 condvar也夠用了。

參考


© Licensed under CC BY-NC-SA 4.0 if not specified otherwise.
Email: dzshy [at] outlook [dot] com