併發程式設計導論

王下邀月熊發表於2019-04-22

併發程式設計導論是對於分散式計算-併發程式設計 https://url.wx-coder.cn/Yagu8 系列的總結與歸納。

併發程式設計導論

併發程式設計導論

隨著硬體效能的迅猛發展與大資料時代的來臨,併發程式設計日益成為程式設計中不可忽略的重要組成部分。簡單定義來看,如果執行單元的邏輯控制流在時間上重疊,那它們就是併發(Concurrent)的。併發程式設計復興的主要驅動力來自於所謂的“多核危機”。正如摩爾定律所預言的那樣,晶片效能仍在不斷提高,但相比加快 CPU 的速度,計算機正在向多核化方向發展。正如 Herb Sutter 所說,“免費午餐的時代已然終結”。為了讓程式碼執行得更快,單純依靠更快的硬體已無法滿足要求,並行和分散式計算是現代應用程式的主要內容,我們需要利用多個核心或多臺機器來加速應用程式或大規模執行它們。

併發程式設計是非常廣泛的概念,其向下依賴於作業系統、儲存等,與分散式系統、微服務等,而又會具體落地於 Java 併發程式設計、Go 併發程式設計、JavaScript 非同步程式設計等領域。雲端計算承諾在所有維度上(記憶體、計算、儲存等)實現無限的可擴充套件性,併發程式設計及其相關理論也是我們構建大規模分散式應用的基礎。

併發程式設計導論

本節主要討論併發程式設計理論相關的內容,可以參閱 [Java 併發程式設計 https://url.wx-coder.cn/72vCj Go 併發程式設計 https://url.wx-coder.cn/FO9EX 等了解具體程式語言中併發程式設計的實踐,可以參閱微服務實戰 https://url.wx-coder.cn/7KZ2i 或者關係型資料庫理論 https://url.wx-coder.cn/DJNQn 瞭解併發程式設計在實際系統中的應用。

併發與並行

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

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

image.png

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

具體而言,Redis 會是一個很好地區分併發和並行的例子。Redis 本身是一個單執行緒的資料庫,但是可以通過多路複用與事件迴圈的方式來提供併發地 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)做加法的指令。


記憶體模型

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

image

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

併發程式設計導論

併發程式設計中的問題,往往源於快取導致的可見性問題、執行緒切換導致的原子性問題以及編譯優化帶來的有序性問題。以 Java 虛擬機器為例,每個執行緒都擁有一個屬於自己的執行緒棧(呼叫棧),隨著執行緒程式碼的執行,呼叫棧會隨之改變。執行緒棧中包含每個正在執行的方法的區域性變數。每個執行緒只能訪問屬於自己的棧。呼叫棧中的區域性變數,只有建立這個棧的執行緒才可以訪問,其他執行緒都不能訪問。即使兩個執行緒在執行一段相同的程式碼,這兩個執行緒也會在屬於各自的執行緒棧中建立區域性變數。因此,每個執行緒擁有屬於自己的區域性變數。所有基本型別的區域性變數全部存放線上程棧中,對其他執行緒不可見。一個執行緒可以把基本型別拷貝到其他執行緒,但是不能共享給其他執行緒,而無論哪個執行緒建立的物件都存放在堆中。

可見性

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

併發程式設計導論

可見性問題最經典的案例即是併發加操作,如下兩個執行緒同時在更新變數 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 能保證同一時刻只有一個執行緒獲取鎖然後執行同步程式碼,並且在釋放鎖之前會將對變數的修改重新整理到主存當中。因此可以保證可見性。

原子性

所謂的原子性,就是一個或者多個操作在 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 能夠保證任一時刻只有一個執行緒執行該程式碼塊,那麼自然就不存在原子性問題了,從而保證了原子性。

有序性

顧名思義,有序性指的是程式按照程式碼的先後順序執行。程式碼重排是指編譯器對使用者程式碼進行優化以提高程式碼的執行效率,優化前提是不改變程式碼的結果,即優化前後程式碼執行結果必須相同。

譬如:

int a = 1, b = 2, c = 3;
void test() {
    a = b + 1;
    b = c + 1;
    c = a + b;
}
複製程式碼

在 gcc 下的彙編程式碼 test 函式體程式碼如下,其中編譯引數: -O0

movl b(%rip), %eax
addl $1, %eax
movl %eax, a(%rip)
movl c(%rip), %eax
addl $1, %eax
movl %eax, b(%rip)
movl a(%rip), %edx
movl b(%rip), %eax
addl %edx, %eax
movl %eax, c(%rip)
複製程式碼

編譯引數:-O3

movl b(%rip), %eax                  ;將b讀入eax暫存器
leal 1(%rax), %edx                  ;將b+1寫入edx暫存器
movl c(%rip), %eax                  ;將c讀入eax
movl %edx, a(%rip)                  ;將edx寫入a
addl $1, %eax                       ;將eax+1
movl %eax, b(%rip)                  ;將eax寫入b
addl %edx, %eax                     ;將eax+edx
movl %eax, c(%rip)                  ;將eax寫入c
複製程式碼

在 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 的成員變數就可能觸發空指標異常。

記憶體屏障

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

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(指的是各種併發關鍵字、鎖、以及重入性等,下節詳細介紹)。這裡的目的只是為了幫助更好 的理解記憶體屏障的工作原理。

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

Java 記憶體模型(Java Memory Model, JMM)

Java 記憶體模型著眼於描述 Java 中的執行緒是如何與記憶體進行互動,以及單執行緒中程式碼執行的順序等,並提供了一系列基礎的併發語義原則;最早的 Java 記憶體模型於 1995 年提出,致力於解決不同處理器/作業系統中執行緒互動/同步的問題,規定和指引 Java 程式在不同的記憶體架構、CPU 和作業系統間有確定性地行為。在 Java 5 版本之前,JMM 並不完善,彼時多執行緒往往會在共享記憶體中讀取到很多奇怪的資料;譬如,某個執行緒無法看到其他執行緒對共享變數寫入的值,或者因為指令重排序的問題,某個執行緒可能看到其他執行緒奇怪的操作步驟。

Java 記憶體模型具備一些先天的“有序性”,即不需要通過任何手段就能夠得到保證的有序性,這個通常也稱為 happens-before 原則。如果兩個操作的執行次序無法從 happens-before 原則推匯出來,那麼它們就不能保證它們的有序性,虛擬機器可以隨意地對它們進行重排序。

Java 記憶體模型對一個執行緒所做的變動能被其它執行緒可見提供了保證,它們之間是先行發生關係。

  • 執行緒內的程式碼能夠按先後順序執行,這被稱為程式次序規則
  • 對於同一個鎖,一個解鎖操作一定要發生在時間上後發生的另一個鎖定操作之前,也叫做管程鎖定規則
  • 前一個對 volatile 的寫操作在後一個 volatile 的讀操作之前,也叫 volatile 變數規則
  • 一個執行緒內的任何操作必需在這個執行緒的 start()呼叫之後,也叫作執行緒啟動規則
  • 一個執行緒的所有操作都會線上程終止之前,執行緒終止規則
  • 一個物件的終結操作必需在這個物件構造完成之後,也叫物件終結規則

對於程式次序規則來說,就是一段程式程式碼的執行在單個執行緒中看起來是有序的。注意,雖然這條規則中提到“書寫在前面的操作先行發生於書寫在後面的操作”,這個應該是程式看起來執行的順序是按照程式碼順序執行的,因為虛擬機器可能會對程式程式碼進行指令重排序。雖然進行重排序,但是最終執行的結果是與程式順序執行的結果一致的,它只會對不存在資料依賴性的指令進行重排序。因此,在單個執行緒中,程式執行看起來是有序執行的,這一點要注意理解。事實上,這個規則是用來保證程式在單執行緒中執行結果的正確性,但無法保證程式在多執行緒中執行的正確性。


程式,執行緒與協程

在未配置 OS 的系統中,程式的執行方式是順序執行,即必須在一個程式執行完後,才允許另一個程式執行;在多道程式環境下,則允許多個程式併發執行。程式的這兩種執行方式間有著顯著的不同。也正是程式併發執行時的這種特徵,才導致了在作業系統中引入程式的概念。程式是資源分配的基本單位,執行緒是資源排程的基本單位

早期的作業系統基於程式來排程 CPU,不同程式間是不共享記憶體空間的,所以程式要做任務切換就要切換記憶體對映地址,而一個程式建立的所有執行緒,都是共享一個記憶體空間的,所以執行緒做任務切換成本就很低了。現代的作業系統都基於更輕量的執行緒來排程,現在我們提到的“任務切換”都是指“執行緒切換”。

Process | 程式

程式是作業系統對一個正在執行的程式的一種抽象,在一個系統上可以同時執行多個程式,而每個程式都好像在獨佔地使用硬體。所謂的併發執行,則是說一個程式的指令和另一個程式的指令是交錯執行的。無論是在單核還是多核系統中,可以通過處理器在程式間切換,來實現單個 CPU 看上去像是在併發地執行多個程式。作業系統實現這種交錯執行的機制稱為上下文切換。

作業系統保持跟蹤程式執行所需的所有狀態資訊。這種狀態,也就是上下文,它包括許多資訊,例如 PC 和暫存器檔案的當前值,以及主存的內容。在任何一個時刻,單處理器系統都只能執行一個程式的程式碼。當作業系統決定要把控制權從當前程式轉移到某個新程式時,就會進行上下文切換,即儲存當前程式的上下文、恢復新程式的上下文,然後將控制權傳遞到新程式。新程式就會從上次停止的地方開始。

image

虛擬儲存管理 https://url.wx-coder.cn/PeNqS 一節中,我們介紹過它為每個程式提供了一個假象,即每個程式都在獨佔地使用主存。每個程式看到的是一致的儲存器,稱為虛擬地址空間。其虛擬地址空間最上面的區域是為作業系統中的程式碼和資料保留的,這對所有程式來說都是一樣的;地址空間的底部區域存放使用者程式定義的程式碼和資料。

image

  • 程式程式碼和資料,對於所有的程式來說,程式碼是從同一固定地址開始,直接按照可執行目標檔案的內容初始化。
  • 堆,程式碼和資料區後緊隨著的是執行時堆。程式碼和資料區是在程式一開始執行時就被規定了大小,與此不同,當呼叫如 malloc 和 free 這樣的 C 標準庫函式時,堆可以在執行時動態地擴充套件和收縮。
  • 共享庫:大約在地址空間的中間部分是一塊用來存放像 C 標準庫和數學庫這樣共享庫的程式碼和資料的區域。
  • 棧,位於使用者虛擬地址空間頂部的是使用者棧,編譯器用它來實現函式呼叫。和堆一樣,使用者棧在程式執行期間可以動態地擴充套件和收縮。
  • 核心虛擬儲存器:核心總是駐留在記憶體中,是作業系統的一部分。地址空間頂部的區域是為核心保留的,不允許應用程式讀寫這個區域的內容或者直接呼叫核心程式碼定義的函式。

Thread | 執行緒

在現代系統中,一個程式實際上可以由多個稱為執行緒的執行單元組成,每個執行緒都執行在程式的上下文中,並共享同樣的程式碼和全域性資料。程式的個體間是完全獨立的,而執行緒間是彼此依存的。多程式環境中,任何一個程式的終止,不會影響到其他程式。而多執行緒環境中,父執行緒終止,全部子執行緒被迫終止(沒有了資源)。而任何一個子執行緒終止一般不會影響其他執行緒,除非子執行緒執行了 exit() 系統呼叫。任何一個子執行緒執行 exit(),全部執行緒同時滅亡。多執行緒程式中至少有一個主執行緒,而這個主執行緒其實就是有 main 函式的程式。它是整個程式的程式,所有執行緒都是它的子執行緒。我們通常把具有多執行緒的主程式稱之為主執行緒。

執行緒共享的環境包括:程式程式碼段、程式的公有資料、程式開啟的檔案描述符、訊號的處理器、程式的當前目錄、程式使用者 ID 與程式組 ID 等,利用這些共享的資料,執行緒很容易的實現相互之間的通訊。程式擁有這許多共性的同時,還擁有自己的個性,並以此實現併發性:

  • 執行緒 ID:每個執行緒都有自己的執行緒 ID,這個 ID 在本程式中是唯一的。程式用此來標識執行緒。
  • 暫存器組的值:由於執行緒間是併發執行的,每個執行緒有自己不同的執行線索,當從一個執行緒切換到另一個執行緒上時,必須將原有的執行緒的暫存器集合的狀態儲存,以便 將來該執行緒在被重新切換到時能得以恢復。
  • 執行緒的堆疊:堆疊是保證執行緒獨立執行所必須的。執行緒函式可以呼叫函式,而被呼叫函式中又是可以層層巢狀的,所以執行緒必須擁有自己的函式堆疊, 使得函式呼叫可以正常執行,不受其他執行緒的影響。
  • 錯誤返回碼:由於同一個程式中有很多個執行緒在同時執行,可能某個執行緒進行系統呼叫後設定了 errno 值,而在該 執行緒還沒有處理這個錯誤,另外一個執行緒就在此時 被排程器投入執行,這樣錯誤值就有可能被修改。 所以,不同的執行緒應該擁有自己的錯誤返回碼變數。
  • 執行緒的訊號遮蔽碼:由於每個執行緒所感興趣的訊號不同,所以執行緒的訊號遮蔽碼應該由執行緒自己管理。但所有的執行緒都共享同樣的訊號處理器。
  • 執行緒的優先順序:由於執行緒需要像程式那樣能夠被排程,那麼就必須要有可供排程使用的引數,這個引數就是執行緒的優先順序。

image.png

Linux 中的執行緒

在 Linux 2.4 版以前,執行緒的實現和管理方式就是完全按照程式方式實現的;在 Linux 2.6 之前,核心並不支援執行緒的概念,僅通過輕量級程式(lightweight process)模擬執行緒,一個使用者執行緒對應一個核心執行緒(核心輕量級程式),這種模型最大的特點是執行緒排程由核心完成了,而其他執行緒操作(同步、取消)等都是核外的執行緒庫(LinuxThread)函式完成的。為了完全相容 Posix 標準,Linux 2.6 首先對核心進行了改進,引入了執行緒組的概念(仍然用輕量級程式表示執行緒),有了這個概念就可以將一組執行緒組織稱為一個程式,不過核心並沒有準備特別的排程演算法或是定義特別的資料結構來表徵執行緒;相反,執行緒僅僅被視為一個與其他程式(概念上應該是執行緒)共享某些資源的程式(概念上應該是執行緒)。在實現上主要的改變就是在 task_struct 中加入 tgid 欄位,這個欄位就是用於表示執行緒組 id 的欄位。在使用者執行緒庫方面,也使用 NPTL 代替 LinuxThread。不同排程模型上仍然採用 1 對 1 模型。

程式的實現是呼叫 fork 系統呼叫:pid_t fork(void);,執行緒的實現是呼叫 clone 系統呼叫:int clone(int (*fn)(void *), void *child_stack, int flags, void *arg, ...)。與標準 fork() 相比,執行緒帶來的開銷非常小,核心無需單獨複製程式的記憶體空間或檔案描寫敘述符等等。這就節省了大量的 CPU 時間,使得執行緒建立比新程式建立快上十到一百倍,能夠大量使用執行緒而無需太過於操心帶來的 CPU 或記憶體不足。無論是 fork、vfork、kthread_create 最後都是要呼叫 do_fork,而 do_fork 就是根據不同的函式引數,對一個程式所需的資源進行分配。

執行緒池

執行緒池的大小依賴於所執行任務的特性以及程式執行的環境,執行緒池的大小應該應採取可配置的方式(寫入配置檔案)或者根據可用的 CPU 數量 Runtime.availableProcessors() 來進行設定,其中 Ncpu 表示可用 CPU 數量,Nthreads 表示執行緒池工作執行緒數量,Ucpu 表示 CPU 的利用率 0≤ Ucpu ≤1;W 表示資源等待時間,C 表示任務計算時間;Rtotal 表示有限資源的總量,Rper 表示每個任務需要的資源數量。

  • 對於對於純 CPU 計算的任務-即不依賴阻塞資源(外部介面呼叫)以及有限資源(執行緒池)的 CPU 密集型(compute-intensive)任務執行緒池的大小可以設定為:Nthreads = Ncpu+1

  • 如果執行的任務除了 cpu 計算還包括一些外部介面呼叫或其他會阻塞的計算,那麼執行緒池的大小可以設定為 Nthreads = Ncpu - Ucpu -(1 + W / C)。可以看出對於 IO 等待時間長於任務計算時間的情況,W/C 大於 1,假設 cpu 利用率是 100%,那麼 W/C 結果越大,需要的工作執行緒也越多,因為如果沒有足夠的執行緒則會造成任務佇列迅速膨脹。

  • 如果任務依賴於一些有限的資源比如記憶體,檔案控制程式碼,資料庫連線等等,那麼執行緒池最大可以設定為 Nthreads ≤ Rtotal/Rper

Coroutine | 協程

協程是使用者模式下的輕量級執行緒,最準確的名字應該叫使用者空間執行緒(User Space Thread),在不同的領域中也有不同的叫法,譬如纖程(Fiber)、綠色執行緒(Green Thread)等等。作業系統核心對協程一無所知,協程的排程完全有應用程式來控制,作業系統不管這部分的排程;一個執行緒可以包含一個或多個協程,協程擁有自己的暫存器上下文和棧,協程排程切換時,將暫存器上細紋和棧儲存起來,在切換回來時恢復先前保運的寄存上下文和棧。

比如 Golang 裡的 go 關鍵字其實就是負責開啟一個 Fiber,讓 func 邏輯跑在上面。而這一切都是發生的使用者態上,沒有發生在核心態上,也就是說沒有 ContextSwitch 上的開銷。協程的實現庫中筆者較為常用的譬如 Go Routine、node-fibersJava-Quasar 等。

Go 的棧是動態分配大小的,隨著儲存資料的數量而增長和收縮。每個新建的 Goroutine 只有大約 4KB 的棧。每個棧只有 4KB,那麼在一個 1GB 的 RAM 上,我們就可以有 256 萬個 Goroutine 了,相對於 Java 中每個執行緒的 1MB,這是巨大的提升。Golang 實現了自己的排程器,允許眾多的 Goroutines 執行在相同的 OS 執行緒上。就算 Go 會執行與核心相同的上下文切換,但是它能夠避免切換至 ring-0 以執行核心,然後再切換回來,這樣就會節省大量的時間。但是,這只是紙面上的分析。為了支援上百萬的 Goroutines,Go 需要完成更復雜的事情。

要支援真正的大併發需要另外一項優化:當你知道執行緒能夠做有用的工作時,才去排程它。如果你執行大量執行緒的話,其實只有少量的執行緒會執行有用的工作。Go 通過整合通道(channel)和排程器(scheduler)來實現這一點。如果某個 Goroutine 在一個空的通道上等待,那麼排程器會看到這一點並且不會執行該 Goroutine。Go 更近一步,將大多數空閒的執行緒都放到它的作業系統執行緒上。通過這種方式,活躍的 Goroutine(預期數量會少得多)會在同一個執行緒上排程執行,而數以百萬計的大多數休眠的 Goroutine 會單獨處理。這樣有助於降低延遲。

除非 Java 增加語言特性,允許排程器進行觀察,否則的話,是不可能支援智慧排程的。但是,你可以在“使用者空間”中構建執行時排程器,它能夠感知執行緒何時能夠執行工作。這構成了像 Akka 這種型別的框架的基礎,它能夠支援上百萬的 Actor。


併發控制

涉及多執行緒程式涉及的時候經常會出現一些令人難以思議的事情,用堆和棧分配一個變數可能在以後的執行中產生意想不到的結果,而這個結果的表現就是記憶體的非法被訪問,導致記憶體的內容被更改。在一個程式的執行緒共享堆區,而程式中的執行緒各自維持自己堆疊。 在 Windows 等平臺上,不同執行緒預設使用同一個堆,所以用 C 的 malloc (或者 windows 的 GlobalAlloc)分配記憶體的時候是使用了同步保護的。如果沒有同步保護,在兩個執行緒同時執行記憶體操作的時候會產生競爭條件,可能導致堆內記憶體管理混亂。比如兩個執行緒分配了統一塊記憶體地址,空閒連結串列指標錯誤等。

最常見的程式/執行緒的同步方法有互斥鎖(或稱互斥量 Mutex),讀寫鎖(rdlock),條件變數(cond),訊號量(Semophore)等;在 Windows 系統中,臨界區(Critical Section)和事件物件(Event)也是常用的同步方法。總結而言,同步問題基本的就是解決原子性與可見性/一致性這兩個問題,其基本手段就是基於鎖,因此又可以分為三個方面:指令序列化/臨界資源管理/鎖、資料一致性/資料可見性、事務/原子操作。在併發控制中我們會考慮執行緒協作、互斥與鎖、併發容器等方面。

執行緒通訊

併發控制中主要考慮執行緒之間的通訊(執行緒之間以何種機制來交換資訊)與同步(讀寫等待,競態條件等)模型,在指令式程式設計中,執行緒之間的通訊機制有兩種:共享記憶體和訊息傳遞。Java 就是典型的共享記憶體模式的通訊機制;而 Go 則是提倡以訊息傳遞方式實現記憶體共享,而非通過共享來實現通訊。

在共享記憶體的併發模型裡,執行緒之間共享程式的公共狀態,執行緒之間通過寫-讀記憶體中的公共狀態來隱式進行通訊。在訊息傳遞的併發模型裡,執行緒之間沒有公共狀態,執行緒之間必須通過明確的傳送訊息來顯式進行通訊。同步是指程式用於控制不同執行緒之間操作發生相對順序的機制。在共享記憶體併發模型裡,同步是顯式進行的。程式設計師必須顯式指定某個方法或某段程式碼需要線上程之間互斥執行。在訊息傳遞的併發模型裡,由於訊息的傳送必須在訊息的接收之前,因此同步是隱式進行的。

常見的執行緒通訊方式有以下幾種:

  • 管道(Pipe):管道是一種半雙工的通訊方式,資料只能單向流動,而且只能在具有親緣關係的程式間使用,其中程式的親緣關係通常是指父子程式關係。

  • 訊息佇列(Message Queue):訊息佇列是由訊息的連結串列,存放在核心中並由訊息佇列識別符號標識。訊息佇列克服了訊號傳遞資訊少、管道只能承載無格式位元組流以及緩衝區大小受限等缺點。

  • 訊號量(Semophore):訊號量是一個計數器,可以用來控制多個程式對共享資源的訪問。它常作為一種鎖機制,防止某程式正在訪問共享資源時,其他程式也訪問該資源。因此,主要作為程式間以及同一程式內不同執行緒之間的同步手段。

  • 共享記憶體(Shared Memory):共享記憶體就是對映一段能被其他程式所訪問的記憶體,這段共享記憶體由一個程式建立,但多個程式都可以訪問。共享記憶體是最快的 IPC 方式,它是針對其他程式間通訊方式執行效率低而專門設計的。它往往與其他通訊機制,如訊號量配合使用,來實現程式間的同步和通訊。

  • 套接字(Socket):套接字也是一種程式間通訊機制,與其他通訊機制不同的是,它可用於不同主機間的程式通訊。

鎖與互斥

互斥是指某一資源同時只允許一個訪問者對其進行訪問,具有唯一性和排它性;但互斥無法限制訪問者對資源的訪問順序,即訪問是無序的。同步:是指在互斥的基礎上(大多數情況),通過其它機制實現訪問者對資源的有序訪問。在大多數情況下,同步已經實現了互斥,特別是所有寫入資源的情況必定是互斥的;少數情況是指可以允許多個訪問者同時訪問資源。

臨界資源

所謂的臨界資源,即一次只允許一個程式訪問的資源,多個程式只能互斥訪問的資源。臨界資源的訪問需要同步操作,比如訊號量就是一種方便有效的程式同步機制。但訊號量的方式要求每個訪問臨界資源的程式都具有 wait 和 signal 操作。這樣使大量的同步操作分散在各個程式中,不僅給系統管理帶來了麻煩,而且會因同步操作的使用不當導致死鎖。管程就是為了解決這樣的問題而產生的。

作業系統中管理的各種軟體和硬體資源,均可用資料結構抽象地描述其資源特性,即用少量資訊和對該資源所執行的操作來表徵該資源,而忽略它們的內部結構和實現細節。利用共享資料結構抽象地表示系統中的共享資源。而把對該共享資料結構實施的操作定義為一組過程,如資源的請求和釋放過程 request 和 release。程式對共享資源的申請、釋放和其他操作,都是通過這組過程對共享資料結構的操作來實現的,這組過程還可以根據資源的情況接受或阻塞程式的訪問,確保每次僅有一個程式使用該共享資源,這樣就可以統一管理對共享資源的所有訪問,實現臨界資源互斥訪問。

管程就是代表共享資源的資料結構以及由對該共享資料結構實施操作的一組過程所組成的資源管理程式共同構成的一個作業系統的資源管理模組。管程被請求和釋放臨界資源的程式所呼叫。管程定義了一個資料結構和能為併發程式所執行(在該資料結構上)的一組操作,這組操作能同步程式和改變管程中的資料。

悲觀鎖(Pessimistic Locking)

悲觀併發控制,又名悲觀鎖(Pessimistic Concurrency Control,PCC)是一種併發控制的方法。它可以阻止一個事務以影響其他使用者的方式來修改資料。如果一個事務執行的操作都某行資料應用了鎖,那只有當這個事務把鎖釋放,其他事務才能夠執行與該鎖衝突的操作。悲觀併發控制主要用於資料爭用激烈的環境,以及發生併發衝突時使用鎖保護資料的成本要低於回滾事務的成本的環境中。

在程式語言中,悲觀鎖可能存在以下缺陷:

  • 在多執行緒競爭下,加鎖、釋放鎖會導致比較多的上下文切換和排程延時,引起效能問題。
  • 一個執行緒持有鎖會導致其它所有需要此鎖的執行緒掛起。
  • 如果一個優先順序高的執行緒等待一個優先順序低的執行緒釋放鎖會導致優先順序倒置,引起效能風險。

資料庫中悲觀鎖主要由以下問題:悲觀鎖大多數情況下依靠資料庫的鎖機制實現,以保證操作最大程度的獨佔性。如果加鎖的時間過長,其他使用者長時間無法訪問,影響了程式的併發訪問性,同時這樣對資料庫效能開銷影響也很大,特別是對長事務而言,這樣的開銷往往無法承受,特別是對長事務而言。如一個金融系統,當某個操作員讀取使用者的資料,並在讀出的使用者資料的基礎上進行修改時(如更改使用者帳戶餘額),如果採用悲觀鎖機制,也就意味著整個操作過程中(從操作員讀出資料、開始修改直至提交修改結果的全過程,甚至還包括操作員中途去煮咖啡的時間),資料庫記錄始終處於加鎖狀態,可以想見,如果面對幾百上千個併發,這樣的情況將導致怎樣的後果。

互斥鎖/排他鎖

互斥鎖即對互斥量進行分加鎖,和自旋鎖類似,唯一不同的是競爭不到鎖的執行緒會回去睡會覺,等到鎖可用再來競爭,第一個切入的執行緒加鎖後,其他競爭失敗者繼續回去睡覺直到再次接到通知、競爭。

互斥鎖算是目前併發系統中最常用的一種鎖,POSIX、C++11、Java 等均支援。處理 POSIX 的加鎖比較普通外,C++ 和 Java 的加鎖方式很有意思。C++ 中可以使用一種 AutoLock(常見於 chromium 等開源專案中)工作方式類似 auto_ptr 智 能指標,在 C++11 中官方將其標準化為 std::lock_guard 和 std::unique_lock。Java 中使用 synchronized 緊跟同步程式碼塊(也可修飾方法)的方式同步程式碼,非常靈活。這兩種實現都巧妙的利用各自語言特性實現了非常優雅的加鎖方式。當然除此之外他們也支援傳統的類 似於 POSIX 的加鎖模式。

可重入鎖

也叫做鎖遞迴,就是獲取一個已經獲取的鎖。不支援執行緒獲取它已經獲取且尚未解鎖的方式叫做不可遞迴或不支援重入。帶重入特性的鎖在重入時會判斷是否同一個執行緒,如果是,則使持鎖計數器+1(0 代表沒有被執行緒獲取,又或者是鎖被釋放)。C++11 中同時支援兩種鎖,遞迴鎖 std::recursive_mutex 和非遞迴 std::mutex。Java 的兩種互斥鎖實現以及讀寫鎖實現均支援重入。POSIX 使用一種叫做重入函式的方法保證函式的執行緒安全,鎖粒度是呼叫而非執行緒。

讀寫鎖

支援兩種模式的鎖,當採用寫模式上鎖時與互斥鎖相同,是獨佔模式。但讀模式上鎖可以被多個讀執行緒讀取。即寫時使用互斥鎖,讀時採用共享鎖,故又叫共享-獨 佔鎖。一種常見的錯誤認為資料只有在寫入時才需要鎖,事實是即使是讀操作也需要鎖保護,如果不這麼做的話,讀寫鎖的讀模式便毫無意義。

樂觀鎖(Optimistic Locking)

相對悲觀鎖而言,樂觀鎖(Optimistic Locking)機制採取了更加寬鬆的加鎖機制。相對悲觀鎖而言,樂觀鎖假設認為資料一般情況下不會造成衝突,所以在資料進行提交更新的時候,才會正式對資料的衝突與否進行檢測,如果發現衝突了,則讓返回使用者錯誤的資訊,讓使用者決定如何去做。上面提到的樂觀鎖的概念中其實已經闡述了他的具體實現細節:主要就是兩個步驟:衝突檢測和資料更新。其實現方式有一種比較典型的就是 Compare and Swap。

CAS 與 ABA

CAS 是項樂觀鎖技術,當多個執行緒嘗試使用 CAS 同時更新同一個變數時,只有其中一個執行緒能更新變數的值,而其它執行緒都失敗,失敗的執行緒並不會被掛起,而是被告知這次競爭中失敗,並可以再次嘗試。CAS 操作包含三個運算元 —— 記憶體位置(V)、預期原值(A)和新值(B)。如果記憶體位置的值與預期原值相匹配,那麼處理器會自動將該位置值更新為新值。否則,處理器不做任何操作。無論哪種情況,它都會在 CAS 指令之前返回該位置的值。CAS 有效地說明了我認為位置 V 應該包含值 A;如果包含該值,則將 B 放到這個位置;否則,不要更改該位置,只告訴我這個位置現在的值即可。這其實和樂觀鎖的衝突檢查+資料更新的原理是一樣的。

樂觀鎖也不是萬能的,樂觀併發控制相信事務之間的資料競爭(Data Race)的概率是比較小的,因此儘可能直接做下去,直到提交的時候才去鎖定,所以不會產生任何鎖和死鎖。但如果直接簡單這麼做,還是有可能會遇到不可預期的結果,例如兩個事務都讀取了資料庫的某一行,經過修改以後寫回資料庫,這時就遇到了問題。

  • 樂觀鎖只能保證一個共享變數的原子操作。如上例子,自旋過程中只能保證 value 變數的原子性,這時如果多一個或幾個變數,樂觀鎖將變得力不從心,但互斥鎖能輕易解決,不管物件數量多少及物件顆粒度大小。
  • 長時間自旋可能導致開銷大。假如 CAS 長時間不成功而一直自旋,會給 CPU 帶來很大的開銷。
  • ABA 問題。

CAS 的核心思想是通過比對記憶體值與預期值是否一樣而判斷記憶體值是否被改過,但這個判斷邏輯不嚴謹,假如記憶體值原來是 A,後來被 一條執行緒改為 B,最後又被改成了 A,則 CAS 認為此記憶體值並沒有發生改變,但實際上是有被其他執行緒改過的,這種情況對依賴過程值的情景的運算結果影響很大。解決的思路是引入版本號,每次變數更新都把版本號加一。部分樂觀鎖的實現是通過版本號(version)的方式來解決 ABA 問題,樂觀鎖每次在執行資料的修改操作時,都會帶上一個版本號,一旦版本號和資料的版本號一致就可以執行修改操作並對版本號執行 +1 操作,否則就執行失敗。因為每次操作的版本號都會隨之增加,所以不會出現 ABA 問題,因為版本號只會增加不會減少。

自旋鎖

Linux 核心中最常見的鎖,作用是在多核處理器間同步資料。這裡的自旋是忙等待的意思。如果一個執行緒(這裡指的是核心執行緒)已經持有了一個自旋鎖,而另一條執行緒也想要獲取該鎖,它就不停地迴圈等待,或者叫做自旋等待直到鎖可用。可以想象這種鎖不能被某個執行緒長時間持有,這會導致其他執行緒一直自旋,消耗處理器。所以,自旋鎖使用範圍很窄,只允許短期內加鎖。

其實還有一種方式就是讓等待執行緒睡眠直到鎖可用,這樣就可以消除忙等待。很明顯後者優於前者的實現,但是卻不適用於此,如果我們使用第二種方式,我們要做幾步操作:把該等待執行緒換出、等到鎖可用在換入,有兩次上下文切換的代價。這個代價和短時間內自旋(實現起來也簡單)相比,後者更能適應實際情況的需要。還有一點需要注意,試圖獲取一個已經持有自旋鎖的執行緒再去獲取這個自旋鎖或導致死鎖,但其他作業系統並非如此。

自旋鎖與互斥鎖有點類似,只是自旋鎖不會引起呼叫者睡眠,如果自旋鎖已經被別的執行單元保持,呼叫者就一直迴圈在那裡看是 否該自旋鎖的保持者已經釋放了鎖,"自旋"一詞就是因此而得名。其作用是為了解決某項資源的互斥使用。因為自旋鎖不會引起呼叫者睡眠,所以自旋鎖的效率遠 高於互斥鎖。雖然它的效率比互斥鎖高,但是它也有些不足之處:

  • 自旋鎖一直佔用 CPU,他在未獲得鎖的情況下,一直執行--自旋,所以佔用著 CPU,如果不能在很短的時 間內獲得鎖,這無疑會使 CPU 效率降低。
  • 在用自旋鎖時有可能造成死鎖,當遞迴呼叫時有可能造成死鎖,呼叫有些其他函式也可能造成死鎖,如 copy_to_user()、copy_from_user()、kmalloc()等。

自旋鎖比較適用於鎖使用者保持鎖時間比較短的情況。正是由於自旋鎖使用者一般保持鎖時間非常短,因此選擇自旋而不是睡眠是非常必要的,自旋鎖的效率遠高於互斥鎖。訊號量和讀寫訊號量適合於保持時間較長的情況,它們會導致呼叫者睡眠,因此只能在程式上下文使用,而自旋鎖適合於保持時間非常短的情況,它可以在任何上下文使用。如果被保護的共享資源只在程式上下文訪問,使用訊號量保護該共享資源非常合適,如果對共享資源的訪問時間非常短,自旋鎖也可以。但是如果被保護的共享資源需要在中斷上下文訪問(包括底半部即中斷處理控制程式碼和頂半部即軟中斷),就必須使用自旋鎖。自旋鎖保持期間是搶佔失效的,而訊號量和讀寫訊號量保持期間是可以被搶佔的。自旋鎖只有在核心可搶佔或 SMP(多處理器)的情況下才真正需要,在單 CPU 且不可搶佔的核心下,自旋鎖的所有操作都是空操作。另外格外注意一點:自旋鎖不能遞迴使用。

MVCC

為了實現可序列化,同時避免鎖機制存在的各種問題,我們可以採用基於多版本併發控制(Multiversion concurrency control,MVCC)思想的無鎖事務機制。人們一般把基於鎖的併發控制機制稱成為悲觀機制,而把 MVCC 機制稱為樂觀機制。這是因為鎖機制是一種預防性的,讀會阻塞寫,寫也會阻塞讀,當鎖定粒度較大,時間較長時併發效能就不會太好;而 MVCC 是一種後驗性的,讀不阻塞寫,寫也不阻塞讀,等到提交的時候才檢驗是否有衝突,由於沒有鎖,所以讀寫不會相互阻塞,從而大大提升了併發效能。我們可以借用原始碼版本控制來理解 MVCC,每個人都可以自由地閱讀和修改本地的程式碼,相互之間不會阻塞,只在提交的時候版本控制器會檢查衝突,並提示 merge。目前,Oracle、PostgreSQL 和 MySQL 都已支援基於 MVCC 的併發機制,但具體實現各有不同。

MVCC 的一種簡單實現是基於 CAS(Compare-and-swap)思想的有條件更新(Conditional Update)。普通的 update 引數只包含了一個 keyValueSet’,Conditional Update 在此基礎上加上了一組更新條件 conditionSet { … data[keyx]=valuex, … },即只有在 D 滿足更新條件的情況下才將資料更新為 keyValueSet’;否則,返回錯誤資訊。這樣,L 就形成了如下圖所示的 Try/Conditional Update/(Try again) 的處理模式:

對於常見的修改使用者帳戶資訊的例子而言,假設資料庫中帳戶資訊表中有一個 version 欄位,當前值為 1 ;而當前帳戶餘額欄位(balance)為 100。

  • 操作員 A 此時將其讀出(version=1),並從其帳戶餘額中扣除 50 (100-50)。

  • 在操作員 A 操作的過程中,操作員 B 也讀入此使用者資訊(version=1),並從其帳戶餘額中扣除 20 (100-20)。

  • 操作員 A 完成了修改工作,將資料版本號加一(version=2),連同帳戶扣除後餘額(balance=50),提交至資料庫更新,此時由於提交資料版本大於資料庫記錄當前版本,資料被更新,資料庫記錄 version 更新為 2 。

  • 操作員 B 完成了操作,也將版本號加一(version=2)試圖向資料庫提交資料(balance=80),但此時比對資料庫記錄版本時發現,操作員 B 提交的資料版本號為 2 ,資料庫記錄當前版本也為 2 ,不滿足提交版本必須大於記錄當前版本才能執行更新的樂觀鎖策略,因此,操作員 B 的提交被駁回。這樣,就避免了操作員 B 用基於 version=1 的舊資料修改的結果覆蓋操作員 A 的操作結果的可能。

從上面的例子可以看出,樂觀鎖機制避免了長事務中的資料庫加鎖開銷(操作員 A 和操作員 B 操作過程中,都沒有對資料庫資料加鎖),大大提升了大併發量下的系統整體效能表現。需要注意的是,樂觀鎖機制往往基於系統中的資料儲存邏輯,因此也具備一定的侷限性,如在上例中,由於樂觀鎖機制是在我們的系統中實現,來自外部系統的使用者餘額更新操作不受我們系統的控制,因此可能會造成髒資料被更新到資料庫中。


併發 IO

IO 的概念,從字義來理解就是輸入輸出。作業系統從上層到底層,各個層次之間均存在 IO。比如,CPU 有 IO,記憶體有 IO, VMM 有 IO, 底層磁碟上也有 IO,這是廣義上的 IO。通常來講,一個上層的 IO 可能會產生針對磁碟的多個 IO,也就是說,上層的 IO 是稀疏的,下層的 IO 是密集的。磁碟的 IO,顧名思義就是磁碟的輸入輸出。輸入指的是對磁碟寫入資料,輸出指的是從磁碟讀出資料。

所謂的併發 IO,即在一個時間片內,如果一個程式進行一個 IO 操作,例如讀個檔案,這個時候該程式可以把自己標記為“休眠狀態”並出讓 CPU 的使用權,待檔案讀進記憶體,作業系統會把這個休眠的程式喚醒,喚醒後的程式就有機會重新獲得 CPU 的使用權了。這裡的程式在等待 IO 時之所以會釋放 CPU 使用權,是為了讓 CPU 在這段等待時間裡可以做別的事情,這樣一來 CPU 的使用率就上來了;此外,如果這時有另外一個程式也讀檔案,讀檔案的操作就會排隊,磁碟驅動在完成一個程式的讀操作後,發現有排隊的任務,就會立即啟動下一個讀操作,這樣 IO 的使用率也上來了。

IO 型別

Unix 中內建了 5 種 IO 模型,阻塞式 IO, 非阻塞式 IO,IO 複用模型,訊號驅動式 IO 和非同步 IO。而從應用的角度來看,IO 的型別可以分為:

  • 大/小塊 IO:這個數值指的是控制器指令中給出的連續讀出扇區數目的多少。如果數目較多,如 64,128 等,我們可以認為是大塊 IO;反之,如果很小,比如 4,8,我們就會認為是小塊 IO,實際上,在大塊和小塊 IO 之間,沒有明確的界限。

  • 連續/隨機 IO:連續 IO 指的是本次 IO 給出的初始扇區地址和上一次 IO 的結束扇區地址是完全連續或者相隔不多的。反之,如果相差很大,則算作一次隨機 IO。連續 IO 比隨機 IO 效率高的原因是:在做連續 IO 的時候,磁頭幾乎不用換道,或者換道的時間很短;而對於隨機 IO,如果這個 IO 很多的話,會導致磁頭不停地換道,造成效率的極大降低。

  • 順序/併發 IO:從概念上講,併發 IO 就是指向一塊磁碟發出一條 IO 指令後,不必等待它回應,接著向另外一塊磁碟發 IO 指令。對於具有條帶性的 RAID(LUN),對其進行的 IO 操作是併發的,例如:raid 0+1(1+0),raid5 等。反之則為順序 IO。

在傳統的網路伺服器的構建中,IO 模式會按照 Blocking/Non-Blocking、Synchronous/Asynchronous 這兩個標準進行分類,其中 Blocking 與 Synchronous 大同小異,而 NIO 與 Async 的區別在於 NIO 強調的是 輪詢(Polling),而 Async 強調的是通知(Notification)。譬如在一個典型的單程式單執行緒 Socket 介面中,阻塞型的介面必須在上一個 Socket 連線關閉之後才能接入下一個 Socket 連線。而對於 NIO 的 Socket 而言,服務端應用會從核心獲取到一個特殊的 "Would Block" 錯誤資訊,但是並不會阻塞到等待發起請求的 Socket 客戶端停止。

併發程式設計導論

一般來說,在 Linux 系統中可以通過呼叫獨立的 select 或者 epoll 方法來遍歷所有讀取好的資料,並且進行寫操作。而對於非同步 Socket 而言(譬如 Windows 中的 Sockets 或者 .Net 中實現的 Sockets 模型),服務端應用會告訴 IO Framework 去讀取某個 Socket 資料,在資料讀取完畢之後 IO Framework 會自動地呼叫你的回撥(也就是通知應用程式本身資料已經準備好了)。以 IO 多路複用中的 Reactor 與 Proactor 模型為例,非阻塞的模型是需要應用程式本身處理 IO 的,而非同步模型則是由 Kernel 或者 Framework 將資料準備好讀入緩衝區中,應用程式直接從緩衝區讀取資料。

  • 同步阻塞:在此種方式下,使用者程式在發起一個 IO 操作以後,必須等待 IO 操作的完成,只有當真正完成了 IO 操作以後,使用者程式才能執行。

  • 同步非阻塞:在此種方式下,使用者程式發起一個 IO 操作以後邊可返回做其它事情,但是使用者程式需要時不時的詢問 IO 操作是否就緒,這就要求使用者程式不停的去詢問,從而引入不必要的 CPU 資源浪費。

  • 非同步非阻塞:在此種模式下,使用者程式只需要發起一個 IO 操作然後立即返回,等 IO 操作真正的完成以後,應用程式會得到 IO 操作完成的通知,此時使用者程式只需要對資料進行處理就好了,不需要進行實際的 IO 讀寫操作,因為真正的 IO 讀取或者寫入操作已經由核心完成了。

而在併發 IO 的問題中,較常見的就是所謂的 C10K 問題,即有 10000 個客戶端需要連上一個伺服器並保持 TCP 連線,客戶端會不定時的傳送請求給伺服器,伺服器收到請求後需及時處理並返回結果。

IO 多路複用

IO 多路複用就通過一種機制,可以監視多個描述符,一旦某個描述符就緒(一般是讀就緒或者寫就緒),能夠通知程式進行相應的讀寫操作。select,poll,epoll 都是 IO 多路複用的機制。值得一提的是,epoll 僅對於 Pipe 或者 Socket 這樣的讀寫阻塞型 IO 起作用,正常的檔案描述符則是會立刻返回檔案的內容,因此 epoll 等函式對普通的檔案讀寫並無作用。

首先來看下可讀事件與可寫事件:當如下任一情況發生時,會產生套接字的可讀事件:

  • 該套接字的接收緩衝區中的資料位元組數大於等於套接字接收緩衝區低水位標記的大小;
  • 該套接字的讀半部關閉(也就是收到了 FIN),對這樣的套接字的讀操作將返回 0(也就是返回 EOF);
  • 該套接字是一個監聽套接字且已完成的連線數不為 0;
  • 該套接字有錯誤待處理,對這樣的套接字的讀操作將返回-1。

當如下任一情況發生時,會產生套接字的可寫事件:

  • 該套接字的傳送緩衝區中的可用空間位元組數大於等於套接字傳送緩衝區低水位標記的大小;
  • 該套接字的寫半部關閉,繼續寫會產生 SIGPIPE 訊號;
  • 非阻塞模式下,connect 返回之後,該套接字連線成功或失敗;
  • 該套接字有錯誤待處理,對這樣的套接字的寫操作將返回-1。

select,poll,epoll 本質上都是同步 IO,因為他們都需要在讀寫事件就緒後自己負責進行讀寫,也就是說這個讀寫過程是阻塞的,而非同步 IO 則無需自己負責進行讀寫,非同步 IO 的實現會負責把資料從核心拷貝到使用者空間。select 本身是輪詢式、無狀態的,每次呼叫都需要把 fd 集合從使用者態拷貝到核心態,這個開銷在 fd 很多時會很大。epoll 則是觸發式處理連線,維護的描述符數目不受到限制,而且效能不會隨著描述符數目的增加而下降。

方法 數量限制 連線處理 記憶體操作
select 描述符個數由核心中的 FD_SETSIZE 限制,僅為 1024;重新編譯核心改變 FD_SETSIZE 的值,但是無法優化效能 每次呼叫 select 都會線性掃描所有描述符的狀態,在 select 結束後,使用者也要線性掃描 fd_set 陣列才知道哪些描述符準備就緒(O(n)) 每次呼叫 select 都要在使用者空間和核心空間裡進行記憶體複製 fd 描述符等資訊
poll 使用 pollfd 結構來儲存 fd,突破了 select 中描述符數目的限制 類似於 select 掃描方式 需要將 pollfd 陣列拷貝到核心空間,之後依次掃描 fd 的狀態,整體複雜度依然是 O(n)的,在併發量大的情況下伺服器效能會快速下降
epoll 該模式下的 Socket 對應的 fd 列表由一個陣列來儲存,大小不限制(預設 4k) 基於核心提供的反射模式,有活躍 Socket 時,核心訪問該 Socket 的 callback,不需要遍歷輪詢 epoll 在傳遞核心與使用者空間的訊息時使用了記憶體共享,而不是記憶體拷貝,這也使得 epoll 的效率比 poll 和 select 更高

相關文章