併發編程基礎
Table of Contents
1 多線程及併發
線程是操做系統進行做業調度的最小單位,也是進程內部的一條執行路徑。與進程不一樣,線程並無對操做系統的資源全部權,也就是說同一個進程內的多個線程對資源的訪問權是共享的。一個進程中的全部線程共享一個地址空間或者諸如打開的文件之類的其餘資源,一個進程對這些資源的任何修改,都會影響到本進程中其餘線程的運行。所以,須要對多個線程的執行進行細緻地設計,使它們可以互不干涉,而且不破壞共享的數據及資源。linux
在單處理器中的併發系統裏,不一樣進程和線程之間的指令流是交替執行的,但因爲調度系統及CPU時鐘的配合,使得程序對外表現出一種同時執行的外部特徵;而在並行多處理器系統中,指令流之間的執行則是重疊的。不管是交替執行仍是重疊執行,實際上併發程序都面臨這一樣的問題,即指令流的執行速度不可預測,這取決於其餘指令流的活動狀態、操做系統處理中斷的方式及操做系統的調度策略。這給併發程序設計帶來了以下的一些問題:編程
- 多個進程(或線程)對同一全局資源的訪問可能形成未定義的後果。例如,若是兩個併發線程都使用同一個全局變量,而且都對該變量執行讀寫操做,那麼程序執行的結果就取決於不一樣的讀寫執行順序——而這些讀寫執行順序是不可預知的。
- 操做系統難以對資源進行最優化分配。這涉及到死鎖及飢餓的問題。
- 很難定位程序的錯誤。在多數狀況下,併發程序設計的失誤都是很難復現的,在一次執行中出現一種結果,而在下一次執行中,每每會出現迥然不一樣的其餘結果。
所以,在進行多線程程序或者併發程序的設計時,尤爲須要當心。能夠看到的是,絕大多數併發程序的錯誤都出如今對共享資源的訪問上,所以,如何保證對共享資源的訪問以一種肯定的、咱們能夠預知的方式運行,成爲併發程序設計的首要問題。在操做系統領域,對共享資源的訪問有個專用的數據,稱爲臨界區。多線程
臨界區是一段代碼,在這段代碼中,進程將訪問共享資源。當另一個進程已經在這段代碼中執行時,這個進程就不能在這段代碼中執行。併發
也就是說,臨界區是一個代碼段,這個代碼段不容許兩條並行的指令流同時進入。提供這種保證的機制稱爲互斥:當一個進程在臨界區訪問共享資源時,其餘進程不能進入該臨界區。less
2 鎖及互斥
實現互斥的機制,最重要的是互斥鎖(mutex)。互斥鎖其實是一種二元信號量(只有0和1),專用於多任務之間臨界區的互斥操做。(關於信號量及互斥鎖的區別,能夠參看操做系統相關知識)函數
mutex本質上是一個信號量對象,只有0和1兩個值。同時,mutex還對信號量加1和減1的操做進行了限制,即某個線程對其進行了+1操做,則-1操做也必須由這個線程來完成。mutex的兩個值也分別表明了mutex的兩種狀態。值爲0, 表示鎖定狀態,當前對象被鎖定,用戶進程/線程若是試圖Lock臨界資源,則進入排隊等待;值爲1,表示空閒狀態,當前對象爲空閒,用戶進程/線程能夠Lock臨界資源,以後mutex值減1變爲0。post
mutex能夠抽象爲建立(Create),加鎖(Lock),解鎖(Unlock),及銷燬(Destroy)等四個操做。在建立mutex時,能夠指定鎖的狀態是空閒或者是鎖定,在linux中,這個屬性的設置主要經過pthread_mutex_init來實現:測試
在使用mutex的時候,務必須要了解其本質:mutex其實是一個在多個線程之間共享的信號量,當其進入鎖定狀態時,再試圖對其加鎖,則會阻塞線程。例如,對於兩個線程A和B,其指令序列以下: 線程Afetch
- lock(&mutex)
- do something
- unlock(&mutex)
線程B
- lock(&mutex)
- do something
- unlock(&mutex)
在線程A的語句1處,線程A對mutex進行了加鎖操做,mutex變爲鎖定狀態。在線程A的語句2及線程B的語句1處,A還沒有對mutex進行解鎖,而B則試圖對mutex進行加鎖操做,所以線程B被阻塞,直到A的語句3處,線程A對mutex進行了解鎖,B的語句1才得以繼續執行,將mutex進行加鎖並繼續執行語句2和語句3。所以,若是在do something中有對共享資源的訪問操做,那麼do something就是一個臨界區,每次都只有一個線程可以進入這段代碼。
3 原子操做
不管是信號量,仍是互斥鎖,其中最重要的一個概念就是原子操做。所謂原子操做,就是不會被線程調度機制所打斷的操做——從該操做的第一條指令開始到最後一條指令結束,中間不會有任何的上下文切換(context switch)。
在單處理器系統上,原子操做的實現較爲簡單:第一種方式是一些單指令便可完成的操做,如compare and swap、test and set等,因爲上下文切換隻可能出如今指令之間,所以單處理器系統上的單指令操做都是原子操做;另外一種方式則是禁用中斷,經過彙編語言支持,在指令執行期間,禁用處理器的全部中斷操做,因爲上下文切換都是經過中斷來觸發的,所以禁用中斷後,能夠保證指令流的執行不會被外部指令所打斷。
而在多處理器系統上,狀況要複雜一些。因爲系統中有多個處理器在獨立地運行,即便能在單條指令中完成的操做也有可能受到干擾。如,在不一樣的CPU運行的兩個線程都在執行一條遞減指令,即對內存中某個內存單元的值-1,則指令流水線多是這樣:(省略了取指)
- A處理器: |–讀內存–|–計數減1–|–寫內存 –|
- B處理器: |–讀內存 –|–計數減1–|–寫內存–|
假設原來內存單元中存儲的值爲5,那麼:A、B處理器所讀到的內存值都爲5,其往內存單元中寫入的值都爲4。所以,雖然進行了兩次-1操做,但實際上運行的結果和執行了1次是同樣的。
注:這是一個數據相關問題(關於數據相關問題,能夠參考計算機體系結構中指令流水線的設計及數據相關的避免等資料),在單處理機中,這個問題能夠經過檢查處理機中的指令寄存器,來檢查在流水線中的指令之間的相關性,若是出現數據相關的狀況,能夠經過延遲相關指令執行的方法來規避;而在對稱多處理機中,因爲CPU之間相互不知道對方的指令寄存器狀態,那麼這種流水線做業引發的數據競跑就沒法避免。
爲了對原子操做提供支持,在 x86 平臺上,CPU提供了在指令執行期間對總線加鎖的手段。CPU芯片上有一條引線#HLOCK pin,若是彙編語言的程序中在一條指令前面加上前綴"LOCK",通過彙編之後的機器代碼就使CPU在執行這條指令的時候把#HLOCK pin的電位拉低,持續到這條指令結束時放開,從而把總線鎖住,這樣同一總線上別的CPU就暫時不能經過總線訪問內存了,保證了這條指令在多處理器環境中的原子性。
能夠看出,其實pthread_mutex_lock及pthread_mutex_unlock就是一個原子操做。它保證了兩個線程不會同時對某個mutex變量加鎖或者解鎖,不然的話,互斥也就無從實現了。
i++和++i是原子操做嗎?
有一個不少人也許都不是很清楚的問題:i++或++i是一個原子操做嗎?在上一節,其實已經提到了,在SMP(對稱多處理器)上,即便是單條遞減彙編指令,其原子性也是不能保證的。那麼在單處理機系統中呢?
在編譯器對C/C++源代碼進行編譯時,每每會進行一些代碼優化。例如,對i++這條指令,實際上編譯器編譯出的彙編代碼是相似下面的彙編語句:
- mov eax,[i]
- add eax,1
- mov [i],eax
語句1是將i所在的內存讀取到寄存器中,而語句2是將寄存器的值加1,語句3是將寄存器值寫回到內存中。之因此進行這樣的操做,是爲了CPU訪問數據效率的高效。能夠看出,i++是由一條語句被編譯成了3條指令,所以,即便在單處理機系統上,i++這種操做也不是原子的。這是因爲指令之間的亂序執行而形成的。
4 GCC的內建原子操做
在GCC中,從版本4.1.2起,提供了__sync_*系列的built-in函數,用於提供加減和邏輯運算的原子操做。這些操做經過鎖定總線,不管在單處理機和多處理機上都保證了其原子性。GCC提供的原子操做主要包括:
type __sync_fetch_and_add (type *ptr, type value, ...); type __sync_fetch_and_sub (type *ptr, type value, ...); type __sync_fetch_and_or (type *ptr, type value, ...); type __sync_fetch_and_and (type *ptr, type value, ...); type __sync_fetch_and_xor (type *ptr, type value, ...); type __sync_fetch_and_nand (type *ptr, type value, ...);
這六個函數的做用是:取得ptr所指向的內存中的數據,同時對ptr中的數據進行修改操做(加,減,或,與,異或,與後取非)等。
type __sync_add_and_fetch (type *ptr, type value, ...); type __sync_sub_and_fetch (type *ptr, type value, ...); type __sync_or_and_fetch (type *ptr, type value, ...); type __sync_and_and_fetch (type *ptr, type value, ...); type __sync_xor_and_fetch (type *ptr, type value, ...); type __sync_nand_and_fetch (type *ptr, type value, ...);
這六個函數與上六個函數基本相同,不一樣之處在於,上六個函數返回值爲修改以前的數據,而這六個函數返回的值爲修改以後的數據。
bool __sync_bool_compare_and_swap (type *ptr, type oldval type newval, ...); type __sync_val_compare_and_swap (type *ptr, type oldval type newval, ...);
比較並交換指令。若是ptr所指向的內存中的數據等於oldval,則設置其爲newval,同時返回true;不然返回false。
type __sync_lock_test_and_set (type *ptr, type value, ...);
測試並置位指令。
void __sync_lock_release (type *ptr, ...);
將ptr設置爲0。
其中,這些操做的操做數(type)能夠是1,2,4或8字節長度的int類型,即:
- int8_t / uint8_t
- int16_t / uint16_t
- int32_t / uint32_t
- int64_t / uint64_t