很久很久以前是沒有并發(fā)這個概念的,因為那個時候操作系統(tǒng)并不支持多任務(wù)。現(xiàn)在的操作系統(tǒng)今非昔比,支持搶占式任務(wù)、多線程、分頁、TCP/IP等現(xiàn)代操作系統(tǒng)特性。能滿足用戶各種各樣的需求同時響應(yīng)用戶的不同操作,靠的是多任務(wù)系統(tǒng)的支持。
簡單來說,多任務(wù)就是多個任務(wù)一起輪流的執(zhí)行在操作系統(tǒng)上。這種執(zhí)行方式在單處理器上給人同時執(zhí)行的幻象,在多處理器上卻是真正的并行。但問題是處理器的數(shù)目總是比進程數(shù)目少的多,也就是說總是有進程得不到執(zhí)行。這個問題需要進程調(diào)度程序解決的。在此之前先解釋下進程和線程。
在Unix時代一個進程(執(zhí)行中的程序)只有一個線程,現(xiàn)代操作系統(tǒng)允許一個進程有多條線程。這里需要具體解釋下線程。在Linux系統(tǒng)上可以將線程當(dāng)做進程看待(Linux確實這么實現(xiàn)的,雖然有些許區(qū)別)。雖然這與Windows和Solaris等實現(xiàn)完全不同的是它們都有一套專門應(yīng)對線程的不同于進程的調(diào)度程序。不過這樣會很好理解許多問題,如:每個線程有獨立棧空間、寄存器、程序計數(shù)器(存放下一條執(zhí)行指令)。
操作系統(tǒng)調(diào)度程序調(diào)度的是線程(在本文中提到線程的地方都指的是操作系統(tǒng)級的線程),并非進程。也就是說線程才是調(diào)度的基本單位。那么線程是如何被調(diào)度的或者說調(diào)度程序是如何工作的?
操作系統(tǒng)的調(diào)度程序決定哪條線程正在使用處理器。支持搶占式多任務(wù)的操作系統(tǒng)采用一種時間分片的機制,也就是給每個線程一個時間段,在此期間可以使用處理器,直到用完時間片。當(dāng)然這種機制欠缺靈活性,所以大多數(shù)操作系統(tǒng)使用兩個關(guān)鍵因素,優(yōu)先級和時間片決定調(diào)度。為了更好的的理解調(diào)度程序的具體工作屏蔽一些細節(jié),我們籠統(tǒng)的稱操作系統(tǒng)調(diào)度方式為根據(jù)時間片調(diào)度(雖然Linux系統(tǒng)并不采用此方法,而是采用一種特殊方式:Nice和處理器占比)。
現(xiàn)代操作系統(tǒng)可以同時運行著多個任務(wù),表面并不互相干涉(但存在潛在的交互)。我們將此稱之為并發(fā)(Concurrency)。
那么為什么需要并發(fā)呢?
如果只有一個線程的情形。首先,在單處理器系統(tǒng)中,一個處于運行狀態(tài)的IO消耗型程序,例如一個網(wǎng)絡(luò)讀取阻塞或用戶輸入阻塞導(dǎo)致處理器出現(xiàn)空閑,在視處理器為昂貴資源的情形下,這是巨大的浪費。然后,在對稱處理器中(SMP),因為只有一個線程,那它只能在其中一個處理器上運行,也就是說剩余的處理器被白白浪費。如果使用多線程,不僅能充分利用多處理器,還能通過線程并發(fā)解決上述IO消耗程序中處理器空閑問題。
開始講同步之前先幫12306設(shè)計一個搶票系統(tǒng),功能簡單:用戶來訂票,12306系統(tǒng)從總票中取出一張(如果票還沒賣光),并且讓總票數(shù)減-1,然后把票交給用戶。它的實現(xiàn)代碼如下:
int total = get_total_from_tickets();if ( total < 1) { not_found("No tickets!";); return FAILURE;}/*以上為步驟一*/ticket tk = get_ticket_from_tickets();--total;/*以上為步驟二*/split_ticket2user(tk);/*步驟三*/
我們假設(shè)一次執(zhí)行單元為其中一個步驟,但是這樣的系統(tǒng)是不能使用的,因為它只能支持同時一個用戶搶票。想象一下多個用戶同時搶票的簡單情形:系統(tǒng)中只剩一張票了,用戶A先開始搶票,整個過程是這樣的:A先看還有一張票,也就是步驟一。此時,用戶B使用了搶票插件,如有神助的在A完成步驟一尚未進行步驟二的時候完成了步驟一,這個時候無論A和B誰先執(zhí)行步驟二都意味只有一個人能搶到票。
導(dǎo)致上述事例結(jié)果的根本原因在于數(shù)據(jù)沒有及時更新,即數(shù)據(jù)同步不及時。多線程下的具體情形可能是一個運行中的線程隨時可能在它正在使用臨界區(qū)(放置臨界資源的區(qū)域)的時候被搶占,而新調(diào)度的線程緊接著進入該臨界區(qū),這個時候就會發(fā)生競爭。如果是在對稱處理器多線程下,就算一個線程能完整的完成它的任務(wù)而不被搶斷,但多處理器真正并行使得多條線程可以同時修改臨界區(qū),這使得及時同步數(shù)據(jù)變得非常困難。采用同步可以確保每條線程看到的數(shù)據(jù)是一致的。尤其是一條線程在修改某一個值而其他線程也需要讀取或修改這個值的時候,使用同步就可以保證訪問的數(shù)據(jù)有效。
這里介紹兩種主要的解決同步問題的方式:一是原子操作,二是加鎖。
原子即不可分割,原子操作就是中間不能停頓,一步到位。這里舉一個用原子操作解決多線程下同步問題的簡單例子:
C代碼:++i; 的AT&T匯編如下:
movl i(%rip), %eaxaddl $1, %eaxmovl %eax, i(%rip)
順便提一句,大家經(jīng)常爭論的i++和++i的效率問題,單條語句中出現(xiàn)i++和++i的情形,匯編指令是相同的,就是如上的匯編代碼。而和其他賦值語句同行的情形中i++比++i少一條指令,C/C++主張偏向于++i,Java傾向于i++。
簡單描述下上面的匯編操作:
1)讀取當(dāng)前變量i并把它賦值給一個臨時寄存器;
2)給臨時寄存器+1;
3)把eax的新值寫回內(nèi)存。
我們可以清楚看到C代碼只需要一句,但編譯成匯編卻需要三步(這里不考慮編譯器優(yōu)化,實際上通過編譯器優(yōu)化可以將這三條匯編指令合并成一條)。和上述搶票類似,導(dǎo)致的問題顯而易見,但是解決方案也簡單。按照原子操作解決同步問題方式:依靠處理器原語支持把上述三條指令合三為一,當(dāng)做一條指令來執(zhí)行,保證在執(zhí)行過程中不會被打斷并且多線程并發(fā)也不會受到干擾。這樣同步問題迎刃而解,這也就是所謂的原子操作。但事情總不會都如此簡單,并且處理器沒有義務(wù)為任意代碼片段提供原子性操作,尤其是我們的臨界區(qū)資源十分龐大甚至大小不確定,處理器沒有必要或是很難提供原子性支持。這個時候加鎖機制就出現(xiàn)了。
鎖,和現(xiàn)實中的門把鎖有相同也有不同。我們把線程看做人,屋子是臨界區(qū),里面放的是東西是臨界資源。每次只允許一個人進入屋子,當(dāng)某個人進入屋子后會把門鎖上,當(dāng)另一個人想進入屋子的時候,只能在門口等待(這里等待的方式有兩種:忙等待,即循環(huán)查看是否屋子的人出來了。還有一種是先睡一覺,等屋子里的人出來叫醒自己)。直到屋子里的人解鎖出來,這個時候在門口等待的人才可以進去。這里有一個關(guān)鍵點需要保證:鎖必須是原子性操作實現(xiàn),決不能中途打斷,由處理器原語支持。鎖的意義在于將操作做為一個執(zhí)行單元以一種原子方式執(zhí)行而不被打斷,多線程下也不會互相干擾。但是鎖會影響性能,這是因為一個加鎖的臨界資源在被訪問前必須獲取對應(yīng)的鎖,獲取該鎖的線程將以獨占的方式訪問臨界區(qū)。如果此時有其他線程同時訪問臨界區(qū),則會因為無法獲取這個鎖而阻塞,顯然,在臨界區(qū)強行通過加鎖使線程執(zhí)行串行化是需要犧牲一定的性能的。
用原子操作和鎖來保證同步的正確性看似簡單,但背后的實現(xiàn)卻非常復(fù)雜。理解同步背后的具體實現(xiàn)相當(dāng)重要,這涉及到并發(fā)的高級話題——內(nèi)存模型。
在多處理器下,多個處理器共享主存。為了效率并不要求處理器將更新立即同步到主存上。處理器擁有自己的緩存以保存這些更新,并且定期與主存同步。這種需要定期同步的方式是為了保證緩存一致性(Cache Coherence)。在緩存一致性許允許的范圍內(nèi),多個處理器可以擁有同一個共享數(shù)據(jù)的不同狀態(tài)。內(nèi)存模型提供了一種保證:規(guī)定共享數(shù)據(jù)在不同線程間的狀態(tài)總是一致的。它的復(fù)雜性在于要協(xié)調(diào)處理器和編譯器在與多線程程序執(zhí)行時的性能與數(shù)據(jù)同步狀態(tài)之間的平衡。處理器和編譯器的工作是通過優(yōu)化指令執(zhí)行順序添加緩存來加快指令執(zhí)行速度。內(nèi)存模型采用一組屏障指令來保證存儲的一致性,當(dāng)然是在盡可能少的犧牲性能的前提下。具體的編譯器和處理器加快指令執(zhí)行的方法是代碼重排、指令重排以及緩存。內(nèi)存模型利用處理器提供的一組指令來保護數(shù)據(jù)一致性,這種方式稱為內(nèi)存屏障(Memory Barriers)。下面一一介紹。
3.1 代碼重排
代碼重排是指編譯器對用戶代碼進行優(yōu)化以提高代碼的執(zhí)行效率,優(yōu)化前提是不改變代碼的結(jié)果,即優(yōu)化前后代碼執(zhí)行結(jié)果必須相同。
先來看段C代碼:
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), %eaxaddl $1, %eaxmovl %eax, a(%rip)movl c(%rip), %eaxaddl $1, %eaxmovl %eax, b(%rip)movl a(%rip), %edxmovl b(%rip), %eaxaddl %edx, %eaxmovl %eax, c(%rip)
編譯參數(shù):-O3
movl b(%rip), %eax ;將b讀入eax寄存器leal 1(%rax), %edx ;將b+1寫入edx寄存器movl c(%rip), %eax ;將c讀入eaxmovl %edx, a(%rip) ;將edx寫入aaddl $1, %eax ;將eax+1movl %eax, b(%rip) ;將eax寫入baddl %edx, %eax ;將eax+edxmovl %eax, c(%rip) ;將eax寫入c
我在-O3的匯編下做了詳細的注釋,參照注釋和原C代碼理解這兩段匯編代碼應(yīng)該不難。當(dāng)然編譯器優(yōu)化并沒有做多少工作,這是因為并未有多少無用代碼。但是如果我們的test函數(shù)體內(nèi)只寫了100行的a++; 那匯編指令使用-O1就會優(yōu)化這100行代碼成一條:
addl $100, a(%rip)
然而,上面的代碼更有意義來說明編譯器優(yōu)化并且其中將后面可能用到的匯編指令做了清楚的注釋以說明其含意,便于下文對匯編代碼的理解。如果覺得上述代碼的指令重排難于理解或是不夠充分,接下來看這段C代碼和gcc -O3下的匯編代碼:
int a = 1, b = 2;void test1() { a = b + 1; b = 39;}
movl b(%rip), %eax ;讀取b給eaxmovl $39, b(%rip) ;向b寫入39addl $1, %eax ;eax+1movl %eax, a(%rip) ;將eax寫回a
很顯然b先被賦值,然后才是a。也就是說在該函數(shù)中,代碼的執(zhí)行順序發(fā)生了變化,但卻不影響最終結(jié)果。編譯器重排是根據(jù)指令之間是否有數(shù)據(jù)依賴關(guān)系來決定的,雖然看似C代碼間存在依賴,但是重排卻是指令級別的。順便分析下這里為什么會把b的賦值操作提前進行呢?寄存器讀入b的值時對b進行緩存,再寫入的時候直接從寄存器緩存中取出賦值避免了再次從高速緩存甚至主存中取出b再賦值。可以動手寫實驗代碼驗證,很容易發(fā)現(xiàn)確實編譯器會將相關(guān)變量的操作提取到一起執(zhí)行,這是因為處理器充分利用寄存器緩存來加速指令執(zhí)行。
3.2 指令重排
大多數(shù)主流的處理器為了效率可以調(diào)整讀寫操作的順序,但為什么這么做呢?處理器在執(zhí)行指令期間,會把指令按照自適應(yīng)處理的最優(yōu)情形進行重新排序,使指令執(zhí)行時間變得更短(絕大多數(shù)情形下,前提是不改變程序的執(zhí)行結(jié)果)。處理器的具體做法是優(yōu)化其指令流水線(Instruction pipeline)以減少指令執(zhí)行時間。
我們假設(shè)在一條簡單的流水線中,完成一個指令可能需要5層。對于一些并不互相依賴的指令,要在最佳性能下運算,當(dāng)?shù)谝粋€指令被運行時,這個流水線需要運行緊接著的4條獨立的指令。如果有指令依賴前面已經(jīng)執(zhí)行的指令,那處理器就會通過某種方式延緩該條指令執(zhí)行直到依賴的指令執(zhí)行完畢。使用多個層執(zhí)行指令,處理器可以顯著提高性能從而減少指令運行所需要的周期。處理器使用這種優(yōu)化Pipeline的方式一方面提高了指令執(zhí)行效率,但另一方面卻出現(xiàn)了另一個麻煩。單處理器下執(zhí)行指令調(diào)整順序在多線程并發(fā)的時候出現(xiàn)了困難。我們假設(shè)有兩個處理器,每一個處理器執(zhí)行一條線程,對于涉及到同一段代碼對非局部變量賦值的順序會因為的每一個處理器對各自指令順序調(diào)整而變得混亂。
看下啟動服務(wù)器的示例代碼:
全局:bool enable = false;
void start_server() { open_server(); enable = true;}
void http_server() { if(enable) handle_request();}
我們使用兩條線程在多處理器下并發(fā),一條線程A負責(zé)啟動服務(wù)器start_server(),另一條線程B負責(zé)處理http請求。由于上述所說的處理器會將互不依賴的兩條指令調(diào)換順序(這里暫且忽略編譯器優(yōu)化),我們有理由相信會出現(xiàn)這樣的情形:A線程:enable = true;已經(jīng)執(zhí)行,但open_server();還未調(diào)用。此時另一條線程B執(zhí)行http_server();發(fā)現(xiàn)服務(wù)可用,直接使用未打開的服務(wù)器來處理請求。
同樣的我們?nèi)绻诖撕雎蕴幚砥髦嘏牛皇褂镁幾g器的代碼重排也會導(dǎo)致上述問題。問題的復(fù)雜性在于編譯器和處理器同時作用下,指令的執(zhí)行順序更加神秘莫測。但更糟糕的是還有一種為了效率緩存指令執(zhí)行結(jié)果使數(shù)據(jù)不能及時更新的因素影響數(shù)據(jù)同步,這個因素就是CPU Cache。
3.3 CPU Cache(高速緩存)
A trip to main memory costs hundreds of clock cycles on commodity hardware. Processors use caching to decrease the costs of memory latency by orders of magnitude.
——Dennis Byrne
上面這句話的大體意思是說對內(nèi)存的一次訪問需要花費數(shù)百個時鐘周期,處理器使用緩存減少內(nèi)存延遲的成本。這就是引入緩存的原因。CPU內(nèi)部結(jié)構(gòu)根據(jù)物理距離依次是:處理器、寄存器、CPU Cache。我根據(jù)自己機器上的CPU Cache的信息用cacoo畫了一個簡單版本的CPU內(nèi)部結(jié)構(gòu)圖:
處理器緩存執(zhí)行指令結(jié)果于自己的cache上,等滿足一定條件如遇到請求刷新的指令,再將緩存結(jié)果一并輸出。
用一段掛起服務(wù)器的代碼就可以看出緩存對數(shù)據(jù)同步的影響:
全局:Bool enable = true;
void halt_server() { enable = false; close_server();}
void http_server() { if(enable) handle_request();}
還是雙核雙線程模擬執(zhí)行的情形:線程A執(zhí)行服務(wù)器掛起操作,但因為CPU緩存影響,服務(wù)器已經(jīng)掛起,但并未同步更新到全局enable。而另一條線程B檢測到enable可用時,就繼續(xù)提供請求處理服務(wù)導(dǎo)致出現(xiàn)一個已經(jīng)關(guān)閉的服務(wù)器還提供服務(wù)的情形。
對于處理器使用高速緩存以追求效率的解釋可以通過對C語言標(biāo)準(zhǔn)庫的IO緩沖分析來解釋。C語言的IO輸出默認(rèn)有三種緩沖方式:無緩沖、行緩沖和塊緩沖。我們以printf(默認(rèn)行緩沖)說明,printf函數(shù)的作用是將格式化后的字符逐個發(fā)送到輸出緩沖區(qū),緩沖區(qū)被刷新直到遇到換行符。這樣做無疑能提高效率,并且延遲了寫輸出。再來看輸入scanf,從鍵盤緩沖區(qū)讀取非空白字符數(shù)據(jù),而把換行符’\n’遺留在緩沖區(qū),引發(fā)的問題是如果同時用getchar、gets等不會忽略’\n’的函數(shù)讀取,就只能讀取到無意義的’\n’。C語言IO系統(tǒng)的讀入寫出和處理器緩存非常類似。因此,解決C語言緩沖區(qū)導(dǎo)致數(shù)據(jù)不一致的方案可以幫助理解處理器的緩存處理。C語言使用fflush函數(shù)或其他類似功能函數(shù)在必要的時候刷新或者清空緩沖區(qū)。處理器在緩存處理上采取類似的做法,但遠比其復(fù)雜的是處理器要同時兼顧指令緩存以及指令排序還有其他影響到數(shù)據(jù)同步的各方面軟硬件因素,這種多方兼顧的方式就叫做內(nèi)存屏障。
3.4 內(nèi)存屏障(Memory Barriers)
現(xiàn)在我們來談下多處理器下的共享內(nèi)存數(shù)據(jù)同步問題。多處理器同時訪問共享主存,每個處理器都要對讀寫進行重新排序,一旦數(shù)據(jù)更新,就需要同步更新到主存上(這里并不要求處理器緩存更新之后立刻更新主存)。在這種情況下,代碼和指令重排,再加上緩存延遲指令結(jié)果輸出導(dǎo)致共享變量被修改的順序發(fā)生了變化,使得程序的行為變得無法預(yù)測。為了解決這種不可預(yù)測的行為,處理器提供一組機器指令來確保指令的順序要求,它告訴處理器在繼續(xù)執(zhí)行前提交所有尚未處理的載入和存儲指令。同樣的也可以要求編譯器不要對給定點以及周圍指令序列進行重排。這些確保順序的指令稱為內(nèi)存屏障。具體的確保措施在程序語言級別的體現(xiàn)就是內(nèi)存模型的定義。POSIX、C++、Java都有各自的共享內(nèi)存模型,實現(xiàn)上并沒有什么差異,只是在一些細節(jié)上稍有不同。這里所說的內(nèi)存模型并非是指內(nèi)存布局,特指內(nèi)存、Cache、CPU、寫緩沖區(qū)、寄存器以及其他的硬件和編譯器優(yōu)化的交互時對讀寫指令操作提供保護手段以確保讀寫序。將這些繁雜因素可以籠統(tǒng)的歸納為兩個方面:重排和緩存,即上文所說的代碼重排、指令重排和CPU Cache。簡單的說內(nèi)存屏障做了兩件事情:拒絕重排,更新緩存。
C++11提供一組用戶API std::memory_order來指導(dǎo)處理器讀寫順序。Java使用happens-before規(guī)則來屏蔽具體細節(jié)保證,指導(dǎo)JVM在指令生成的過程中穿插屏障指令。
內(nèi)存屏障也可以在編譯期間指示對指令或者包括周圍指令序列不進行優(yōu)化,稱之為編譯器屏障,相當(dāng)于輕量級內(nèi)存屏障,它的工作同樣重要,因為它在編譯期指導(dǎo)編譯器優(yōu)化。屏障的實現(xiàn)稍微復(fù)雜一些,我們使用一組抽象的假想指令來描述內(nèi)存屏障的工作原理。
使用MB_R、MB_W、MB來抽象處理器指令為宏:
MB_R代表讀內(nèi)存屏障,它保證讀取操作不會重排到該指令調(diào)用之后。
MB_W代表寫內(nèi)存屏障,它保證寫入操作不會重排到該指令調(diào)用之后。
MB代表讀寫內(nèi)存屏障,可保證之前的指令不會重排到該指令調(diào)用之后。
這里用一個例子描述在多線程并發(fā)中使用內(nèi)存屏障的意義:
全局變量:bool has = false, Ticket t = NULL;
Thread A Thread Bhas = has_remain_ticket();/*有票*/ -------------------mb();/*表示在該處插入MB指令 */ if(has) {;--------------- --------------- --------------- ------ t = get_a_ticket();--------------- --------------- --------------- ------ to_user(t);--------------- --------------- --------------- -------------- }
用兩條線程A、B執(zhí)行,A中加入的內(nèi)存屏障有票,再通知用戶,不會發(fā)生先通知用戶,再去看有票。B中的內(nèi)存屏障保證必須取到票再交給用戶,防止尚未取票,就交給用戶。
這些屏障指令在單核處理器上同樣有效,因為單處理器雖不涉及多處理器間數(shù)據(jù)同步問題,但指令重排和緩存仍然影響數(shù)據(jù)的正確同步。指令重排是非常底層的且實現(xiàn)效果差異非常大,尤其是不同體系架構(gòu)對內(nèi)存屏障的支持程度,甚至在不支持指令重排的體系架構(gòu)中根本不必使用屏障指令。具體如何使用這些屏障指令是支持的平臺、編譯器或虛擬機要實現(xiàn)的,我們只需要使用這些實現(xiàn)的API(指的是各種并發(fā)關(guān)鍵字、鎖、以及重入性等,下節(jié)詳細介紹)。這里的目的只是為了幫助更好的理解內(nèi)存屏障的工作原理。
內(nèi)存屏障的意義重大,是確保正確并發(fā)的關(guān)鍵。通過正確的設(shè)置內(nèi)存屏障可以確保指令按照我們期望的順序執(zhí)行。這里需要注意的是內(nèi)存屏蔽只應(yīng)該作用于需要同步的指令或者還可以包含周圍指令的片段。如果用來同步所有指令,目前絕大多數(shù)處理器架構(gòu)的設(shè)計就會毫無意義。
并發(fā)程序依然非常復(fù)雜,雖然有眾多平臺和語言的支持。它們提供一組并發(fā)的API來簡化并發(fā),并在編譯或者運行期根據(jù)這些API或者規(guī)則穿插屏障指令來正確同步代碼以保證程序的執(zhí)行與預(yù)想的一致。接下來我們介紹并發(fā)程序在眾多平臺和語言上比較通用的幾個概念。
Volatile
原意是易變的。在計算機領(lǐng)域意思相同,指由該關(guān)鍵字修飾的變量的值易變,因此具有可見性??梢娦允侵府?dāng)一個線程對臨界資源進行修改后,在其他線程中可以看到發(fā)生的變化。為了實現(xiàn)這種可見性,處理器和編譯器忽略對該關(guān)鍵字修飾變量的優(yōu)化,也就是不對它進行重排,也不會緩存。但該變量的使用卻是非常危險的,因為它的行為總是違反我們的直覺。具體原因有以下幾方面:
雖然編譯器不去對它進行優(yōu)化,并且阻止volatile變量之間的重排(如C/C++和Java)。但是,它們可能和非volatile變量一起被重排序。在C/C++和早期Java內(nèi)存模型中確實是這么實現(xiàn)的。Java在新的內(nèi)存模型下,不僅volatile變量不能彼此重排序,而且volatile周圍的普通字段的也不再能夠隨便的重排序了(和舊模型不同),但是C/C++的volatile卻并未支持。還有一點容易讓人誤解的是它并不具有原子性這也是最容易犯錯的地方。最后一點也是最能讓使用者謹(jǐn)慎使用的理由是某些編譯器或者是陳舊的Java虛擬機對該關(guān)鍵字的支持不完整。也許使用volatile最安全的方式是嚴(yán)格限制到只是一個boolean值并且是在完全與周圍變量或操作無關(guān)的場合。但不能放棄使用volatile的原因是在如今的處理器架構(gòu)下,內(nèi)存模型強大到處理器對volatile的操作性能和非volatile相差無幾。
自旋鎖
Linux內(nèi)核中最常見的鎖,作用是在多核處理器間同步數(shù)據(jù)。這里的自旋是忙等待的意思。如果一個線程(這里指的是內(nèi)核線程)已經(jīng)持有了一個自旋鎖,而另一條線程也想要獲取該鎖,它就不停地循環(huán)等待,或者叫做自旋等待直到鎖可用??梢韵胂筮@種鎖不能被某個線程長時間持有,這會導(dǎo)致其他線程一直自旋,消耗處理器。所以,自旋鎖使用范圍很窄,只允許短期內(nèi)加鎖。其實還有一種方式就是讓等待線程睡眠直到鎖可用,這樣就可以消除忙等待。很明顯后者優(yōu)于前者的實現(xiàn),但是卻不適用于此。我們來詳細分析。如果我們使用第二種方式,我們要做幾步操作:把該等待線程換出、等到鎖可用在換入,有兩次上下文切換的代價。這個代價和短時間內(nèi)自旋(實現(xiàn)起來也簡單)相比,后者更能適應(yīng)實際情況的需要。還有一點需要注意,試圖獲取一個已經(jīng)持有自旋鎖的線程再去獲取這個自旋鎖或?qū)е滤梨i,但其他操作系統(tǒng)并非如此。
互斥鎖
即對互斥量進行分加鎖,和自旋鎖類似,唯一不同的是競爭不到鎖的線程會回去睡會覺,等到鎖可用再來競爭,第一個切入的線程加鎖后,其他競爭失敗者繼續(xù)回去睡覺直到再次接到通知、競爭。互斥鎖算是目前并發(fā)系統(tǒng)中最常用的一種鎖,POSIX、C++11、Java等均支持。處理POSIX的加鎖比較普通外,C++和Java的加鎖方式很有意思。C++中可以使用一種AutoLock(常見于chromium等開源項目中)工作方式類似auto_ptr智能指針,在C++11中官方將其標(biāo)準(zhǔn)化為std::lock_guard和std::unique_lock。Java中使用synchronized緊跟同步代碼塊(也可修飾方法)的方式同步代碼,非常靈活。這兩種實現(xiàn)都巧妙的利用各自語言特性實現(xiàn)了非常優(yōu)雅的加鎖方式。當(dāng)然除此之外他們也支持傳統(tǒng)的類似于POSIX的加鎖模式。
讀寫鎖
支持兩種模式的鎖,當(dāng)采用寫模式上鎖時與互斥鎖相同,是獨占模式。但讀模式上鎖可以被多個讀線程讀取。即寫時使用互斥鎖,讀時采用共享鎖,故又叫共享-獨占鎖。一種常見的錯誤認(rèn)為數(shù)據(jù)只有在寫入時才需要鎖,事實是即使是讀操作也需要鎖保護,如果不這么做的話,讀寫鎖的讀模式便毫無意義。
重入
也叫做鎖遞歸,就是獲取一個已經(jīng)獲取的鎖。不支持線程獲取它已經(jīng)獲取且尚未解鎖的方式叫做不可遞歸或不支持重入。帶重入特性的鎖在重入時會判斷是否同一個線程,如果是,則使持鎖計數(shù)器+1(0代表沒有被線程獲取,又或者是鎖被釋放)。C++11中同時支持兩種鎖,遞歸鎖std::recursive_mutex和非遞歸std::mutex。Java的兩種互斥鎖實現(xiàn)以及讀寫鎖實現(xiàn)均支持重入。POSIX使用一種叫做重入函數(shù)的方法保證函數(shù)的線程安全,鎖粒度是調(diào)用而非線程。
死鎖
線程在執(zhí)行過程中等待鎖釋放,如果存在多個線程相互等待已經(jīng)被加鎖的資源,就會造成死鎖。大多數(shù)語言的鎖實現(xiàn)都支持重入的一個重要原因是一個函數(shù)體內(nèi)加鎖的代碼段中經(jīng)常會調(diào)用其他函數(shù),而其他函數(shù)內(nèi)部同樣加了相同的鎖,在不支持重入的情況下,執(zhí)行線程總是要獲取自己尚未釋放的鎖。也就是說該條線程試圖獲取一個自己已經(jīng)獲取而尚未釋放的鎖。死鎖就此產(chǎn)生。還有最經(jīng)典的哲學(xué)家就餐問題。
線程饑餓
互斥鎖中提到獲取不到鎖的線程回去睡眠等待下一次競爭鎖,如果下一次仍然得不到,就繼續(xù)睡眠,這種持續(xù)得不到鎖的情況我們稱之為饑餓。一個很有意思的例子是關(guān)于小米手機饑餓營銷的。將小米手機比作競爭資源,搶手機的用戶就是線程,每次開搶都搶不到的用戶就是線程饑餓。和饑餓相對的是公平,操作系統(tǒng)調(diào)度程序負責(zé)這種公平,使用分片或nice或執(zhí)行比等方式避免得不到調(diào)度的線程活活餓死。Java默認(rèn)采用非公平的互斥鎖(synchronized是強制的,Lock是可選的。關(guān)于Java內(nèi)置鎖和Lock鎖的公平性討論參見:Java中的ReentrantLock和synchronized兩種鎖定機制的對比),但是公平鎖因為要防止饑餓需要根據(jù)線程調(diào)度策略做調(diào)整,所以性能會受到影響,而且一般情況下某條線程餓死的情況鮮有發(fā)生(因為調(diào)度本來就是不公平的),因此默認(rèn)都是非公平的。
CAS(Compare – And – Swap)
中譯名比較交換。目前有一種特殊的并發(fā)方式叫做無鎖并發(fā),通過上文的說明大家應(yīng)該馬上清楚要使用CAS達到正確同步須由處理其提供支持。有一個叫做Lock_Free的算法提出了一種方案:確保執(zhí)行它的所有線程中至少有一個能夠繼續(xù)往下執(zhí)行。實現(xiàn)這個算法的技術(shù)叫做比較并交換,即CAS。CAS使用樂觀技術(shù)來更新值,如果在另一條線程更新了這個值,CAS可以檢測到這個錯誤?,F(xiàn)在大多數(shù)處理器架構(gòu)(包括IA32和Sparc)都支持比較并交換CAS的原子操作,X86下對應(yīng)的是 CMPXCHG 匯編指令。CAS 原語負責(zé)將某處內(nèi)存地址的值(1 個字節(jié))與一個期望值進行比較,如果相等,則將該內(nèi)存地址處的值替換為新值,CAS 操作用一行C代碼描述如下:
return *add == old_val ? (*add = new_val) ? false;
目前windows內(nèi)置、Gcc內(nèi)置、C++11定義頭文件和Java通過java.util.concurrent.atomic包提供對CAS操作的支持。
并發(fā)相關(guān)的知識概念已經(jīng)介紹完了,涉及的面非常廣,從多任務(wù)到操作系統(tǒng)調(diào)度,從處理器到CPU架構(gòu),從多級緩存到內(nèi)存共享,從編譯器優(yōu)化到指令重排,從內(nèi)存模型到內(nèi)存屏障,最后對并發(fā)在Linux Kernel、C/C++(POSIX)以及Java等支持并發(fā)的平臺或高級程序設(shè)計語言中涉及到的關(guān)鍵字以及各種鎖做了具體介紹。
并發(fā)編程非常復(fù)雜,就僅僅是構(gòu)建正確的并發(fā)程序而言。因為它涉及到操作系統(tǒng)、處理器等各種軟硬件在正確的前提下進行復(fù)雜的協(xié)作的工作?,F(xiàn)在的高級程序設(shè)計語言都提供成熟的并發(fā)API來減輕這項復(fù)雜的工作,代價是在背后做更多的事情。我無意于過程與面向?qū)ο笾疇?,但不得不說面向?qū)ο罂梢院喕l(fā),抽象出更簡單的API。C++11版加入了線程支持,這是他們的宿愿(這里指把boost庫中的對于線程的支持部分納入語言標(biāo)準(zhǔn)),卻花了十年的時間,但他們?nèi)匀贿@么做就足以證明使用OO思想來更好的實現(xiàn)并發(fā)是值得的。Java一開始就支持線程,它對線程的發(fā)展遠快于C++,并且正不斷把并發(fā)框架化以支持更多處理器以及更復(fù)雜并發(fā)模型(Java 7的Fork/Join框架希望將來能夠支撐數(shù)百個CPU,并試圖挑戰(zhàn)1000核)。按照并行計算專家Doug Lea的話來說,這已經(jīng)不只是技術(shù)問題,而是藝術(shù)問題了。對于藝術(shù)的討論當(dāng)然遙不可及,對于Doug Lea大師只能高山仰止了。然,終究初涉并發(fā),水平有限,尚不能尋到藝術(shù)之門,行文就止于此吧。
參考文獻: