座標上海松江高科技園,誠聘高級前端工程師/高級 Java 工程師,有興趣的看 JD:www.lagou.com/jobs/636156…javascript
在 《Awesome Interviews》 概括的常見面試題中,不管先後端,併發與異步的相關知識都是面試的中重中之重,本系列即對於面試中常見的併發知識再進行回顧總結;你也能夠前往 《Awesome Interviews》,在實際的面試題考校中瞭解本身的掌握程度。也能夠前往《Java 實戰》、《Go 實戰》等了解具體編程語言中的併發編程的相關知識。html
隨着硬件性能的迅猛發展與大數據時代的來臨,爲了讓代碼運行得更快,單純依靠更快的硬件已沒法知足要求,並行和分佈式計算是現代應用程序的主要內容;咱們須要利用多個核心或多臺機器來加速應用程序或大規模運行它們,併發編程日益成爲編程中不可忽略的重要組成部分。前端
簡單定義來看,若是執行單元的邏輯控制流在時間上重疊,那它們就是併發(Concurrent)的;由此定義可擴展到很是普遍的概念,其向下依賴於操做系統、存儲等,與分佈式系統、微服務等,而又會具體落地於 Java 併發編程、Go 併發編程、JavaScript 異步編程等領域。雲計算承諾在全部維度上(內存、計算、存儲等)實現無限的可擴展性,併發編程及其相關理論也是咱們構建大規模分佈式應用的基礎。java
併發就是可同時發起執行的程序,指程序的邏輯結構;並行就是能夠在支持並行的硬件上執行的併發程序,指程序的運⾏狀態。換句話說,併發程序表明了全部能夠實現併發行爲的程序,這是一個比較寬泛的概念,並行程序也只是他的一個子集。併發是並⾏的必要條件;但併發不是並⾏的充分條件。併發只是更符合現實問題本質的表達,目的是簡化代碼邏輯,⽽不是使程序運⾏更快。要是程序運⾏更快必是併發程序加多核並⾏。python
簡言之,併發是同一時間應對(dealing with)多件事情的能力;並行是同一時間動手作(doing)多件事情的能力。git
併發是問題域中的概念——程序須要被設計成可以處理多個同時(或者幾乎同時)發生的事件;一個併發程序含有多個邏輯上的獨立執行塊,它們能夠獨立地並行執行,也能夠串行執行。而並行則是方法域中的概念——經過將問題中的多個部分並行執行,來加速解決問題。一個並行程序解決問題的速度每每比一個串行程序快得多,由於其能夠同時執行整個任務的多個部分。並行程序可能有多個獨立執行塊,也可能僅有一個。程序員
具體而言,早期的 Redis(6.0 版本後也引入了多線程) 會是一個很好地區分併發和並行的例子,它自己是一個單線程的數據庫,可是能夠經過多路複用與事件循環的方式來提供併發地 IO 服務。這是由於多核並行本質上會有很大的一個同步的代價,特別是在鎖或者信號量的狀況下。所以,Redis 利用了單線程的事件循環來保證一系列的原子操做,從而保證了即便在高併發的狀況下也能達到幾乎零消耗的同步。再引用下 Rob Pike 的描述:github
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).web
從 20 世紀 60 年代初期出現時間共享以來,計算機系統中就開始有了對併發執行的支持;傳統意義上,這種併發執行只是模擬出來的,是經過使一臺計算機在它正在執行的進程間快速切換的方式實現的,這種配置稱爲單處理器系統。從 20 世紀 80 年代開始,多處理器系統,即由單操做系統內核控制的多處理器組成的系統採用了多核處理器與超線程(HyperThreading)等技術容許咱們實現真正的並行。多核處理器是將多個 CPU 集成到一個集成電路芯片上:面試
超線程,有時稱爲同時多線程(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 定律能夠用來計算處理器平行運算以後效率提高的能力,其由 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 數量而不下降程序的串行化比重,也沒法提升系統性能。
系統優化某部件所得到的系統性能的改善程度,取決於該部件被使用的頻率,或所佔總執行時間的比例。
如前文所述,現代計算機一般有兩個或者更多的 CPU,一些 CPU 還有多個核;其容許多個線程同時運行,每一個 CPU 在某個時間片內運行其中的一個線程。在存儲管理一節中咱們介紹了計算機系統中的不一樣的存儲類別:
每一個 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 緩存中。
傳統的 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)爲單位存儲的,緩存行是 2 的整數冪個連續字節,通常爲 32-256 個字節。最多見的緩存行大小是 64 個字節。當多線程修改互相獨立的變量時,若是這些變量共享同一個緩存行,就會無心中影響彼此的性能,這就是僞共享。
若兩個變量放在同一個緩存行中,在多線程狀況下,可能會相互影響彼此的性能。如上圖所示,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)這兩個機制來解決:
多處理器同時訪問共享主存,每一個處理器都要對讀寫進行從新排序,一旦數據更新,就須要同步更新到主存上 (這裏並不要求處理器緩存更新以後馬上更新主存)。在這種狀況下,代碼和指令重排,再加上緩存延遲指令結果輸出致使共享變量被修改的順序發生了變化,使得程序的行爲變得沒法預測。爲了解決這種不可預測的行爲,處理器提供一組機器指令來確保指令的順序要求,它告訴處理器在繼續執行前提交全部還沒有處理的載入和存儲指令。一樣的也能夠要求編譯器不要對給定點以及周圍指令序列進行重排。這些確保順序的指令稱爲內存屏障。具體的確保措施在程序語言級別的體現就是內存模型的定義。
POSIX、C++、Java 都有各自的共享內存模型,實現上並無什麼差別,只是在一些細節上稍有不一樣。這裏所說的內存模型並不是是指內存布 局,特指內存、Cache、CPU、寫緩衝區、寄存器以及其餘的硬件和編譯器優化的交互時對讀寫指令操做提供保護手段以確保讀寫序。將這些繁雜因素能夠籠統的概括爲兩個方面:重排和緩存,即上文所說的代碼重排、指令重排和 CPU Cache。簡單的說內存屏障作了兩件事情:拒絕重排,更新緩存。
C++11 提供一組用戶 API std::memory_order 來指導處理器讀寫順序。Java 使用 happens-before 規則來屏蔽具體細節保證,指導 JVM 在指令生成的過程當中穿插屏障指令。內存屏障也能夠在編譯期間指示對指令或者包括周圍指令序列不進行優化,稱之爲編譯器屏障,至關於輕量級內存屏障,它的工做一樣重要,由於它在編譯期指導編譯器優化。屏障的實現稍微複雜一些,咱們使用一組抽象的假想指令來描述內存屏障的工做原理。使用 MB_R、MB_W、MB 來抽象處理器指令爲宏:
這些屏障指令在單核處理器上一樣有效,由於單處理器雖不涉及多處理器間數據同步問題,但指令重排和緩存仍然影響數據的正確同步。指令重排是很是底層的且實 現效果差別很是大,尤爲是不一樣體系架構對內存屏障的支持程度,甚至在不支持指令重排的體系架構中根本沒必要使用屏障指令。具體如何使用這些屏障指令是支持的 平臺、編譯器或虛擬機要實現的,咱們只須要使用這些實現的 API(指的是各類併發關鍵字、鎖、以及重入性等,下節詳細介紹)。這裏的目的只是爲了幫助更好 的理解內存屏障的工做原理。
內存屏障的意義重大,是確保正確併發的關鍵。經過正確的設置內存屏障能夠確保指令按照咱們指望的順序執行。這裏須要注意的是內存屏蔽只應該做用於須要同步的指令或者還能夠包含周圍指令的片斷。若是用來同步全部指令,目前絕大多數處理器架構的設計就會毫無心義。
您能夠經過如下導航來在 Gitbook 中閱讀筆者的系列文章,涵蓋了技術資料概括、編程語言與理論、Web 與大前端、服務端開發與基礎架構、雲計算與大數據、數據科學與人工智能、產品設計等多個領域:
知識體系:《Awesome Lists | CS 資料集錦》、《Awesome CheatSheets | 速學速查手冊》、《Awesome Interviews | 求職面試必備》、《Awesome RoadMaps | 程序員進階指南》、《Awesome MindMaps | 知識脈絡思惟腦圖》、《Awesome-CS-Books | 開源書籍(.pdf)彙總》
編程語言:《編程語言理論》、《Java 實戰》、《JavaScript 實戰》、《Go 實戰》、《Python 實戰》、《Rust 實戰》
Web 與大前端:《現代 Web 開發基礎與工程實踐》、《數據可視化》、《iOS》、《Android》、《混合開發與跨端應用》
服務端開發實踐與工程架構:《服務端基礎》、《微服務與雲原生》、《測試與高可用保障》、《DevOps》、《Node》、《Spring》、《信息安全與滲透測試》
分佈式基礎架構:《分佈式系統》、《分佈式計算》、《數據庫》、《網絡》、《虛擬化與編排》、《雲計算與大數據》、《Linux 與操做系統》
數據科學,人工智能與深度學習:《數理統計》、《數據分析》、《機器學習》、《深度學習》、《天然語言處理》、《工具與工程化》、《行業應用》
此外,你還可前往 xCompass 交互式地檢索、查找須要的文章/連接/書籍/課程;或者在 MATRIX 文章與代碼索引矩陣中查看文章與項目源代碼等更詳細的目錄導航信息。最後,你也能夠關注微信公衆號:『某熊的技術之路』以獲取最新資訊。