併發程式設計前傳

水目沾發表於2019-03-17

前言

  以前在學習 C++ 關鍵字 volatile 的時候,看過阿里資料庫大牛何登成關於 volatile 的文章《C/C++ volatile關鍵詞深度剖析》,看的雲裡霧裡。主要是當時沒理解什麼是可見性、原子性和有序性;沒有理解什麼是記憶體模型及一些規範。相信很多初學者和我一樣,在學習 Java 併發程式設計書籍的時候都直奔 volatile 、synchronized、wait/notify 的主題去了,其實這是不對的。比如在看《Java 高併發程式設計》這本書的時候請一定不要跳過第 1/2 章。最近看了極客時間上併發程式設計專欄的文章後,有種醍醐灌頂的感覺,一下讓我想通了很多在學習 C++ 時不理解的知識點。加上整理,將自己理解的一些知識點做個串聯並記錄下來,為併發程式設計做準備。文章可能有點長,但內容比較簡單,請耐心看完。

為什麼需要併發程式設計

  在 CPU 效能低、記憶體小、硬碟貴的年代,別說多執行緒就是單執行緒能正常跑完都謝天謝地了,所以那時候大部分程式都是序列的。然而隨著硬體的發展,CPU 的效能越來越高、核數越來越多、記憶體越來越大、磁碟也越來越便宜。為了在程式中充分利用計算機硬體,特別是那些寶貴的資源(如 CPU ),單執行緒/單程式已經不能滿足要求了,便開始漸漸出現了併發程式。併發程式一方面可以充分利用計算機資源,另一方面可以更快的響應使用者,兩全其美,何樂而不為。但新的世界大門開啟,它有光,也必有黑暗。學習併發程式設計,我們可以在新的世界裡遨遊;理解併發程式設計,我們可以避開新世界裡的黑暗。

併發中的多執行緒與多程式

  在討論併發程式設計的時候我們更多的是討論同一份程式碼的程式併發,而不是不同程式碼程式之間的併發。前者是程式設計師需要解決的問題,後者是作業系統需要解決的問題。併發程式設計常用的有幾種方式:多程式和多執行緒以及他們的組合。在用 C++ 程式設計的時候還會考慮到多程式,而在 Java 程式設計的時候完全只考慮多執行緒這種方式了。這樣的選擇也是有道理的,畢竟作業系統對執行緒的切換比程式的切換消耗的資源少、速度快。程式與執行緒的區別每個被面試過的程式設計師都應該滾瓜爛熟了,這裡不在贅述,本段要強調的是後續的討論都是基於單程式的多執行緒併發程式設計。

程式能進行併發的前提

  IO 密集型和 CPU 密集型這兩個名詞相信大家都不陌生,前者是指程式執行過程中 IO操作(磁碟讀寫、網路讀寫)會多一些,比如 mysql 進行資料讀寫;後者是指執行過程中 CPU 使用會多一些,比如 matlab 做矩陣乘法。可以說,所有程式在執行過程中不是在使用 CPU 就是在進行 IO 操作,當然還有休眠的時候。正是因為此,才有了併發的可能性。想象一下,如果某臺機器上的所有程式都只使用 CPU 或者只進行 IO,如何併發?比如 while(true){} 這樣的程式!

  我們知道 CPU 是計算機的大腦,理論上在進行任何操作的時候都需要 CPU ,那為啥 CPU 密集型和 IO 密集型能分開討論呢?這不得不提一下 DMA(Direct Memory Access)技術,它讓 IO 操作只在開始和結束的時候需要使用下 CPU ,其他時間 CPU 可以幹其他事情。它讓 CPU 和 IO 並行工作成了可能。所以程式能進行併發的前提有如下兩點:

  • CPU 執行指令與 IO 操作可以並行
  • 絕大多數程式既要使用 CPU 又要進行 IO

併發程式設計的難點

  併發程式設計難主要有以下幾點原因:

  • 理論多:不像語言中的其他語法部分,併發程式設計不僅要了解語言所涉及到的關鍵知識,還涉及到底層的作業系統。在學習很多計算機語言的時候,光併發程式設計部分就能寫一本厚厚的書籍了,所以說理論多。
  • 接觸少:再過幾個月,筆者就工作三年了,學習程式設計也有十年了。在這麼多的時間裡,筆者也就在學習的過程中寫過幾個併發程式設計的例子,工作中都沒實際運用過。併發程式設計大部分情況下並不常有,特別是高併發,一般只有那些中介軟體系統會比較常見。
  • 易出錯:由於併發程式設計中的可見性、原子性和有序性問題導致併發程式常出現一些詭異的 BUG。這些 BUG 往往復現難、定位難。索性很多程式放棄了使用併發,就我在騰訊在職期間維護的比較大的系統都是單程式單執行緒的。效能不夠,機器來湊。

核心知識點

  都說打蛇打七寸,學習併發程式設計也要把握住其關鍵技術。但學習併發程式設計關鍵點的前提是弄懂這些關鍵技術都是為了解決什麼問題以及怎麼解決的,這樣才能學的更快、記得更牢。多執行緒併發程式設計關鍵技術點都是圍繞可見性、原子性和有序性建立的。掌握了可見性、原子性和有序性的定義和觸發問題的場景,在定位併發 BUG 時便有跡可循,在學習併發程式設計時也遊刃有餘。

1、什麼是可見性

  可見性(Visibility):一個執行緒對共享變數的修改,另外一個執行緒能立刻看到,我們稱之為可見性。對序列程式來說可見性問題是不存在,但併發程式就不一定了,其中一種可能如下:

  CPU 執行指令的速度是記憶體讀寫速度的數十倍甚至上百倍,如果 CPU 每執行一條讀寫指令都去記憶體中讀寫資料的話,那 CPU 的效能就被大大浪費了。學過作業系統的都知道,為了解決這個問題硬體工程師在 CPU 和記憶體之間加入了快取記憶體,CPU 執行讀寫指令時將資料讀入快取,並將執行結果存入快取,當運算結束後再在某個時候將結果同步到記憶體。這樣做好處多多,但會引入新的問題:快取一致性。

併發程式設計前傳
  如上圖所示,如果執行緒 1 更改了變數並快取,執行緒 2 並不能馬上看到結果,這就產生了可見性問題。可見性問題是一個綜合問題,CPU 快取只是導致可見性的一種可能之一,如編譯器優化、指令重排都有可能造成可見性問題

2、什麼是原子性

  原子性(Atomicity):一個或者多個操作在 CPU 執行的過程中不被中斷的特性,我們稱之為原子性。原子這個詞程式設計師並不陌生,因為資料庫中也有原子的概念。從化學角度來看,原子是不可再被分割的基本微粒。回到計算機的世界,很多不瞭解底層程式設計師以為高階語言的每條語句都是一個原子操作,其實不然,比如簡單的賦值操作在 C++ 中以上程式碼至少需要三條 CPU 指令。

  • 指令1:首先,需要把變數 i 從記憶體載入到 CPU 的暫存器。
  • 指令2:之後,在暫存器中執行 i=1 操作;
  • 指令3:最後,將結果寫入記憶體。

  如果是單執行緒序列即使一個語句分成多條指令執行,也不會存在原子性問題。而在併發程式就不一定了。Java 的 long 型別是 8 位元組的,在 32 系統上,該型別變數的讀寫就需要兩次操作記憶體(即兩條虛擬機器指令),而 Java 虛擬機器規範又允許兩次虛擬機器指令操作是非原子的。這樣機會出現以下場景:

併發程式設計前傳

  • 執行緒 1 剛讀取 i 的前 4 位元組,準備讀取後 4 位元組;
  • 執行緒 2 對 i 後 4 位元組進行了修改;
  • 執行緒 1 讀取 i 後 4 位元組並將值展示。

  這樣 i 讀出來是一個拼接後錯誤值,出現原子性問題。具體見《深入理解Java虛擬機器:JVM高階特性與最佳實踐(第2版)》12.3.4-對於long和double型變數的特殊規則這一章節。

3、什麼是有序性

  有序性(Ordering):即程式執行的順序性。我們總是以為程式碼是從前往後依次執行的,在單執行緒情況下確實是這樣。但在併發程式中可能就會出現亂序,從而導致有序性問題。一句話總結為:如果在本執行緒內觀察,所有的操作都是有序的;如果在一個執行緒中觀察另一個執行緒,所有的操作都是無序的。舉個栗子,如下程式碼:

class OrderingExample {
    int x = 0;
    boolean flag = false;
    public void writer() {
        x = 42; //宇宙的終極答案
        flag = true;
    }
    public void reader() {
        if (flag == true) {
            //x = ?
        }
    }
}

複製程式碼

  在單執行緒中 x 肯定等於 42,而在併發程式中,x 不一定等於 42,也有可能是 0。由於編譯器優化、指令重排等可能導致以上程式碼在被執行時是這樣:

class OrderingExample {
    int x = 0;
    boolean flag = false;
    public void writer() {
        flag = true;
        x = 42; //宇宙的終極答案
    }
    public void reader() {
        if (flag == true) {
            x = ?
        }
    }
}
複製程式碼

  當執行緒 1 剛執行到 writer 中的 flag = true ,準備接著執行 x = 42 時,cpu 切換到執行緒 2 執行reader 中的 if(flag==true),並進入 x = ?程式碼,此時 x = 0,出現有序性問題。

記憶體模型

  由於可見性、原子性和有序性導致併發程式在讀寫記憶體中共享變數存在種種問題,那該如何解決呢?這就是記憶體模型需要做的事:記憶體模型定義了共享記憶體系統中多執行緒程式讀寫操作行為的規範。通過這些規則來規範對記憶體的讀寫操作,從而保證指令執行的正確性。它與處理器有關、與快取有關、與併發有關、與編譯器也有關。它解決了CPU多級快取、處理器優化、指令重排等導致的記憶體訪問問題,保證了併發場景下的可見性、原子性和有序性。

  記憶體模型只是一種模型也即一種規範,每種語言有著自己的具體的實現細節,Java 有 Java 的記憶體模型 JMM,C++ 有著 C++ 的記憶體模型(依賴作業系統的記憶體模型)。這裡需要注意的是記憶體模型與物件模型的區別,在學習 C++ 的時候有本比較出名的書《深入理解 C++ 物件模型》它講的是 C++ 物件在記憶體中如何佈局的,跟 C++ 記憶體模型完全是兩碼事,Java 中同理。同時還要區分記憶體模型與記憶體結構的區別,很多人容易混淆這兩個概念,不信百度下記憶體模型,很多講的都是堆疊之類的。而堆疊、靜態區對應的應該是記憶體結構。

作業系統與 JVM

  本來不想加這段的,因為前傳只想寫一些與語言無關的知識點。但為了提醒下 Java 程式設計師,加上了這段。我們都知道 C++ 程式是不可移植的,主要因為它的編譯與作業系統息息相關。而 Java 不一樣,它能做到真正的 write once,run everywhere,主要是因為它有 Java 虛擬機器(Java Vitual Machine,JVM)。JVM 在作業系統上做了一層抽象,遮蔽了作業系統層面的細節。對於 Java 程式來說,JVM 就是它的作業系統,所以作業系統中的很多概念都直接搬到了 JVM 中,比如程式/執行緒、IO 操作等,大多時候很多書籍都不對其進行區分,因為這些 api 大部分情況下都是 JVM 呼叫 native 方法實現的。但有些概念卻有所不同,比如虛擬機器指令、虛擬機器程式計數器、主記憶體與工作記憶體、JMM 等,可能因為這些實現與作業系統不大一樣。這裡做個對照,在 Java 虛擬機器中:虛擬機器指令對應 CPU 指令;主記憶體對應實體記憶體;工作記憶體對應 CPU 快取

總結

  本篇文章講的併發程式設計內容與語言無關,更多的是學習併發程式設計的一些前置概念知識,是我個人的一些理解。希望對你有所幫助,錯誤的地方還請多多指教。記得關注公眾號哦,記錄著一個 C++ 程式設計師轉 Java 的學習之路。

併發程式設計前傳

相關文章