摘要:并發(fā)編程導論是對于分布式計算并發(fā)編程系列的總結與歸納。并發(fā)編程導論隨著硬件性能的迅猛發(fā)展與大數據時代的來臨,并發(fā)編程日益成為編程中不可忽略的重要組成部分。并發(fā)編程復興的主要驅動力來自于所謂的多核危機。
并發(fā)編程導論是對于分布式計算-并發(fā)編程 https://url.wx-coder.cn/Yagu8 系列的總結與歸納。歡迎關注公眾號:某熊的技術之路。并發(fā)編程導論
隨著硬件性能的迅猛發(fā)展與大數據時代的來臨,并發(fā)編程日益成為編程中不可忽略的重要組成部分。簡單定義來看,如果執(zhí)行單元的邏輯控制流在時間上重疊,那它們就是并發(fā)(Concurrent)的。并發(fā)編程復興的主要驅動力來自于所謂的“多核危機”。正如摩爾定律所預言的那樣,芯片性能仍在不斷提高,但相比加快 CPU 的速度,計算機正在向多核化方向發(fā)展。正如 Herb Sutter 所說,“免費午餐的時代已然終結”。為了讓代碼運行得更快,單純依靠更快的硬件已無法滿足要求,并行和分布式計算是現代應用程序的主要內容,我們需要利用多個核心或多臺機器來加速應用程序或大規(guī)模運行它們。
并發(fā)編程是非常廣泛的概念,其向下依賴于操作系統(tǒng)、存儲等,與分布式系統(tǒng)、微服務等,而又會具體落地于 Java 并發(fā)編程、Go 并發(fā)編程、JavaScript 異步編程等領域。云計算承諾在所有維度上(內存、計算、存儲等)實現無限的可擴展性,并發(fā)編程及其相關理論也是我們構建大規(guī)模分布式應用的基礎。
本節(jié)主要討論并發(fā)編程理論相關的內容,可以參閱 [Java 并發(fā)編程 https://url.wx-coder.cn/72vCj 、Go 并發(fā)編程 https://url.wx-coder.cn/FO9EX 等了解具體編程語言中并發(fā)編程的實踐,可以參閱微服務實戰(zhàn) https://url.wx-coder.cn/7KZ2i 或者關系型數據庫理論 https://url.wx-coder.cn/DJNQn 了解并發(fā)編程在實際系統(tǒng)中的應用。
并發(fā)與并行并發(fā)就是可同時發(fā)起執(zhí)行的程序,指程序的邏輯結構;并行就是可以在支持并行的硬件上執(zhí)行的并發(fā)程序,指程序的運?狀態(tài)。換句話說,并發(fā)程序代表了所有可以實現并發(fā)行為的程序,這是一個比較寬泛的概念,并行程序也只是他的一個子集。并發(fā)是并?的必要條件;但并發(fā)不是并?的充分條件。并發(fā)只是更符合現實問題本質的表達,目的是簡化代碼邏輯,?不是使程序運?更快。要是程序運?更快必是并發(fā)程序加多核并?。
簡言之,并發(fā)是同一時間應對(dealing with)多件事情的能力;并行是同一時間動手做(doing)多件事情的能力。
并發(fā)是問題域中的概念——程序需要被設計成能夠處理多個同時(或者幾乎同時)發(fā)生的事件;一個并發(fā)程序含有多個邏輯上的獨立執(zhí)行塊,它們可以獨立地并行執(zhí)行,也可以串行執(zhí)行。而并行則是方法域中的概念——通過將問題中的多個部分并行執(zhí)行,來加速解決問題。一個并行程序解決問題的速度往往比一個串行程序快得多,因為其可以同時執(zhí)行整個任務的多個部分。并行程序可能有多個獨立執(zhí)行塊,也可能僅有一個。
具體而言,Redis 會是一個很好地區(qū)分并發(fā)和并行的例子。Redis 本身是一個單線程的數據庫,但是可以通過多路復用與事件循環(huán)的方式來提供并發(fā)地 IO 服務。這是因為多核并行本質上會有很大的一個同步的代價,特別是在鎖或者信號量的情況下。因此,Redis 利用了單線程的事件循環(huán)來保證一系列的原子操作,從而保證了即使在高并發(fā)的情況下也能達到幾乎零消耗的同步。再引用下 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).線程級并發(fā)
從 20 世紀 60 年代初期出現時間共享以來,計算機系統(tǒng)中就開始有了對并發(fā)執(zhí)行的支持;傳統(tǒng)意義上,這種并發(fā)執(zhí)行只是模擬出來的,是通過使一臺計算機在它正在執(zhí)行的進程間快速切換的方式實現的,這種配置稱為單處理器系統(tǒng)。從 20 世紀 80 年代開始,多處理器系統(tǒng),即由單操作系統(tǒng)內核控制的多處理器組成的系統(tǒng)采用了多核處理器與超線程(HyperThreading)等技術允許我們實現真正的并行。多核處理器是將多個 CPU 集成到一個集成電路芯片上:
超線程,有時稱為同時多線程(simultaneous multi-threading),是一項允許一個 CPU 執(zhí)行多個控制流的技術。它涉及 CPU 某些硬件有多個備份,比如程序計數器和寄存器文件;而其他的硬件部分只有一份,比如執(zhí)行浮點算術運算的單元。常規(guī)的處理器需要大約 20 000 個時鐘周期做不同線程間的轉換,而超線程的處理器可以在單個周期的基礎上決定要執(zhí)行哪一個線程。這使得 CPU 能夠更好地利用它的處理資源。例如,假設一個線程必須等到某些數據被裝載到高速緩存中,那 CPU 就可以繼續(xù)去執(zhí)行另一個線程。
指令級并發(fā)在較低的抽象層次上,現代處理器可以同時執(zhí)行多條指令的屬性稱為指令級并行。實每條指令從開始到結束需要長得多的時間,大約 20 個或者更多的周期,但是處理器使用了非常多的聰明技巧來同時處理多達 100 條的指令。在流水線中,將執(zhí)行一條指令所需要的活動劃分成不同的步驟,將處理器的硬件組織成一系列的階段,每個階段執(zhí)行一個步驟。這些階段可以并行地操作,用來處理不同指令的不同部分。我們會看到一個相當簡單的硬件設計,它能夠達到接近于一個時鐘周期一條指令的執(zhí)行速率。如果處理器可以達到比一個周期一條指令更快的執(zhí)行速率,就稱之為超標量(Super Scalar)處理器。
單指令、多數據在最低層次上,許多現代處理器擁有特殊的硬件,允許一條指令產生多個可以并行執(zhí)行的操作,這種方式稱為單指令、多數據,即 SIMD 并行。例如,較新的 Intel 和 AMD 處理器都具有并行地對 4 對單精度浮點數(C 數據類型 float)做加法的指令。
內存模型如前文所述,現代計算機通常有兩個或者更多的 CPU,一些 CPU 還有多個核;其允許多個線程同時運行,每個 CPU 在某個時間片內運行其中的一個線程。在存儲管理 https://parg.co/Z47 一節(jié)中我們介紹了計算機系統(tǒng)中的不同的存儲類別:
每個 CPU 包含多個寄存器,這些寄存器本質上就是 CPU 內存;CPU 在寄存器中執(zhí)行操作的速度會比在主內存中操作快非常多。每個 CPU 可能還擁有 CPU 緩存層,CPU 訪問緩存層的速度比訪問主內存塊很多,但是卻比訪問寄存器要慢。計算機還包括主內存(RAM),所有的 CPU 都可以訪問這個主內存,主內存一般都比 CPU 緩存大很多,但速度要比 CPU 緩存慢。當一個 CPU 需要訪問主內存的時候,會把主內存中的部分數據讀取到 CPU 緩存,甚至進一步把緩存中的部分數據讀取到內部的寄存器,然后對其進行操作。當 CPU 需要向主內存寫數據的時候,會將寄存器中的數據寫入緩存,某些時候會將數據從緩存刷入主內存。無論從緩存讀還是寫數據,都沒有必要一次性全部讀出或者寫入,而是僅對部分數據進行操作。
并發(fā)編程中的問題,往往源于緩存導致的可見性問題、線程切換導致的原子性問題以及編譯優(yōu)化帶來的有序性問題。以 Java 虛擬機為例,每個線程都擁有一個屬于自己的線程棧(調用棧),隨著線程代碼的執(zhí)行,調用棧會隨之改變。線程棧中包含每個正在執(zhí)行的方法的局部變量。每個線程只能訪問屬于自己的棧。調用棧中的局部變量,只有創(chuàng)建這個棧的線程才可以訪問,其他線程都不能訪問。即使兩個線程在執(zhí)行一段相同的代碼,這兩個線程也會在屬于各自的線程棧中創(chuàng)建局部變量。因此,每個線程擁有屬于自己的局部變量。所有基本類型的局部變量全部存放在線程棧中,對其他線程不可見。一個線程可以把基本類型拷貝到其他線程,但是不能共享給其他線程,而無論哪個線程創(chuàng)建的對象都存放在堆中。
可見性所謂的可見性,即是一個線程對共享變量的修改,另外一個線程能夠立刻看到。單核時代,所有的線程都是直接操作單個 CPU 的數據,某個線程對緩存的寫對另外一個線程來說一定是可見的;譬如下圖中,如果線程 B 在線程 A 更新了變量值之后進行訪問,那么獲得的肯定是變量 V 的最新值。多核時代,每顆 CPU 都有自己的緩存,共享變量存儲在主內存。運行在某個 CPU 中的線程將共享變量讀取到自己的 CPU 緩存。在 CPU 緩存中,修改了共享對象的值,由于 CPU 并未將緩存中的數據刷回主內存,導致對共享變量的修改對于在另一個 CPU 中運行的線程而言是不可見的。這樣每個線程都會擁有一份屬于自己的共享變量的拷貝,分別存于各自對應的 CPU 緩存中。
可見性問題最經典的案例即是并發(fā)加操作,如下兩個線程同時在更新變量 test 的 count 屬性域的值,第一次都會將 count=0 讀到各自的 CPU 緩存里,執(zhí)行完 count+=1 之后,各自 CPU 緩存里的值都是 1,同時寫入內存后,我們會發(fā)現內存中是 1,而不是我們期望的 2。之后由于各自的 CPU 緩存里都有了 count 的值,兩個線程都是基于 CPU 緩存里的 count 值來計算,所以導致最終 count 的值都是小于 20000 的。
Thread th1 = new Thread(()->{ test.add10K(); }); Thread th2 = new Thread(()->{ test.add10K(); }); // 每個線程中對相同對象執(zhí)行加操作 count += 1;
在 Java 中,如果多個線程共享一個對象,并且沒有合理的使用 volatile 聲明和線程同步,一個線程更新共享對象后,另一個線程可能無法取到對象的最新值。當一個共享變量被 volatile 修飾時,它會保證修改的值會立即被更新到主存,當有其他線程需要讀取時,它會去內存中讀取新值。通過 synchronized 和 Lock 也能夠保證可見性,synchronized 和 Lock 能保證同一時刻只有一個線程獲取鎖然后執(zhí)行同步代碼,并且在釋放鎖之前會將對變量的修改刷新到主存當中。因此可以保證可見性。
原子性所謂的原子性,就是一個或者多個操作在 CPU 執(zhí)行的過程中不被中斷的特性,CPU 能保證的原子操作是 CPU 指令級別的,而不是高級語言的操作符。我們在編程語言中部分看似原子操作的指令,在被編譯到匯編之后往往會變成多個操作:
i++ # 編譯成匯編之后就是: # 讀取當前變量 i 并把它賦值給一個臨時寄存器; movl i(%rip), %eax # 給臨時寄存器+1; addl $1, %eax # 把 eax 的新值寫回內存 movl %eax, i(%rip)
我們可以清楚看到 C 代碼只需要一句,但編譯成匯編卻需要三步(這里不考慮編譯器優(yōu)化,實際上通過編譯器優(yōu)化可以將這三條匯編指令合并成一條)。也就是說,只有簡單的讀取、賦值(而且必須是將數字賦值給某個變量,變量之間的相互賦值不是原子操作)才是原子操作。按照原子操作解決同步問題方式:依靠處理器原語支持把上述三條指令合三為一,當做一條指令來執(zhí)行,保證在執(zhí)行過程中不會被打斷并且多線程并發(fā)也不會受到干擾。這樣同步問題迎刃而解,這也就是所謂的原子操作。但處理器沒有義務為任意代碼片段提供原子性操作,尤其是我們的臨界區(qū)資源十分龐大甚至大小不確定,處理器沒有必要或是很難提供原子性支持,此時往往需要依賴于鎖來保證原子性。
對應原子操作/事務在 Java 中,對基本數據類型的變量的讀取和賦值操作是原子性操作,即這些操作是不可被中斷的,要么執(zhí)行,要么不執(zhí)行。Java 內存模型只保證了基本讀取和賦值是原子性操作,如果要實現更大范圍操作的原子性,可以通過 synchronized 和 Lock 來實現。由于 synchronized 和 Lock 能夠保證任一時刻只有一個線程執(zhí)行該代碼塊,那么自然就不存在原子性問題了,從而保證了原子性。
有序性顧名思義,有序性指的是程序按照代碼的先后順序執(zhí)行。代碼重排是指編譯器對用戶代碼進行優(yōu)化以提高代碼的執(zhí)行效率,優(yōu)化前提是不改變代碼的結果,即優(yōu)化前后代碼執(zhí)行結果必須相同。
譬如:
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 中與有序性相關的經典問題就是單例模式,譬如我們會采用靜態(tài)函數來獲取某個對象的實例,并且使用 synchronized 加鎖來保證只有單線程能夠觸發(fā)創(chuàng)建,其他線程則是直接獲取到實例對象。
if (instance == null) { synchronized(Singleton.class) { if (instance == null) instance = new Singleton(); } }
不過雖然我們期望的對象創(chuàng)建的過程是:內存分配、初始化對象、將對象引用賦值給成員變量,但是實際情況下經過優(yōu)化的代碼往往會首先進行變量賦值,而后進行對象初始化。假設線程 A 先執(zhí)行 getInstance() 方法,當執(zhí)行完指令 2 時恰好發(fā)生了線程切換,切換到了線程 B 上;如果此時線程 B 也執(zhí)行 getInstance() 方法,那么線程 B 在執(zhí)行第一個判斷時會發(fā)現 instance != null,所以直接返回 instance,而此時的 instance 是沒有初始化過的,如果我們這個時候訪問 instance 的成員變量就可能觸發(fā)空指針異常。
內存屏障多處理器同時訪問共享主存,每個處理器都要對讀寫進行重新排序,一旦數據更新,就需要同步更新到主存上 (這里并不要求處理器緩存更新之后立刻更新主存)。在這種情況下,代碼和指令重排,再加上緩存延遲指令結果輸出導致共享變量被修改的順序發(fā)生了變化,使得程序的行為變得無法預測。為了解決這種不可預測的行為,處理器提供一組機器指令來確保指令的順序要求,它告訴處理器在繼續(xù)執(zhí)行前提交所有尚未處理的載入和存儲指令。同樣的也可以要求編譯器不要對給定點以及周圍指令序列進行重排。這些確保順序的指令稱為內存屏障。具體的確保措施在程序語言級別的體現就是內存模型的定義。
POSIX、C++、Java 都有各自的共享內存模型,實現上并沒有什么差異,只是在一些細節(jié)上稍有不同。這里所說的內存模型并非是指內存布 局,特指內存、Cache、CPU、寫緩沖區(qū)、寄存器以及其他的硬件和編譯器優(yōu)化的交互時對讀寫指令操作提供保護手段以確保讀寫序。將這些繁雜因素可以籠 統(tǒng)的歸納為兩個方面:重排和緩存,即上文所說的代碼重排、指令重排和 CPU Cache。簡單的說內存屏障做了兩件事情:拒絕重排,更新緩存。
C++11 提供一組用戶 API std::memory_order 來指導處理器讀寫順序。Java 使用 happens-before 規(guī)則來屏蔽具體細節(jié)保證,指導 JVM 在指令生成的過程中穿插屏障指令。內存屏障也可以在編譯期間指示對指令或者包括周圍指令序列不進行優(yōu)化,稱之為編譯器屏障,相當于輕量級內存屏障,它的工作同樣重要,因為它在編譯期指導編譯器優(yōu)化。屏障的實現稍微復雜一些,我們使用一組抽象的假想指令來描述內存屏障的工作原理。使用 MB_R、MB_W、MB 來抽象處理器指令為宏:
MB_R 代表讀內存屏障,它保證讀取操作不會重排到該指令調用之后。
MB_W 代表寫內存屏障,它保證寫入操作不會重排到該指令調用之后。
MB 代表讀寫內存屏障,可保證之前的指令不會重排到該指令調用之后。
這些屏障指令在單核處理器上同樣有效,因為單處理器雖不涉及多處理器間數據同步問題,但指令重排和緩存仍然影響數據的正確同步。指令重排是非常底層的且實 現效果差異非常大,尤其是不同體系架構對內存屏障的支持程度,甚至在不支持指令重排的體系架構中根本不必使用屏障指令。具體如何使用這些屏障指令是支持的 平臺、編譯器或虛擬機要實現的,我們只需要使用這些實現的 API(指的是各種并發(fā)關鍵字、鎖、以及重入性等,下節(jié)詳細介紹)。這里的目的只是為了幫助更好 的理解內存屏障的工作原理。
內存屏障的意義重大,是確保正確并發(fā)的關鍵。通過正確的設置內存屏障可以確保指令按照我們期望的順序執(zhí)行。這里需要注意的是內存屏蔽只應該作用于需要同步的指令或者還可以包含周圍指令的片段。如果用來同步所有指令,目前絕大多數處理器架構的設計就會毫無意義。
Java 內存模型(Java Memory Model, JMM)Java 內存模型著眼于描述 Java 中的線程是如何與內存進行交互,以及單線程中代碼執(zhí)行的順序等,并提供了一系列基礎的并發(fā)語義原則;最早的 Java 內存模型于 1995 年提出,致力于解決不同處理器/操作系統(tǒng)中線程交互/同步的問題,規(guī)定和指引 Java 程序在不同的內存架構、CPU 和操作系統(tǒng)間有確定性地行為。在 Java 5 版本之前,JMM 并不完善,彼時多線程往往會在共享內存中讀取到很多奇怪的數據;譬如,某個線程無法看到其他線程對共享變量寫入的值,或者因為指令重排序的問題,某個線程可能看到其他線程奇怪的操作步驟。
Java 內存模型具備一些先天的“有序性”,即不需要通過任何手段就能夠得到保證的有序性,這個通常也稱為 happens-before 原則。如果兩個操作的執(zhí)行次序無法從 happens-before 原則推導出來,那么它們就不能保證它們的有序性,虛擬機可以隨意地對它們進行重排序。
Java 內存模型對一個線程所做的變動能被其它線程可見提供了保證,它們之間是先行發(fā)生關系。
線程內的代碼能夠按先后順序執(zhí)行,這被稱為程序次序規(guī)則。
對于同一個鎖,一個解鎖操作一定要發(fā)生在時間上后發(fā)生的另一個鎖定操作之前,也叫做管程鎖定規(guī)則。
前一個對 volatile 的寫操作在后一個 volatile 的讀操作之前,也叫 volatile 變量規(guī)則。
一個線程內的任何操作必需在這個線程的 start()調用之后,也叫作線程啟動規(guī)則。
一個線程的所有操作都會在線程終止之前,線程終止規(guī)則。
一個對象的終結操作必需在這個對象構造完成之后,也叫對象終結規(guī)則。
對于程序次序規(guī)則來說,就是一段程序代碼的執(zhí)行在單個線程中看起來是有序的。注意,雖然這條規(guī)則中提到“書寫在前面的操作先行發(fā)生于書寫在后面的操作”,這個應該是程序看起來執(zhí)行的順序是按照代碼順序執(zhí)行的,因為虛擬機可能會對程序代碼進行指令重排序。雖然進行重排序,但是最終執(zhí)行的結果是與程序順序執(zhí)行的結果一致的,它只會對不存在數據依賴性的指令進行重排序。因此,在單個線程中,程序執(zhí)行看起來是有序執(zhí)行的,這一點要注意理解。事實上,這個規(guī)則是用來保證程序在單線程中執(zhí)行結果的正確性,但無法保證程序在多線程中執(zhí)行的正確性。
進程,線程與協程在未配置 OS 的系統(tǒng)中,程序的執(zhí)行方式是順序執(zhí)行,即必須在一個程序執(zhí)行完后,才允許另一個程序執(zhí)行;在多道程序環(huán)境下,則允許多個程序并發(fā)執(zhí)行。程序的這兩種執(zhí)行方式間有著顯著的不同。也正是程序并發(fā)執(zhí)行時的這種特征,才導致了在操作系統(tǒng)中引入進程的概念。進程是資源分配的基本單位,線程是資源調度的基本單位。
早期的操作系統(tǒng)基于進程來調度 CPU,不同進程間是不共享內存空間的,所以進程要做任務切換就要切換內存映射地址,而一個進程創(chuàng)建的所有線程,都是共享一個內存空間的,所以線程做任務切換成本就很低了。現代的操作系統(tǒng)都基于更輕量的線程來調度,現在我們提到的“任務切換”都是指“線程切換”。
Process | 進程進程是操作系統(tǒng)對一個正在運行的程序的一種抽象,在一個系統(tǒng)上可以同時運行多個進程,而每個進程都好像在獨占地使用硬件。所謂的并發(fā)運行,則是說一個進程的指令和另一個進程的指令是交錯執(zhí)行的。無論是在單核還是多核系統(tǒng)中,可以通過處理器在進程間切換,來實現單個 CPU 看上去像是在并發(fā)地執(zhí)行多個進程。操作系統(tǒng)實現這種交錯執(zhí)行的機制稱為上下文切換。
操作系統(tǒng)保持跟蹤進程運行所需的所有狀態(tài)信息。這種狀態(tài),也就是上下文,它包括許多信息,例如 PC 和寄存器文件的當前值,以及主存的內容。在任何一個時刻,單處理器系統(tǒng)都只能執(zhí)行一個進程的代碼。當操作系統(tǒng)決定要把控制權從當前進程轉移到某個新進程時,就會進行上下文切換,即保存當前進程的上下文、恢復新進程的上下文,然后將控制權傳遞到新進程。新進程就會從上次停止的地方開始。
在虛擬存儲管理 https://url.wx-coder.cn/PeNqS 一節(jié)中,我們介紹過它為每個進程提供了一個假象,即每個進程都在獨占地使用主存。每個進程看到的是一致的存儲器,稱為虛擬地址空間。其虛擬地址空間最上面的區(qū)域是為操作系統(tǒng)中的代碼和數據保留的,這對所有進程來說都是一樣的;地址空間的底部區(qū)域存放用戶進程定義的代碼和數據。
程序代碼和數據,對于所有的進程來說,代碼是從同一固定地址開始,直接按照可執(zhí)行目標文件的內容初始化。
堆,代碼和數據區(qū)后緊隨著的是運行時堆。代碼和數據區(qū)是在進程一開始運行時就被規(guī)定了大小,與此不同,當調用如 malloc 和 free 這樣的 C 標準庫函數時,堆可以在運行時動態(tài)地擴展和收縮。
共享庫:大約在地址空間的中間部分是一塊用來存放像 C 標準庫和數學庫這樣共享庫的代碼和數據的區(qū)域。
棧,位于用戶虛擬地址空間頂部的是用戶棧,編譯器用它來實現函數調用。和堆一樣,用戶棧在程序執(zhí)行期間可以動態(tài)地擴展和收縮。
內核虛擬存儲器:內核總是駐留在內存中,是操作系統(tǒng)的一部分。地址空間頂部的區(qū)域是為內核保留的,不允許應用程序讀寫這個區(qū)域的內容或者直接調用內核代碼定義的函數。
Thread | 線程在現代系統(tǒng)中,一個進程實際上可以由多個稱為線程的執(zhí)行單元組成,每個線程都運行在進程的上下文中,并共享同樣的代碼和全局數據。進程的個體間是完全獨立的,而線程間是彼此依存的。多進程環(huán)境中,任何一個進程的終止,不會影響到其他進程。而多線程環(huán)境中,父線程終止,全部子線程被迫終止(沒有了資源)。而任何一個子線程終止一般不會影響其他線程,除非子線程執(zhí)行了 exit() 系統(tǒng)調用。任何一個子線程執(zhí)行 exit(),全部線程同時滅亡。多線程程序中至少有一個主線程,而這個主線程其實就是有 main 函數的進程。它是整個程序的進程,所有線程都是它的子線程。我們通常把具有多線程的主進程稱之為主線程。
線程共享的環(huán)境包括:進程代碼段、進程的公有數據、進程打開的文件描述符、信號的處理器、進程的當前目錄、進程用戶 ID 與進程組 ID 等,利用這些共享的數據,線程很容易的實現相互之間的通訊。進程擁有這許多共性的同時,還擁有自己的個性,并以此實現并發(fā)性:
線程 ID:每個線程都有自己的線程 ID,這個 ID 在本進程中是唯一的。進程用此來標識線程。
寄存器組的值:由于線程間是并發(fā)運行的,每個線程有自己不同的運行線索,當從一個線程切換到另一個線程上時,必須將原有的線程的寄存器集合的狀態(tài)保存,以便 將來該線程在被重新切換到時能得以恢復。
線程的堆棧:堆棧是保證線程獨立運行所必須的。線程函數可以調用函數,而被調用函數中又是可以層層嵌套的,所以線程必須擁有自己的函數堆棧, 使得函數調用可以正常執(zhí)行,不受其他線程的影響。
錯誤返回碼:由于同一個進程中有很多個線程在同時運行,可能某個線程進行系統(tǒng)調用后設置了 errno 值,而在該 線程還沒有處理這個錯誤,另外一個線程就在此時 被調度器投入運行,這樣錯誤值就有可能被修改。 所以,不同的線程應該擁有自己的錯誤返回碼變量。
線程的信號屏蔽碼:由于每個線程所感興趣的信號不同,所以線程的信號屏蔽碼應該由線程自己管理。但所有的線程都共享同樣的信號處理器。
線程的優(yōu)先級:由于線程需要像進程那樣能夠被調度,那么就必須要有可供調度使用的參數,這個參數就是線程的優(yōu)先級。
Linux 中的線程在 Linux 2.4 版以前,線程的實現和管理方式就是完全按照進程方式實現的;在 Linux 2.6 之前,內核并不支持線程的概念,僅通過輕量級進程(lightweight process)模擬線程,一個用戶線程對應一個內核線程(內核輕量級進程),這種模型最大的特點是線程調度由內核完成了,而其他線程操作(同步、取消)等都是核外的線程庫(LinuxThread)函數完成的。為了完全兼容 Posix 標準,Linux 2.6 首先對內核進行了改進,引入了線程組的概念(仍然用輕量級進程表示線程),有了這個概念就可以將一組線程組織稱為一個進程,不過內核并沒有準備特別的調度算法或是定義特別的數據結構來表征線程;相反,線程僅僅被視為一個與其他進程(概念上應該是線程)共享某些資源的進程(概念上應該是線程)。在實現上主要的改變就是在 task_struct 中加入 tgid 字段,這個字段就是用于表示線程組 id 的字段。在用戶線程庫方面,也使用 NPTL 代替 LinuxThread。不同調度模型上仍然采用 1 對 1 模型。
進程的實現是調用 fork 系統(tǒng)調用:pid_t fork(void);,線程的實現是調用 clone 系統(tǒng)調用:int clone(int (*fn)(void *), void *child_stack, int flags, void *arg, ...)。與標準 fork() 相比,線程帶來的開銷非常小,內核無需多帶帶復制進程的內存空間或文件描寫敘述符等等。這就節(jié)省了大量的 CPU 時間,使得線程創(chuàng)建比新進程創(chuàng)建快上十到一百倍,能夠大量使用線程而無需太過于操心帶來的 CPU 或內存不足。無論是 fork、vfork、kthread_create 最后都是要調用 do_fork,而 do_fork 就是根據不同的函數參數,對一個進程所需的資源進行分配。
線程池線程池的大小依賴于所執(zhí)行任務的特性以及程序運行的環(huán)境,線程池的大小應該應采取可配置的方式(寫入配置文件)或者根據可用的 CPU 數量 Runtime.availableProcessors() 來進行設置,其中 Ncpu 表示可用 CPU 數量,Nthreads 表示線程池工作線程數量,Ucpu 表示 CPU 的利用率 0≤ Ucpu ≤1;W 表示資源等待時間,C 表示任務計算時間;Rtotal 表示有限資源的總量,Rper 表示每個任務需要的資源數量。
對于對于純 CPU 計算的任務-即不依賴阻塞資源(外部接口調用)以及有限資源(線程池)的 CPU 密集型(compute-intensive)任務線程池的大小可以設置為:Nthreads = Ncpu+1。
如果執(zhí)行的任務除了 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)等等。操作系統(tǒng)內核對協程一無所知,協程的調度完全有應用程序來控制,操作系統(tǒng)不管這部分的調度;一個線程可以包含一個或多個協程,協程擁有自己的寄存器上下文和棧,協程調度切換時,將寄存器上細紋和棧保存起來,在切換回來時恢復先前保運的寄存上下文和棧。
比如 Golang 里的 go 關鍵字其實就是負責開啟一個 Fiber,讓 func 邏輯跑在上面。而這一切都是發(fā)生的用戶態(tài)上,沒有發(fā)生在內核態(tài)上,也就是說沒有 ContextSwitch 上的開銷。協程的實現庫中筆者較為常用的譬如 Go Routine、node-fibers、Java-Quasar 等。
Go 的棧是動態(tài)分配大小的,隨著存儲數據的數量而增長和收縮。每個新建的 Goroutine 只有大約 4KB 的棧。每個棧只有 4KB,那么在一個 1GB 的 RAM 上,我們就可以有 256 萬個 Goroutine 了,相對于 Java 中每個線程的 1MB,這是巨大的提升。Golang 實現了自己的調度器,允許眾多的 Goroutines 運行在相同的 OS 線程上。就算 Go 會運行與內核相同的上下文切換,但是它能夠避免切換至 ring-0 以運行內核,然后再切換回來,這樣就會節(jié)省大量的時間。但是,這只是紙面上的分析。為了支持上百萬的 Goroutines,Go 需要完成更復雜的事情。
要支持真正的大并發(fā)需要另外一項優(yōu)化:當你知道線程能夠做有用的工作時,才去調度它。如果你運行大量線程的話,其實只有少量的線程會執(zhí)行有用的工作。Go 通過集成通道(channel)和調度器(scheduler)來實現這一點。如果某個 Goroutine 在一個空的通道上等待,那么調度器會看到這一點并且不會運行該 Goroutine。Go 更近一步,將大多數空閑的線程都放到它的操作系統(tǒng)線程上。通過這種方式,活躍的 Goroutine(預期數量會少得多)會在同一個線程上調度執(zhí)行,而數以百萬計的大多數休眠的 Goroutine 會多帶帶處理。這樣有助于降低延遲。
除非 Java 增加語言特性,允許調度器進行觀察,否則的話,是不可能支持智能調度的。但是,你可以在“用戶空間”中構建運行時調度器,它能夠感知線程何時能夠執(zhí)行工作。這構成了像 Akka 這種類型的框架的基礎,它能夠支持上百萬的 Actor。
并發(fā)控制涉及多線程程序涉及的時候經常會出現一些令人難以思議的事情,用堆和棧分配一個變量可能在以后的執(zhí)行中產生意想不到的結果,而這個結果的表現就是內存的非法被訪問,導致內存的內容被更改。在一個進程的線程共享堆區(qū),而進程中的線程各自維持自己堆棧。 在 Windows 等平臺上,不同線程缺省使用同一個堆,所以用 C 的 malloc (或者 windows 的 GlobalAlloc)分配內存的時候是使用了同步保護的。如果沒有同步保護,在兩個線程同時執(zhí)行內存操作的時候會產生競爭條件,可能導致堆內內存管理混亂。比如兩個線程分配了統(tǒng)一塊內存地址,空閑鏈表指針錯誤等。
最常見的進程/線程的同步方法有互斥鎖(或稱互斥量 Mutex),讀寫鎖(rdlock),條件變量(cond),信號量(Semophore)等;在 Windows 系統(tǒng)中,臨界區(qū)(Critical Section)和事件對象(Event)也是常用的同步方法。總結而言,同步問題基本的就是解決原子性與可見性/一致性這兩個問題,其基本手段就是基于鎖,因此又可以分為三個方面:指令串行化/臨界資源管理/鎖、數據一致性/數據可見性、事務/原子操作。在并發(fā)控制中我們會考慮線程協作、互斥與鎖、并發(fā)容器等方面。
線程通信并發(fā)控制中主要考慮線程之間的通信(線程之間以何種機制來交換信息)與同步(讀寫等待,競態(tài)條件等)模型,在命令式編程中,線程之間的通信機制有兩種:共享內存和消息傳遞。Java 就是典型的共享內存模式的通信機制;而 Go 則是提倡以消息傳遞方式實現內存共享,而非通過共享來實現通信。
在共享內存的并發(fā)模型里,線程之間共享程序的公共狀態(tài),線程之間通過寫-讀內存中的公共狀態(tài)來隱式進行通信。在消息傳遞的并發(fā)模型里,線程之間沒有公共狀態(tài),線程之間必須通過明確的發(fā)送消息來顯式進行通信。同步是指程序用于控制不同線程之間操作發(fā)生相對順序的機制。在共享內存并發(fā)模型里,同步是顯式進行的。程序員必須顯式指定某個方法或某段代碼需要在線程之間互斥執(zhí)行。在消息傳遞的并發(fā)模型里,由于消息的發(fā)送必須在消息的接收之前,因此同步是隱式進行的。
常見的線程通信方式有以下幾種:
管道(Pipe):管道是一種半雙工的通信方式,數據只能單向流動,而且只能在具有親緣關系的進程間使用,其中進程的親緣關系通常是指父子進程關系。
消息隊列(Message Queue):消息隊列是由消息的鏈表,存放在內核中并由消息隊列標識符標識。消息隊列克服了信號傳遞信息少、管道只能承載無格式字節(jié)流以及緩沖區(qū)大小受限等缺點。
信號量(Semophore):信號量是一個計數器,可以用來控制多個進程對共享資源的訪問。它常作為一種鎖機制,防止某進程正在訪問共享資源時,其他進程也訪問該資源。因此,主要作為進程間以及同一進程內不同線程之間的同步手段。
共享內存(Shared Memory):共享內存就是映射一段能被其他進程所訪問的內存,這段共享內存由一個進程創(chuàng)建,但多個進程都可以訪問。共享內存是最快的 IPC 方式,它是針對其他進程間通信方式運行效率低而專門設計的。它往往與其他通信機制,如信號量配合使用,來實現進程間的同步和通信。
套接字(Socket):套接字也是一種進程間通信機制,與其他通信機制不同的是,它可用于不同主機間的進程通信。
鎖與互斥互斥是指某一資源同時只允許一個訪問者對其進行訪問,具有唯一性和排它性;但互斥無法限制訪問者對資源的訪問順序,即訪問是無序的。同步:是指在互斥的基礎上(大多數情況),通過其它機制實現訪問者對資源的有序訪問。在大多數情況下,同步已經實現了互斥,特別是所有寫入資源的情況必定是互斥的;少數情況是指可以允許多個訪問者同時訪問資源。
臨界資源所謂的臨界資源,即一次只允許一個進程訪問的資源,多個進程只能互斥訪問的資源。臨界資源的訪問需要同步操作,比如信號量就是一種方便有效的進程同步機制。但信號量的方式要求每個訪問臨界資源的進程都具有 wait 和 signal 操作。這樣使大量的同步操作分散在各個進程中,不僅給系統(tǒng)管理帶來了麻煩,而且會因同步操作的使用不當導致死鎖。管程就是為了解決這樣的問題而產生的。
操作系統(tǒng)中管理的各種軟件和硬件資源,均可用數據結構抽象地描述其資源特性,即用少量信息和對該資源所執(zhí)行的操作來表征該資源,而忽略它們的內部結構和實現細節(jié)。利用共享數據結構抽象地表示系統(tǒng)中的共享資源。而把對該共享數據結構實施的操作定義為一組過程,如資源的請求和釋放過程 request 和 release。進程對共享資源的申請、釋放和其他操作,都是通過這組過程對共享數據結構的操作來實現的,這組過程還可以根據資源的情況接受或阻塞進程的訪問,確保每次僅有一個進程使用該共享資源,這樣就可以統(tǒng)一管理對共享資源的所有訪問,實現臨界資源互斥訪問。
管程就是代表共享資源的數據結構以及由對該共享數據結構實施操作的一組過程所組成的資源管理程序共同構成的一個操作系統(tǒng)的資源管理模塊。管程被請求和釋放臨界資源的進程所調用。管程定義了一個數據結構和能為并發(fā)進程所執(zhí)行(在該數據結構上)的一組操作,這組操作能同步進程和改變管程中的數據。
悲觀鎖(Pessimistic Locking)悲觀并發(fā)控制,又名悲觀鎖(Pessimistic Concurrency Control,PCC)是一種并發(fā)控制的方法。它可以阻止一個事務以影響其他用戶的方式來修改數據。如果一個事務執(zhí)行的操作都某行數據應用了鎖,那只有當這個事務把鎖釋放,其他事務才能夠執(zhí)行與該鎖沖突的操作。悲觀并發(fā)控制主要用于數據爭用激烈的環(huán)境,以及發(fā)生并發(fā)沖突時使用鎖保護數據的成本要低于回滾事務的成本的環(huán)境中。
在編程語言中,悲觀鎖可能存在以下缺陷:
在多線程競爭下,加鎖、釋放鎖會導致比較多的上下文切換和調度延時,引起性能問題。
一個線程持有鎖會導致其它所有需要此鎖的線程掛起。
如果一個優(yōu)先級高的線程等待一個優(yōu)先級低的線程釋放鎖會導致優(yōu)先級倒置,引起性能風險。
數據庫中悲觀鎖主要由以下問題:悲觀鎖大多數情況下依靠數據庫的鎖機制實現,以保證操作最大程度的獨占性。如果加鎖的時間過長,其他用戶長時間無法訪問,影響了程序的并發(fā)訪問性,同時這樣對數據庫性能開銷影響也很大,特別是對長事務而言,這樣的開銷往往無法承受,特別是對長事務而言。如一個金融系統(tǒng),當某個操作員讀取用戶的數據,并在讀出的用戶數據的基礎上進行修改時(如更改用戶帳戶余額),如果采用悲觀鎖機制,也就意味著整個操作過程中(從操作員讀出數據、開始修改直至提交修改結果的全過程,甚至還包括操作員中途去煮咖啡的時間),數據庫記錄始終處于加鎖狀態(tài),可以想見,如果面對幾百上千個并發(fā),這樣的情況將導致怎樣的后果。
互斥鎖/排他鎖互斥鎖即對互斥量進行分加鎖,和自旋鎖類似,唯一不同的是競爭不到鎖的線程會回去睡會覺,等到鎖可用再來競爭,第一個切入的線程加鎖后,其他競爭失敗者繼續(xù)回去睡覺直到再次接到通知、競爭。
互斥鎖算是目前并發(fā)系統(tǒng)中最常用的一種鎖,POSIX、C++11、Java 等均支持。處理 POSIX 的加鎖比較普通外,C++ 和 Java 的加鎖方式很有意思。C++ 中可以使用一種 AutoLock(常見于 chromium 等開源項目中)工作方式類似 auto_ptr 智 能指針,在 C++11 中官方將其標準化為 std::lock_guard 和 std::unique_lock。Java 中使用 synchronized 緊跟同步代碼塊(也可修飾方法)的方式同步代碼,非常靈活。這兩種實現都巧妙的利用各自語言特性實現了非常優(yōu)雅的加鎖方式。當然除此之外他們也支持傳統(tǒng)的類 似于 POSIX 的加鎖模式。
可重入鎖也叫做鎖遞歸,就是獲取一個已經獲取的鎖。不支持線程獲取它已經獲取且尚未解鎖的方式叫做不可遞歸或不支持重入。帶重入特性的鎖在重入時會判斷是否同一個線程,如果是,則使持鎖計數器+1(0 代表沒有被線程獲取,又或者是鎖被釋放)。C++11 中同時支持兩種鎖,遞歸鎖 std::recursive_mutex 和非遞歸 std::mutex。Java 的兩種互斥鎖實現以及讀寫鎖實現均支持重入。POSIX 使用一種叫做重入函數的方法保證函數的線程安全,鎖粒度是調用而非線程。
讀寫鎖支持兩種模式的鎖,當采用寫模式上鎖時與互斥鎖相同,是獨占模式。但讀模式上鎖可以被多個讀線程讀取。即寫時使用互斥鎖,讀時采用共享鎖,故又叫共享-獨 占鎖。一種常見的錯誤認為數據只有在寫入時才需要鎖,事實是即使是讀操作也需要鎖保護,如果不這么做的話,讀寫鎖的讀模式便毫無意義。
樂觀鎖(Optimistic Locking)相對悲觀鎖而言,樂觀鎖(Optimistic Locking)機制采取了更加寬松的加鎖機制。相對悲觀鎖而言,樂觀鎖假設認為數據一般情況下不會造成沖突,所以在數據進行提交更新的時候,才會正式對數據的沖突與否進行檢測,如果發(fā)現沖突了,則讓返回用戶錯誤的信息,讓用戶決定如何去做。上面提到的樂觀鎖的概念中其實已經闡述了他的具體實現細節(jié):主要就是兩個步驟:沖突檢測和數據更新。其實現方式有一種比較典型的就是 Compare and Swap。
CAS 與 ABACAS 是項樂觀鎖技術,當多個線程嘗試使用 CAS 同時更新同一個變量時,只有其中一個線程能更新變量的值,而其它線程都失敗,失敗的線程并不會被掛起,而是被告知這次競爭中失敗,并可以再次嘗試。CAS 操作包含三個操作數 —— 內存位置(V)、預期原值(A)和新值(B)。如果內存位置的值與預期原值相匹配,那么處理器會自動將該位置值更新為新值。否則,處理器不做任何操作。無論哪種情況,它都會在 CAS 指令之前返回該位置的值。CAS 有效地說明了我認為位置 V 應該包含值 A;如果包含該值,則將 B 放到這個位置;否則,不要更改該位置,只告訴我這個位置現在的值即可。這其實和樂觀鎖的沖突檢查+數據更新的原理是一樣的。
樂觀鎖也不是萬能的,樂觀并發(fā)控制相信事務之間的數據競爭(Data Race)的概率是比較小的,因此盡可能直接做下去,直到提交的時候才去鎖定,所以不會產生任何鎖和死鎖。但如果直接簡單這么做,還是有可能會遇到不可預期的結果,例如兩個事務都讀取了數據庫的某一行,經過修改以后寫回數據庫,這時就遇到了問題。
樂觀鎖只能保證一個共享變量的原子操作。如上例子,自旋過程中只能保證 value 變量的原子性,這時如果多一個或幾個變量,樂觀鎖將變得力不從心,但互斥鎖能輕易解決,不管對象數量多少及對象顆粒度大小。
長時間自旋可能導致開銷大。假如 CAS 長時間不成功而一直自旋,會給 CPU 帶來很大的開銷。
ABA 問題。
CAS 的核心思想是通過比對內存值與預期值是否一樣而判斷內存值是否被改過,但這個判斷邏輯不嚴謹,假如內存值原來是 A,后來被 一條線程改為 B,最后又被改成了 A,則 CAS 認為此內存值并沒有發(fā)生改變,但實際上是有被其他線程改過的,這種情況對依賴過程值的情景的運算結果影響很大。解決的思路是引入版本號,每次變量更新都把版本號加一。部分樂觀鎖的實現是通過版本號(version)的方式來解決 ABA 問題,樂觀鎖每次在執(zhí)行數據的修改操作時,都會帶上一個版本號,一旦版本號和數據的版本號一致就可以執(zhí)行修改操作并對版本號執(zhí)行 +1 操作,否則就執(zhí)行失敗。因為每次操作的版本號都會隨之增加,所以不會出現 ABA 問題,因為版本號只會增加不會減少。
自旋鎖Linux 內核中最常見的鎖,作用是在多核處理器間同步數據。這里的自旋是忙等待的意思。如果一個線程(這里指的是內核線程)已經持有了一個自旋鎖,而另一條線程也想要獲取該鎖,它就不停地循環(huán)等待,或者叫做自旋等待直到鎖可用。可以想象這種鎖不能被某個線程長時間持有,這會導致其他線程一直自旋,消耗處理器。所以,自旋鎖使用范圍很窄,只允許短期內加鎖。
其實還有一種方式就是讓等待線程睡眠直到鎖可用,這樣就可以消除忙等待。很明顯后者優(yōu)于前者的實現,但是卻不適用于此,如果我們使用第二種方式,我們要做幾步操作:把該等待線程換出、等到鎖可用在換入,有兩次上下文切換的代價。這個代價和短時間內自旋(實現起來也簡單)相比,后者更能適應實際情況的需要。還有一點需要注意,試圖獲取一個已經持有自旋鎖的線程再去獲取這個自旋鎖或導致死鎖,但其他操作系統(tǒng)并非如此。
自旋鎖與互斥鎖有點類似,只是自旋鎖不會引起調用者睡眠,如果自旋鎖已經被別的執(zhí)行單元保持,調用者就一直循環(huán)在那里看是 否該自旋鎖的保持者已經釋放了鎖,"自旋"一詞就是因此而得名。其作用是為了解決某項資源的互斥使用。因為自旋鎖不會引起調用者睡眠,所以自旋鎖的效率遠 高于互斥鎖。雖然它的效率比互斥鎖高,但是它也有些不足之處:
自旋鎖一直占用 CPU,他在未獲得鎖的情況下,一直運行--自旋,所以占用著 CPU,如果不能在很短的時 間內獲得鎖,這無疑會使 CPU 效率降低。
在用自旋鎖時有可能造成死鎖,當遞歸調用時有可能造成死鎖,調用有些其他函數也可能造成死鎖,如 copy_to_user()、copy_from_user()、kmalloc()等。
自旋鎖比較適用于鎖使用者保持鎖時間比較短的情況。正是由于自旋鎖使用者一般保持鎖時間非常短,因此選擇自旋而不是睡眠是非常必要的,自旋鎖的效率遠高于互斥鎖。信號量和讀寫信號量適合于保持時間較長的情況,它們會導致調用者睡眠,因此只能在進程上下文使用,而自旋鎖適合于保持時間非常短的情況,它可以在任何上下文使用。如果被保護的共享資源只在進程上下文訪問,使用信號量保護該共享資源非常合適,如果對共享資源的訪問時間非常短,自旋鎖也可以。但是如果被保護的共享資源需要在中斷上下文訪問(包括底半部即中斷處理句柄和頂半部即軟中斷),就必須使用自旋鎖。自旋鎖保持期間是搶占失效的,而信號量和讀寫信號量保持期間是可以被搶占的。自旋鎖只有在內核可搶占或 SMP(多處理器)的情況下才真正需要,在單 CPU 且不可搶占的內核下,自旋鎖的所有操作都是空操作。另外格外注意一點:自旋鎖不能遞歸使用。
MVCC為了實現可串行化,同時避免鎖機制存在的各種問題,我們可以采用基于多版本并發(fā)控制(Multiversion concurrency control,MVCC)思想的無鎖事務機制。人們一般把基于鎖的并發(fā)控制機制稱成為悲觀機制,而把 MVCC 機制稱為樂觀機制。這是因為鎖機制是一種預防性的,讀會阻塞寫,寫也會阻塞讀,當鎖定粒度較大,時間較長時并發(fā)性能就不會太好;而 MVCC 是一種后驗性的,讀不阻塞寫,寫也不阻塞讀,等到提交的時候才檢驗是否有沖突,由于沒有鎖,所以讀寫不會相互阻塞,從而大大提升了并發(fā)性能。我們可以借用源代碼版本控制來理解 MVCC,每個人都可以自由地閱讀和修改本地的代碼,相互之間不會阻塞,只在提交的時候版本控制器會檢查沖突,并提示 merge。目前,Oracle、PostgreSQL 和 MySQL 都已支持基于 MVCC 的并發(fā)機制,但具體實現各有不同。
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),但此時比對數據庫記錄版本時發(fā)現,操作員 B 提交的數據版本號為 2 ,數據庫記錄當前版本也為 2 ,不滿足提交版本必須大于記錄當前版本才能執(zhí)行更新的樂觀鎖策略,因此,操作員 B 的提交被駁回。這樣,就避免了操作員 B 用基于 version=1 的舊數據修改的結果覆蓋操作員 A 的操作結果的可能。
從上面的例子可以看出,樂觀鎖機制避免了長事務中的數據庫加鎖開銷(操作員 A 和操作員 B 操作過程中,都沒有對數據庫數據加鎖),大大提升了大并發(fā)量下的系統(tǒng)整體性能表現。需要注意的是,樂觀鎖機制往往基于系統(tǒng)中的數據存儲邏輯,因此也具備一定的局限性,如在上例中,由于樂觀鎖機制是在我們的系統(tǒng)中實現,來自外部系統(tǒng)的用戶余額更新操作不受我們系統(tǒng)的控制,因此可能會造成臟數據被更新到數據庫中。
并發(fā) IOIO 的概念,從字義來理解就是輸入輸出。操作系統(tǒng)從上層到底層,各個層次之間均存在 IO。比如,CPU 有 IO,內存有 IO, VMM 有 IO, 底層磁盤上也有 IO,這是廣義上的 IO。通常來講,一個上層的 IO 可能會產生針對磁盤的多個 IO,也就是說,上層的 IO 是稀疏的,下層的 IO 是密集的。磁盤的 IO,顧名思義就是磁盤的輸入輸出。輸入指的是對磁盤寫入數據,輸出指的是從磁盤讀出數據。
所謂的并發(fā) IO,即在一個時間片內,如果一個進程進行一個 IO 操作,例如讀個文件,這個時候該進程可以把自己標記為“休眠狀態(tài)”并出讓 CPU 的使用權,待文件讀進內存,操作系統(tǒng)會把這個休眠的進程喚醒,喚醒后的進程就有機會重新獲得 CPU 的使用權了。這里的進程在等待 IO 時之所以會釋放 CPU 使用權,是為了讓 CPU 在這段等待時間里可以做別的事情,這樣一來 CPU 的使用率就上來了;此外,如果這時有另外一個進程也讀文件,讀文件的操作就會排隊,磁盤驅動在完成一個進程的讀操作后,發(fā)現有排隊的任務,就會立即啟動下一個讀操作,這樣 IO 的使用率也上來了。
IO 類型Unix 中內置了 5 種 IO 模型,阻塞式 IO, 非阻塞式 IO,IO 復用模型,信號驅動式 IO 和異步 IO。而從應用的角度來看,IO 的類型可以分為:
大/小塊 IO:這個數值指的是控制器指令中給出的連續(xù)讀出扇區(qū)數目的多少。如果數目較多,如 64,128 等,我們可以認為是大塊 IO;反之,如果很小,比如 4,8,我們就會認為是小塊 IO,實際上,在大塊和小塊 IO 之間,沒有明確的界限。
連續(xù)/隨機 IO:連續(xù) IO 指的是本次 IO 給出的初始扇區(qū)地址和上一次 IO 的結束扇區(qū)地址是完全連續(xù)或者相隔不多的。反之,如果相差很大,則算作一次隨機 IO。連續(xù) IO 比隨機 IO 效率高的原因是:在做連續(xù) IO 的時候,磁頭幾乎不用換道,或者換道的時間很短;而對于隨機 IO,如果這個 IO 很多的話,會導致磁頭不停地換道,造成效率的極大降低。
順序/并發(fā) IO:從概念上講,并發(fā) IO 就是指向一塊磁盤發(fā)出一條 IO 指令后,不必等待它回應,接著向另外一塊磁盤發(fā) IO 指令。對于具有條帶性的 RAID(LUN),對其進行的 IO 操作是并發(fā)的,例如:raid 0+1(1+0),raid5 等。反之則為順序 IO。
在傳統(tǒng)的網絡服務器的構建中,IO 模式會按照 Blocking/Non-Blocking、Synchronous/Asynchronous 這兩個標準進行分類,其中 Blocking 與 Synchronous 大同小異,而 NIO 與 Async 的區(qū)別在于 NIO 強調的是 輪詢(Polling),而 Async 強調的是通知(Notification)。譬如在一個典型的單進程單線程 Socket 接口中,阻塞型的接口必須在上一個 Socket 連接關閉之后才能接入下一個 Socket 連接。而對于 NIO 的 Socket 而言,服務端應用會從內核獲取到一個特殊的 "Would Block" 錯誤信息,但是并不會阻塞到等待發(fā)起請求的 Socket 客戶端停止。
一般來說,在 Linux 系統(tǒng)中可以通過調用獨立的 select 或者 epoll 方法來遍歷所有讀取好的數據,并且進行寫操作。而對于異步 Socket 而言(譬如 Windows 中的 Sockets 或者 .Net 中實現的 Sockets 模型),服務端應用會告訴 IO Framework 去讀取某個 Socket 數據,在數據讀取完畢之后 IO Framework 會自動地調用你的回調(也就是通知應用程序本身數據已經準備好了)。以 IO 多路復用中的 Reactor 與 Proactor 模型為例,非阻塞的模型是需要應用程序本身處理 IO 的,而異步模型則是由 Kernel 或者 Framework 將數據準備好讀入緩沖區(qū)中,應用程序直接從緩沖區(qū)讀取數據。
同步阻塞:在此種方式下,用戶進程在發(fā)起一個 IO 操作以后,必須等待 IO 操作的完成,只有當真正完成了 IO 操作以后,用戶進程才能運行。
同步非阻塞:在此種方式下,用戶進程發(fā)起一個 IO 操作以后邊可返回做其它事情,但是用戶進程需要時不時的詢問 IO 操作是否就緒,這就要求用戶進程不停的去詢問,從而引入不必要的 CPU 資源浪費。
異步非阻塞:在此種模式下,用戶進程只需要發(fā)起一個 IO 操作然后立即返回,等 IO 操作真正的完成以后,應用程序會得到 IO 操作完成的通知,此時用戶進程只需要對數據進行處理就好了,不需要進行實際的 IO 讀寫操作,因為真正的 IO 讀取或者寫入操作已經由內核完成了。
而在并發(fā) IO 的問題中,較常見的就是所謂的 C10K 問題,即有 10000 個客戶端需要連上一個服務器并保持 TCP 連接,客戶端會不定時的發(fā)送請求給服務器,服務器收到請求后需及時處理并返回結果。
IO 多路復用IO 多路復用就通過一種機制,可以監(jiān)視多個描述符,一旦某個描述符就緒(一般是讀就緒或者寫就緒),能夠通知程序進行相應的讀寫操作。select,poll,epoll 都是 IO 多路復用的機制。值得一提的是,epoll 僅對于 Pipe 或者 Socket 這樣的讀寫阻塞型 IO 起作用,正常的文件描述符則是會立刻返回文件的內容,因此 epoll 等函數對普通的文件讀寫并無作用。
首先來看下可讀事件與可寫事件:當如下任一情況發(fā)生時,會產生套接字的可讀事件:
該套接字的接收緩沖區(qū)中的數據字節(jié)數大于等于套接字接收緩沖區(qū)低水位標記的大小;
該套接字的讀半部關閉(也就是收到了 FIN),對這樣的套接字的讀操作將返回 0(也就是返回 EOF);
該套接字是一個監(jiān)聽套接字且已完成的連接數不為 0;
該套接字有錯誤待處理,對這樣的套接字的讀操作將返回-1。
當如下任一情況發(fā)生時,會產生套接字的可寫事件:
該套接字的發(fā)送緩沖區(qū)中的可用空間字節(jié)數大于等于套接字發(fā)送緩沖區(qū)低水位標記的大小;
該套接字的寫半部關閉,繼續(xù)寫會產生 SIGPIPE 信號;
非阻塞模式下,connect 返回之后,該套接字連接成功或失敗;
該套接字有錯誤待處理,對這樣的套接字的寫操作將返回-1。
select,poll,epoll 本質上都是同步 IO,因為他們都需要在讀寫事件就緒后自己負責進行讀寫,也就是說這個讀寫過程是阻塞的,而異步 IO 則無需自己負責進行讀寫,異步 IO 的實現會負責把數據從內核拷貝到用戶空間。select 本身是輪詢式、無狀態(tài)的,每次調用都需要把 fd 集合從用戶態(tài)拷貝到內核態(tài),這個開銷在 fd 很多時會很大。epoll 則是觸發(fā)式處理連接,維護的描述符數目不受到限制,而且性能不會隨著描述符數目的增加而下降。
方法 | 數量限制 | 連接處理 | 內存操作 |
---|---|---|---|
select | 描述符個數由內核中的 FD_SETSIZE 限制,僅為 1024;重新編譯內核改變 FD_SETSIZE 的值,但是無法優(yōu)化性能 | 每次調用 select 都會線性掃描所有描述符的狀態(tài),在 select 結束后,用戶也要線性掃描 fd_set 數組才知道哪些描述符準備就緒(O(n)) | 每次調用 select 都要在用戶空間和內核空間里進行內存復制 fd 描述符等信息 |
poll | 使用 pollfd 結構來存儲 fd,突破了 select 中描述符數目的限制 | 類似于 select 掃描方式 | 需要將 pollfd 數組拷貝到內核空間,之后依次掃描 fd 的狀態(tài),整體復雜度依然是 O(n)的,在并發(fā)量大的情況下服務器性能會快速下降 |
epoll | 該模式下的 Socket 對應的 fd 列表由一個數組來保存,大小不限制(默認 4k) | 基于內核提供的反射模式,有活躍 Socket 時,內核訪問該 Socket 的 callback,不需要遍歷輪詢 | epoll 在傳遞內核與用戶空間的消息時使用了內存共享,而不是內存拷貝,這也使得 epoll 的效率比 poll 和 select 更高 |
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規(guī)行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/74240.html
摘要:基類導出類導出類繼承了基類的特點,基類和導出類具有相同的基礎接口,形成兩者差異的做法在導出類中添加新方法在導出類型中添加新的接口元素,擴展了接口。覆蓋在導出類用創(chuàng)建方法的新定義,覆蓋基類中的方法定義純粹替代,只覆蓋。 一、抽象過程 建模基于計算機的結構 解空間的解 匯編語言:對底層機器的輕微抽象 命令式語言:匯編語言的抽象 建模基于待解決問題 問題空間的元素 面向對象 二、每個...
摘要:而面向對象則是向程序員提供表示問題空間中元素的工具,我們將問題空間中的元素及其在解空間中的表示稱為對象。為什么要把對象看作是服務提供者呢這是將問題分解為對象集合的一種合理方式。職能太多,可能會導致對象的內聚性降低。在試圖將子類對象當作其基類 計算機是頭腦延伸的工具,是一種不同類型的表達媒體。本文以背景性的和補充性的材料,介紹包括開發(fā)方法概述在內的面向對象程序設計(Object-orie...
摘要:大家好,我是冰河有句話叫做投資啥都不如投資自己的回報率高。馬上就十一國慶假期了,給小伙伴們分享下,從小白程序員到大廠高級技術專家我看過哪些技術類書籍。 大家好,我是...
摘要:函數式編程導論從屬于筆者的前端入門與工程實踐。函數式編程即是在軟件開發(fā)的工程中避免使用共享狀態(tài)可變狀態(tài)以及副作用。 JavaScript 函數式編程導論從屬于筆者的Web 前端入門與工程實踐。本文很多地方是講解函數式編程的優(yōu)勢,就筆者個人而言是認可函數式編程具有一定的好處,但是不推崇徹底的函數式編程化,特別是對于復雜應用邏輯的開發(fā)。筆者在應用的狀態(tài)管理工具中就更傾向于使用MobX而不是...
閱讀 2565·2023-04-25 20:05
閱讀 2891·2023-04-25 17:56
閱讀 2204·2021-10-14 09:49
閱讀 2687·2019-08-29 15:10
閱讀 2926·2019-08-29 12:25
閱讀 421·2019-08-28 18:23
閱讀 762·2019-08-26 13:26
閱讀 1375·2019-08-23 18:21