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