併發面試必備系列之併發基礎與內存模型

座標上海松江高科技園,誠聘高級前端工程師/高級 Java 工程師,有興趣的看 JD:https://www.lagou.com/jobs/63...

併發面試必備系列之併發基礎與內存模型

《Awesome Interviews》 歸納的常見面試題中,無論前後端,併發與異步的相關知識都是面試的中重中之重,本系列即對於面試中常見的併發知識再進行回顧總結;你也可以前往 《Awesome Interviews》,在實際的面試題考校中瞭解自己的掌握程度。也可以前往《Java 實戰》、《Go 實戰》等了解具體編程語言中的併發編程的相關知識。

隨着硬件性能的迅猛發展與大數據時代的來臨,爲了讓代碼運行得更快,單純依靠更快的硬件已無法滿足要求,並行和分佈式計算是現代應用程序的主要內容;我們需要利用多個核心或多臺機器來加速應用程序或大規模運行它們,併發編程日益成爲編程中不可忽略的重要組成部分。

簡單定義來看,如果執行單元的邏輯控制流在時間上重疊,那它們就是併發(Concurrent)的;由此定義可擴展到非常廣泛的概念,其向下依賴於操作系統、存儲等,與分佈式系統、微服務等,而又會具體落地於 Java 併發編程、Go 併發編程、JavaScript 異步編程等領域。雲計算承諾在所有維度上(內存、計算、存儲等)實現無限的可擴展性,併發編程及其相關理論也是我們構建大規模分佈式應用的基礎。

併發編程

併發與並行

併發就是可同時發起執行的程序,指程序的邏輯結構;並行就是可以在支持並行的硬件上執行的併發程序,指程序的運⾏狀態。換句話說,併發程序代表了所有可以實現併發行爲的程序,這是一個比較寬泛的概念,並行程序也只是他的一個子集。併發是並⾏的必要條件;但併發不是並⾏的充分條件。併發只是更符合現實問題本質的表達,目的是簡化代碼邏輯,⽽不是使程序運⾏更快。要是程序運⾏更快必是併發程序加多核並⾏。

簡言之,併發是同一時間應對(dealing with)多件事情的能力;並行是同一時間動手做(doing)多件事情的能力。

image.png

併發是問題域中的概念——程序需要被設計成能夠處理多個同時(或者幾乎同時)發生的事件;一個併發程序含有多個邏輯上的獨立執行塊,它們可以獨立地並行執行,也可以串行執行。而並行則是方法域中的概念——通過將問題中的多個部分並行執行,來加速解決問題。一個並行程序解決問題的速度往往比一個串行程序快得多,因爲其可以同時執行整個任務的多個部分。並行程序可能有多個獨立執行塊,也可能僅有一個。

具體而言,早期的 Redis(6.0 版本後也引入了多線程) 會是一個很好地區分併發和並行的例子,它本身是一個單線程的數據庫,但是可以通過多路複用與事件循環的方式來提供併發地 IO 服務。這是因爲多核並行本質上會有很大的一個同步的代價,特別是在鎖或者信號量的情況下。因此,Redis 利用了單線程的事件循環來保證一系列的原子操作,從而保證了即使在高併發的情況下也能達到幾乎零消耗的同步。再引用下 Rob Pike 的描述:

A single-threaded program can definitely provides concurrency at the IO level by using an IO (de)multiplexing mechanism and an event loop (which is what Redis does).

併發維度

線程級併發

從 20 世紀 60 年代初期出現時間共享以來,計算機系統中就開始有了對併發執行的支持;傳統意義上,這種併發執行只是模擬出來的,是通過使一臺計算機在它正在執行的進程間快速切換的方式實現的,這種配置稱爲單處理器系統。從 20 世紀 80 年代開始,多處理器系統,即由單操作系統內核控制的多處理器組成的系統採用了多核處理器與超線程(HyperThreading)等技術允許我們實現真正的並行。多核處理器是將多個 CPU 集成到一個集成電路芯片上:

image

超線程,有時稱爲同時多線程(simultaneous multi-threading),是一項允許一個 CPU 執行多個控制流的技術。它涉及 CPU 某些硬件有多個備份,比如程序計數器和寄存器文件;而其他的硬件部分只有一份,比如執行浮點算術運算的單元。常規的處理器需要大約 20 000 個時鐘週期做不同線程間的轉換,而超線程的處理器可以在單個週期的基礎上決定要執行哪一個線程。這使得 CPU 能夠更好地利用它的處理資源。例如,假設一個線程必須等到某些數據被裝載到高速緩存中,那 CPU 就可以繼續去執行另一個線程。

指令級併發

在較低的抽象層次上,現代處理器可以同時執行多條指令的屬性稱爲指令級並行。實每條指令從開始到結束需要長得多的時間,大約 20 個或者更多的週期,但是處理器使用了非常多的聰明技巧來同時處理多達 100 條的指令。在流水線中,將執行一條指令所需要的活動劃分成不同的步驟,將處理器的硬件組織成一系列的階段,每個階段執行一個步驟。這些階段可以並行地操作,用來處理不同指令的不同部分。我們會看到一個相當簡單的硬件設計,它能夠達到接近於一個時鐘週期一條指令的執行速率。如果處理器可以達到比一個週期一條指令更快的執行速率,就稱之爲超標量(Super Scalar)處理器。

單指令、多數據

在最低層次上,許多現代處理器擁有特殊的硬件,允許一條指令產生多個可以並行執行的操作,這種方式稱爲單指令、多數據,即 SIMD 並行。例如,較新的 Intel 和 AMD 處理器都具有並行地對 4 對單精度浮點數(C 數據類型 float)做加法的指令。

同步、異步、阻塞、非阻塞

在併發與並行的基礎概念之後,我們還需要了解同步、異步、阻塞與非阻塞這幾個概念的關係與區別。

同步即執行某個操作開始後就一直等着按部就班的直到操作結束,異步即執行某個操作後立即離開,後面有響應的話再來通知執行者。從編程的角度來看,如果同步調用,則調用的結果會在本次調用後返回。如果異步調用,則調用的結果不會直接返回。會返回一個 Future 或者 Promise 對象來供調用方主動/被動的獲取本次調用的結果。

而阻塞與非阻塞在併發編程中,主要是從對於臨界區公共資源或者共享數據競態訪問的角度來進行區分。某個操作需要的共享資源被佔用了,只能等待,稱爲阻塞;某個操作需要的共享資源被佔用了,不等待立即返回,並攜帶錯誤信息回去,期待重試,則稱爲非阻塞。

值得一提的是,在併發 IO 的討論中,我們還會出現同步非阻塞的 IO 模型,這是因爲 IO 操作(read/write 系統調用)其實包含了發起 IO 請求與實際的 IO 讀寫這兩個步驟。阻塞 IO 和非阻塞 IO 的區別在於第一步,發起 IO 請求的進程是否會被阻塞,如果阻塞直到 IO 操作完成才返回那麼就是傳統的阻塞 IO,如果不阻塞,那麼就是非阻塞 IO。同步 IO 和異步 IO 的區別就在於第二步,實際的 IO 讀寫(內核態與用戶態的數據拷貝)是否需要進程參與,如果需要進程參與則是同步 IO,如果不需要進程參與就是異步 IO。如果實際的 IO 讀寫需要請求進程參與,那麼就是同步 IO;因此阻塞 IO、非阻塞 IO、IO 複用、信號驅動 IO 都是同步 IO。

併發級別

在實際的部署環境下,受限於 CPU 的數量,我們不可能無限制地增加線程數量,不同場景需要的併發需求也不一樣;譬如秒殺系統中我們強調高併發高吞吐,而對於一些下載服務,則更強調快響應低時延。因此根據不同的需求場景我們也可以定義不同的併發級別:

  • 阻塞:阻塞是指一個線程進入臨界區後,其它線程就必須在臨界區外等待,待進去的線程執行完任務離開臨界區後,其它線程才能再進去。
  • 無飢餓:線程排隊先來後到,不管優先級大小,先來先執行,就不會產生飢餓等待資源,也即公平鎖;相反非公平鎖則是根據優先級來執行,有可能排在前面的低優先級線程被後面的高優先級線程插隊,就形成飢餓
  • 無障礙:共享資源不加鎖,每個線程都可以自有讀寫,單監測到被其他線程修改過則回滾操作,重試直到單獨操作成功;風險就是如果多個線程發現彼此修改了,所有線程都需要回滾,就會導致死循環的回滾中,造成死鎖
  • 無鎖:無鎖是無障礙的加強版,無鎖級別保證至少有一個線程在有限操作步驟內成功退出,不管是否修改成功,這樣保證了多個線程回滾不至於導致死循環
  • 無等待:無等待是無鎖的升級版,併發編程的最高境界,無鎖只保證有線程能成功退出,但存在低級別的線程一直處於飢餓狀態,無等待則要求所有線程必須在有限步驟內完成退出,讓低級別的線程有機會執行,從而保證所有線程都能運行,提高併發度。

量化模型

多線程不意味着併發,但併發肯定是多線程或者多進程;多線程存在的優勢是能夠更好的利用資源,有更快的請求響應。但是我們也深知一旦進入多線程,附帶而來的是更高的編碼複雜度,線程設計不當反而會帶來更高的切換成本和資源開銷。如何衡量多線程帶來的效率提升呢,我們需要藉助兩個定律來衡量。

Amdahl 定律

Amdahl 定律可以用來計算處理器平行運算之後效率提升的能力,其由 Gene Amdal 在 1967 年提出;它描述了在一個系統中,基於可並行化和串行化的組件各自所佔的比重,程序通過獲得額外的計算資源,理論上能夠加速多少。任何程序或算法可以按照是否可以被並行化分爲可以被並行化的部分 1 - B 與不可以被並行化的部分 B,那麼根據 Amdahl 定律,不同的並行因子的情況下程序的總執行時間的變化如下所示:

如果 F 是必須串行化執行的比重,那麼 Amdahl 定律告訴我們,在一個 N 處理器的機器中,我們最多可以加速:

當 N 無限增大趨近無窮時,speedup 的最大值無限趨近 1/F,這意味着一個程序中如果 50% 的處理都需要串行進行的話,speedup 只能提升 2 倍(不考慮事實上有多少線程可用);如果程序的 10% 需要串行進行,speedup 最多能夠提高近 10 倍。

Amdahl 定律同樣量化了串行化的效率開銷。在擁有 10 個處理器的系統中,程序如果有 10% 是串行化的,那麼最多可以加速 5.3 倍(53 %的使用率),在擁有 100 個處理器的系統中,這個數字可以達到 9.2(9 %的使用率)。這使得無效的 CPU 利用永遠不可能到達 10 倍。下圖展示了隨着串行執行和處理器數量變化,處理器最大限度的利用率的曲線。隨着處理器數量的增加,我們很明顯地看到,即使串行化執行的程度發 生細微的百分比變化,都會大大限制吞吐量隨計算資源增加。

Amdahl 定律旨在說明,多核 CPU 對系統進行優化時,優化的效果取決於 CPU 的數量以及系統中的串行化程序的比重;如果僅關注於提高 CPU 數量而不降低程序的串行化比重,也無法提高系統性能。

Gustafson

系統優化某部件所獲得的系統性能的改善程度,取決於該部件被使用的頻率,或所佔總執行時間的比例。

內存模型

如前文所述,現代計算機通常有兩個或者更多的 CPU,一些 CPU 還有多個核;其允許多個線程同時運行,每個 CPU 在某個時間片內運行其中的一個線程。在存儲管理一節中我們介紹了計算機系統中的不同的存儲類別:

image

每個 CPU 包含多個寄存器,這些寄存器本質上就是 CPU 內存;CPU 在寄存器中執行操作的速度會比在主內存中操作快非常多。每個 CPU 可能還擁有 CPU 緩存層,CPU 訪問緩存層的速度比訪問主內存塊很多,但是卻比訪問寄存器要慢。計算機還包括主內存(RAM),所有的 CPU 都可以訪問這個主內存,主內存一般都比 CPU 緩存大很多,但速度要比 CPU 緩存慢。當一個 CPU 需要訪問主內存的時候,會把主內存中的部分數據讀取到 CPU 緩存,甚至進一步把緩存中的部分數據讀取到內部的寄存器,然後對其進行操作。當 CPU 需要向主內存寫數據的時候,會將寄存器中的數據寫入緩存,某些時候會將數據從緩存刷入主內存。無論從緩存讀還是寫數據,都沒有必要一次性全部讀出或者寫入,而是僅對部分數據進行操作。

併發編程中的問題,往往源於緩存導致的可見性問題、線程切換導致的原子性問題以及編譯優化帶來的有序性問題。以 Java 虛擬機爲例,每個線程都擁有一個屬於自己的線程棧(調用棧),隨着線程代碼的執行,調用棧會隨之改變。線程棧中包含每個正在執行的方法的局部變量。每個線程只能訪問屬於自己的棧。調用棧中的局部變量,只有創建這個棧的線程纔可以訪問,其他線程都不能訪問。即使兩個線程在執行一段相同的代碼,這兩個線程也會在屬於各自的線程棧中創建局部變量。因此,每個線程擁有屬於自己的局部變量。所有基本類型的局部變量全部存放在線程棧中,對其他線程不可見。一個線程可以把基本類型拷貝到其他線程,但是不能共享給其他線程,而無論哪個線程創建的對象都存放在堆中。

原子性

所謂的原子性,就是一個或者多個操作在 CPU 執行的過程中不被中斷的特性,CPU 能保證的原子操作是 CPU 指令級別的,而不是高級語言的操作符。我們在編程語言中部分看似原子操作的指令,在被編譯到彙編之後往往會變成多個操作:

i++

# 編譯成彙編之後就是:
# 讀取當前變量 i 並把它賦值給一個臨時寄存器;
movl i(%rip), %eax
# 給臨時寄存器+1;
addl $1, %eax
# 把 eax 的新值寫回內存
movl %eax, i(%rip)

我們可以清楚看到 C 代碼只需要一句,但編譯成彙編卻需要三步(這裏不考慮編譯器優化,實際上通過編譯器優化可以將這三條彙編指令合併成一條)。也就是說,只有簡單的讀取、賦值(而且必須是將數字賦值給某個變量,變量之間的相互賦值不是原子操作)纔是原子操作。按照原子操作解決同步問題方式:依靠處理器原語支持把上述三條指令合三爲一,當做一條指令來執行,保證在執行過程中不會被打斷並且多線程併發也不會受到干擾。這樣同步問題迎刃而解,這也就是所謂的原子操作。但處理器沒有義務爲任意代碼片段提供原子性操作,尤其是我們的臨界區資源十分龐大甚至大小不確定,處理器沒有必要或是很難提供原子性支持,此時往往需要依賴於鎖來保證原子性。

對應原子操作/事務在 Java 中,對基本數據類型的變量的讀取和賦值操作是原子性操作,即這些操作是不可被中斷的,要麼執行,要麼不執行。Java 內存模型只保證了基本讀取和賦值是原子性操作,如果要實現更大範圍操作的原子性,可以通過 synchronized 和 Lock 來實現。由於 synchronized 和 Lock 能夠保證任一時刻只有一個線程執行該代碼塊,那麼自然就不存在原子性問題了,從而保證了原子性。

有序性

顧名思義,有序性指的是程序按照代碼的先後順序執行。現代編譯器的代碼優化和編譯器指令重排可能會影響到代碼的執行順序。編譯期指令重排是通過調整代碼中的指令順序,在不改變代碼語義的前提下,對變量訪問進行優化。從而儘可能的減少對寄存器的讀取和存儲,並充分複用寄存器。但是編譯器對數據的依賴關係判斷只能在單執行流內,無法判斷其他執行流對競爭數據的依賴關係。就拿無鎖環形隊列來說,如果 Writer 做的是先放置數據,再更新索引的行爲。如果索引先於數據更新,Reader 就有可能會因爲判斷索引已更新而讀到髒數據。

禁止編譯器對該類變量的優化,解決了編譯期的重排序並不能保證有序性,因爲 CPU 還有亂序執行(Out-of-Order Execution)的特性。流水線(Pipeline)和亂序執行是現代 CPU 基本都具有的特性。機器指令在流水線中經歷取指、譯碼、執行、訪存、寫回等操作。爲了 CPU 的執行效率,流水線都是並行處理的,在不影響語義的情況下。處理器次序(Process Ordering,機器指令在 CPU 實際執行時的順序)和程序次序(Program Ordering,程序代碼的邏輯執行順序)是允許不一致的,即滿足 As-if-Serial 特性。顯然,這裏的不影響語義依舊只能是保證指令間的顯式因果關係,無法保證隱式因果關係。即無法保證語義上不相關但是在程序邏輯上相關的操作序列按序執行。從此單核時代 CPU 的 Self-Consistent 特性在多核時代已不存在,多核 CPU 作爲一個整體看,不再滿足 Self-Consistent 特性。

簡單總結一下,如果不做多餘的防護措施,單核時代的無鎖環形隊列在多核 CPU 中,一個 CPU 核心上的 Writer 寫入數據,更新 index 後。另一個 CPU 核心上的 Reader 依靠這個 index 來判斷數據是否寫入的方式不一定可靠。index 有可能先於數據被寫入,從而導致 Reader 讀到髒數據。

在 Java 中與有序性相關的經典問題就是單例模式,譬如我們會採用靜態函數來獲取某個對象的實例,並且使用 synchronized 加鎖來保證只有單線程能夠觸發創建,其他線程則是直接獲取到實例對象。

if (instance == null) {
    synchronized(Singleton.class) {
        if (instance == null){
            instance = new Singleton();
        }
    }
}

不過雖然我們期望的對象創建的過程是:內存分配、初始化對象、將對象引用賦值給成員變量,但是實際情況下經過優化的代碼往往會首先進行變量賦值,而後進行對象初始化。假設線程 A 先執行 getInstance() 方法,當執行完指令 2 時恰好發生了線程切換,切換到了線程 B 上;如果此時線程 B 也執行 getInstance() 方法,那麼線程 B 在執行第一個判斷時會發現 instance != null,所以直接返回 instance,而此時的 instance 是沒有初始化過的,如果我們這個時候訪問 instance 的成員變量就可能觸發空指針異常。

可見性

所謂的可見性,即是一個線程對共享變量的修改,另外一個線程能夠立刻看到。單核時代,所有的線程都是直接操作單個 CPU 的數據,某個線程對緩存的寫對另外一個線程來說一定是可見的;譬如下圖中,如果線程 B 在線程 A 更新了變量值之後進行訪問,那麼獲得的肯定是變量 V 的最新值。多核時代,每顆 CPU 都有自己的緩存,共享變量存儲在主內存。運行在某個 CPU 中的線程將共享變量讀取到自己的 CPU 緩存。在 CPU 緩存中,修改了共享對象的值,由於 CPU 並未將緩存中的數據刷回主內存,導致對共享變量的修改對於在另一個 CPU 中運行的線程而言是不可見的。這樣每個線程都會擁有一份屬於自己的共享變量的拷貝,分別存於各自對應的 CPU 緩存中。

CPU 讀寫流程

傳統的 MESI 協議中有兩個行爲的執行成本比較大。一個是將某個 Cache Line 標記爲 Invalid 狀態,另一個是當某 Cache Line 當前狀態爲 Invalid 時寫入新的數據。所以 CPU 通過 Store Buffer 和 Invalidate Queue 組件來降低這類操作的延時。如圖:

當一個核心在 Invalid 狀態進行寫入時,首先會給其它 CPU 核發送 Invalid 消息,然後把當前寫入的數據寫入到 Store Buffer 中。然後異步在某個時刻真正的寫入到 Cache Line 中。當前 CPU 核如果要讀 Cache Line 中的數據,需要先掃描 Store Buffer 之後再讀取 Cache Line(Store-Buffer Forwarding)。但是此時其它 CPU 核是看不到當前核的 Store Buffer 中的數據的,要等到 Store Buffer 中的數據被刷到了 Cache Line 之後纔會觸發失效操作。而當一個 CPU 覈收到 Invalid 消息時,會把消息寫入自身的 Invalidate Queue 中,隨後異步將其設爲 Invalid 狀態。和 Store Buffer 不同的是,當前 CPU 核心使用 Cache 時並不掃描 Invalidate Queue 部分,所以可能會有極短時間的髒讀問題。當然這裏的 Store Buffer 和 Invalidate Queue 的說法是針對一般的 SMP 架構來說的,不涉及具體架構。事實上除了 Store Buffer 和 Load Buffer,流水線爲了實現並行處理,還有 Line Fill Buffer/Write Combining Buffer 等組件。

典型案例:併發加

可見性問題最經典的案例即是併發加操作,如下兩個線程同時在更新變量 test 的 count 屬性域的值,第一次都會將 count=0 讀到各自的 CPU 緩存裏,執行完 count+=1 之後,各自 CPU 緩存裏的值都是 1,同時寫入內存後,我們會發現內存中是 1,而不是我們期望的 2。之後由於各自的 CPU 緩存裏都有了 count 的值,兩個線程都是基於 CPU 緩存裏的 count 值來計算,所以導致最終 count 的值都是小於 20000 的。

Thread th1 = new Thread(()->{
    test.add10K();
});

Thread th2 = new Thread(()->{
    test.add10K();
});

// 每個線程中對相同對象執行加操作
count += 1;

在 Java 中,如果多個線程共享一個對象,並且沒有合理的使用 volatile 聲明和線程同步,一個線程更新共享對象後,另一個線程可能無法取到對象的最新值。當一個共享變量被 volatile 修飾時,它會保證修改的值會立即被更新到主存,當有其他線程需要讀取時,它會去內存中讀取新值。通過 synchronized 和 Lock 也能夠保證可見性,synchronized 和 Lock 能保證同一時刻只有一個線程獲取鎖然後執行同步代碼,並且在釋放鎖之前會將對變量的修改刷新到主存當中。因此可以保證可見性。

Cache Line & False Sharing | 緩存行與僞共享

緩存系統中是以緩存行(Cache Line)爲單位存儲的,緩存行是 2 的整數冪個連續字節,一般爲 32-256 個字節。最常見的緩存行大小是 64 個字節。當多線程修改互相獨立的變量時,如果這些變量共享同一個緩存行,就會無意中影響彼此的性能,這就是僞共享。

image.png

若兩個變量放在同一個緩存行中,在多線程情況下,可能會相互影響彼此的性能。如上圖所示,CPU1 上的線程更新了變量 X,則 CPU 上的緩存行會失效,同一行的 Y 即使沒有更新也會失效,導致 Cache 無法命中。同樣地,若 CPU2 上的線程更新了 Y,則導致 CPU1 上的緩存行又失效。如果 CPU 經常不能命中緩存,則系統的吞吐量則會下降。這就是僞共享問題。

解決僞共享問題,可以在變量的前後都佔據一定的填充位置,儘量讓變量佔用一個完整的緩存行。如上圖中,CPU1 上的線程更新了 X,則 CPU2 上的 Y 則不會失效。同樣地,CPU2 上的線程更新了 Y,則 CPU1 的不會失效。參考 Java 內存佈局可知,所有對象都有兩個字長的對象頭。第一個字是由 24 位哈希碼和 8 位標誌位(如鎖的狀態或作爲鎖對象)組成的 Mark Word。第二個字是對象所屬類的引用。如果是數組對象還需要一個額外的字來存儲數組的長度。每個對象的起始地址都對齊於 8 字節以提高性能。因此當封裝對象的時候爲了高效率,對象字段聲明的順序會被重排序成下列基於字節大小的順序:

doubles (8) 和 longs (8)
ints (4) 和 floats (4)
shorts (2) 和 chars (2)
booleans (1) 和 bytes (1)
references (4/8)
<子類字段重複上述順序>

一條緩存行有 64 字節, 而 Java 程序的對象頭固定佔 8 字節(32 位系統)或 12 字節(64 位系統默認開啓壓縮, 不開壓縮爲 16 字節)。我們只需要填 6 個無用的長整型補上 6*8=48 字節,讓不同的 VolatileLong 對象處於不同的緩存行, 就可以避免僞共享了;64 位系統超過緩存行的 64 字節也無所謂,只要保證不同線程不要操作同一緩存行就可以。這個辦法叫做補齊(Padding):

public final static class VolatileLong
{
    public volatile long value = 0L;
    public long p1, p2, p3, p4, p5, p6; // 添加該行,錯開緩存行,避免僞共享
}

某些 Java 編譯器會將沒有使用到的補齊數據, 即示例代碼中的 6 個長整型在編譯時優化掉, 可以在程序中加入一些代碼防止被編譯優化。

public static long preventFromOptimization(VolatileLong v) {
    return v.p1 + v.p2 + v.p3 + v.p4 + v.p5 + v.p6;
}

屏障

編譯器優化亂序和 CPU 執行亂序的問題可以分別使用優化屏障 (Optimization Barrier)和內存屏障 (Memory Barrier)這兩個機制來解決:

  • 優化屏障 (Optimization Barrier):避免編譯器的重排序優化操作,保證編譯程序時在優化屏障之前的指令不會在優化屏障之後執行。這就保證了編譯時期的優化不會影響到實際代碼邏輯順序。
  • 內存屏障 (Memory Barrier)分爲寫屏障(Store Barrier)、讀屏障(Load Barrier)和全屏障(Full Barrier),其作用有兩個:防止指令之間的重排序、保證數據的可見性。

多處理器同時訪問共享主存,每個處理器都要對讀寫進行重新排序,一旦數據更新,就需要同步更新到主存上 (這裏並不要求處理器緩存更新之後立刻更新主存)。在這種情況下,代碼和指令重排,再加上緩存延遲指令結果輸出導致共享變量被修改的順序發生了變化,使得程序的行爲變得無法預測。爲了解決這種不可預測的行爲,處理器提供一組機器指令來確保指令的順序要求,它告訴處理器在繼續執行前提交所有尚未處理的載入和存儲指令。同樣的也可以要求編譯器不要對給定點以及周圍指令序列進行重排。這些確保順序的指令稱爲內存屏障。具體的確保措施在程序語言級別的體現就是內存模型的定義。

POSIX、C++、Java 都有各自的共享內存模型,實現上並沒有什麼差異,只是在一些細節上稍有不同。這裏所說的內存模型並非是指內存布 局,特指內存、Cache、CPU、寫緩衝區、寄存器以及其他的硬件和編譯器優化的交互時對讀寫指令操作提供保護手段以確保讀寫序。將這些繁雜因素可以籠統的歸納爲兩個方面:重排和緩存,即上文所說的代碼重排、指令重排和 CPU Cache。簡單的說內存屏障做了兩件事情:拒絕重排,更新緩存

C++11 提供一組用戶 API std::memory_order 來指導處理器讀寫順序。Java 使用 happens-before 規則來屏蔽具體細節保證,指導 JVM 在指令生成的過程中穿插屏障指令。內存屏障也可以在編譯期間指示對指令或者包括周圍指令序列不進行優化,稱之爲編譯器屏障,相當於輕量級內存屏障,它的工作同樣重要,因爲它在編譯期指導編譯器優化。屏障的實現稍微複雜一些,我們使用一組抽象的假想指令來描述內存屏障的工作原理。使用 MB_R、MB_W、MB 來抽象處理器指令爲宏:

  • MB_R 代表讀內存屏障,它保證讀取操作不會重排到該指令調用之後。
  • MB_W 代表寫內存屏障,它保證寫入操作不會重排到該指令調用之後。
  • MB 代表讀寫內存屏障,可保證之前的指令不會重排到該指令調用之後。

這些屏障指令在單核處理器上同樣有效,因爲單處理器雖不涉及多處理器間數據同步問題,但指令重排和緩存仍然影響數據的正確同步。指令重排是非常底層的且實 現效果差異非常大,尤其是不同體系架構對內存屏障的支持程度,甚至在不支持指令重排的體系架構中根本不必使用屏障指令。具體如何使用這些屏障指令是支持的 平臺、編譯器或虛擬機要實現的,我們只需要使用這些實現的 API(指的是各種併發關鍵字、鎖、以及重入性等,下節詳細介紹)。這裏的目的只是爲了幫助更好 的理解內存屏障的工作原理。

內存屏障的意義重大,是確保正確併發的關鍵。通過正確的設置內存屏障可以確保指令按照我們期望的順序執行。這裏需要注意的是內存屏蔽只應該作用於需要同步的指令或者還可以包含周圍指令的片段。如果用來同步所有指令,目前絕大多數處理器架構的設計就會毫無意義。

延伸閱讀

您可以通過以下導航來在 Gitbook 中閱讀筆者的系列文章,涵蓋了技術資料歸納、編程語言與理論、Web 與大前端、服務端開發與基礎架構、雲計算與大數據、數據科學與人工智能、產品設計等多個領域:

此外,你還可前往 xCompass 交互式地檢索、查找需要的文章/鏈接/書籍/課程;或者在 MATRIX 文章與代碼索引矩陣中查看文章與項目源代碼等更詳細的目錄導航信息。最後,你也可以關注微信公衆號:『某熊的技術之路』以獲取最新資訊。

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