您好,登錄后才能下訂單哦!
坐標上海松江高科技園,誠聘高級前端工程師/高級 Java 工程師,有興趣的看 JD:https://www.lagou.com/jobs/6361564.html
在 《Awesome Interviews》 歸納的常見面試題中,無論前后端,并發與異步的相關知識都是面試的中重中之重,本系列即對于面試中常見的并發知識再進行回顧總結;你也可以前往 《Awesome Interviews》,在實際的面試題考校中了解自己的掌握程度。也可以前往《Java 實戰》、《Go 實戰》等了解具體編程語言中的并發編程的相關知識。
隨著硬件性能的迅猛發展與大數據時代的來臨,為了讓代碼運行得更快,單純依靠更快的硬件已無法滿足要求,并行和分布式計算是現代應用程序的主要內容;我們需要利用多個核心或多臺機器來加速應用程序或大規模運行它們,并發編程日益成為編程中不可忽略的重要組成部分。
簡單定義來看,如果執行單元的邏輯控制流在時間上重疊,那它們就是并發(Concurrent)的;由此定義可擴展到非常廣泛的概念,其向下依賴于操作系統、存儲等,與分布式系統、微服務等,而又會具體落地于 Java 并發編程、Go 并發編程、JavaScript 異步編程等領域。云計算承諾在所有維度上(內存、計算、存儲等)實現無限的可擴展性,并發編程及其相關理論也是我們構建大規模分布式應用的基礎。
并發就是可同時發起執行的程序,指程序的邏輯結構;并行就是可以在支持并行的硬件上執行的并發程序,指程序的運?狀態。換句話說,并發程序代表了所有可以實現并發行為的程序,這是一個比較寬泛的概念,并行程序也只是他的一個子集。并發是并?的必要條件;但并發不是并?的充分條件。并發只是更符合現實問題本質的表達,目的是簡化代碼邏輯,?不是使程序運?更快。要是程序運?更快必是并發程序加多核并?。
簡言之,并發是同一時間應對(dealing with)多件事情的能力;并行是同一時間動手做(doing)多件事情的能力。
并發是問題域中的概念——程序需要被設計成能夠處理多個同時(或者幾乎同時)發生的事件;一個并發程序含有多個邏輯上的獨立執行塊,它們可以獨立地并行執行,也可以串行執行。而并行則是方法域中的概念——通過將問題中的多個部分并行執行,來加速解決問題。一個并行程序解決問題的速度往往比一個串行程序快得多,因為其可以同時執行整個任務的多個部分。并行程序可能有多個獨立執行塊,也可能僅有一個。
具體而言,早期的 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 集成到一個集成電路芯片上:
超線程,有時稱為同時多線程(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 th2 = new Thread(()->{
test.add10K();
});
Thread th3 = 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 文章與代碼索引矩陣中查看文章與項目源代碼等更詳細的目錄導航信息。最后,你也可以關注微信公眾號:『某熊的技術之路』以獲取最新資訊。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。