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