併發程式背後的故事以及併發當中的記憶體模型

y浴血發表於2020-12-09

併發程式幕後的故事

核心矛盾

併發當中有一個核心矛盾一直存在,就是三者( CPU、記憶體、I/O 裝置)的速度差異

CPU 和記憶體的速度差異可以形象地描述為:CPU執行一條指令假如花費一天時間的話,CPU讀寫記憶體需要等待一年,而記憶體和 I/O 裝置的速度差異就更大了,記憶體是天上一天,I/O 裝置是地上十年。所以就存在一個問題,單獨提高CPU是沒有作用的,因為根據木桶理論,程式整體的效能取決於最慢的操作->那就是讀寫 I/O 裝置

為了合理利用 CPU 的高效能,平衡這三者的速度差異,計算機體系機構、作業系統、編譯程式都做出了貢獻,主要體現為:

  1. CPU 增加了快取,以均衡與記憶體的速度差異;
  2. 作業系統增加了程式、執行緒,以分時複用 CPU,進而均衡 CPU 與 I/O 裝置的速度差異;
  3. 編譯程式優化指令執行次序,使得快取能夠得到更加合理地利用。

併發問題的存在有以下三種原因

源頭之一:快取導致的可見性問題

在單核時代,所有的執行緒都是在一顆 CPU 上執行,CPU 快取與記憶體的資料一致性容易解決。因為所有執行緒都是操作同一個 CPU 的快取,一個執行緒對快取的寫,對另外一個執行緒來說一定是可見的。

單核

一個執行緒對共享變數的修改,另外一個執行緒能夠立刻看到,我們稱為可見性

多核時代,每顆 CPU 都有自己的快取,這時 CPU 快取與記憶體的資料一致性就沒那麼容易解決了,當多個執行緒在不同的 CPU 上執行時,這些執行緒操作的是不同的 CPU 快取。此時出現兩個執行緒就沒有所謂的可見性了

多核

我們假設執行緒 1 和執行緒 2 同時開始執行,那麼第一次都會將 count=0 讀到各自的 CPU 快取裡,執行完 count+=1 之後,各自 CPU 快取裡的值都是 1,同時寫入記憶體後,我們會發現記憶體中是 1,而不是我們期望的 2。之後由於各自的 CPU 快取裡都有了 count 的值,兩個執行緒都是基於 CPU 快取裡的 count 值來計算,所以導致最終 count 的值錯誤。

這就發生了快取的可見性問題。

源頭之二:執行緒切換帶來的原子性問題

Java 併發程式都是基於多執行緒的,自然也會涉及到任務切換,也許你想不到,任務切換竟然也是併發程式設計裡詭異 Bug 的源頭之一。任務切換的時機大多數是在時間片結束的時候,我們現在基本都使用高階語言程式設計,高階語言裡一條語句往往需要多條 CPU 指令完成,例如程式碼count += 1,至少需要三條 CPU 指令。

  • 指令 1:首先,需要把變數 count 從記憶體載入到 CPU 的暫存器;
  • 指令 2:之後,在暫存器中執行 +1 操作;
  • 指令 3:最後,將結果寫入記憶體(快取機制導致可能寫入的是 CPU 快取而不是記憶體)。

作業系統做任務切換,可以發生在任何一條CPU 指令執行完,是的,是 CPU 指令,而不是高階語言裡的一條語句。對於上面的三條指令來說,我們假設 count=0,如果執行緒 A 在指令 1 執行完後做執行緒切換,執行緒 A 和執行緒 B 按照下圖的序列執行,那麼我們會發現兩個執行緒都執行了 count+=1 的操作,但是得到的結果不是我們期望的 2,而是 1。

我們潛意識裡面覺得 count+=1 這個操作是一個不可分割的整體,就像一個原子一樣,執行緒的切換可以發生在 count+=1 之前,也可以發生在 count+=1 之後,但就是不會發生在中間。我們把一個或者多個操作在 CPU 執行的過程中不被中斷的特性稱為原子性。CPU 能保證的原子操作是 CPU 指令級別的,而不是高階語言的操作符,這是違揹我們直覺的地方。因此,很多時候我們需要在高階語言層面保證操作的原子性。

源頭之三:編譯優化帶來的有序性問題

顧名思義,有序性指的是程式按照程式碼的先後順序執行。編譯器為了優化效能,有時候會改變程式中語句的先後順序,例如程式中:“a=6;b=7;”編譯器優化後可能變成“b=7;a=6;”

Java記憶體模型?

Java當中三個基本原則:原子性、可見性、有序性。

Java記憶體模型涉及的幾個關鍵詞:

鎖、volatile欄位、final修飾符與物件的安全釋出。其中:第一是鎖,鎖操作是具備happens-before關係的,解鎖操作happens-before之後對同一把鎖的加鎖操作。實際上,在解鎖的時候,JVM需要強制重新整理快取,使得當前執行緒所修改的記憶體對其他執行緒可見。第二是volatile欄位,volatile欄位可以看成是一種不保證原子性的同步但保證可見性的特性,其效能往往是優於鎖操作的。但是,頻繁地訪問 volatile欄位也會出現因為不斷地強制重新整理快取而影響程式的效能的問題。第三是final修飾符,final修飾的例項欄位則是涉及到新建物件的釋出問題。當一個物件包含final修飾的例項欄位時,其他執行緒能夠看到已經初始化的final例項欄位,這是安全的。

為什麼要定義Java記憶體模型?

現代計算機體系大部是採用的對稱多處理器的體系架構。每個處理器均有獨立的暫存器組和快取,多個處理器可同時執行同一程式中的不同執行緒,這裡稱為處理器的亂序執行。在Java中,不同的執行緒可能訪問同一個共享或共享變數。如果任由編譯器或處理器對這些訪問進行優化的話,很有可能出現無法想象的問題,這裡稱為編譯器的重排序。除了處理器的亂序執行、編譯器的重排序,還有記憶體系統的重排序。因此Java語言規範引入了Java記憶體模型,通過定義多項規則對編譯器和處理器進行限制,主要是針對可見性和有序性。

導致可見性的原因是快取,導致有序性的原因是編譯優化,那解決可見性、有序性最直接的辦法就是禁用快取和編譯優化

Java 記憶體模型規範了 JVM 如何提供按需禁用快取和編譯優化的方法。具體來說,這些方法包括 volatilesynchronizedfinal 三個關鍵字,以及六項 Happens-Before 規則

Reference

Java併發程式設計實戰

相關文章