Linux核心之 核心同步

its666發表於2020-12-31

 

一、同步介紹

 

1、臨界區與競爭條件

所謂臨界區(critical regions)就是訪問和操作共享資料的程式碼段。為了避免在臨界區中併發訪問,程式設計者必須保證這些程式碼原子地執行——也就是說,程式碼在執行結束前不可被打斷,就如同整個臨界區是一個不可分割的指令一樣。如果兩個執行執行緒有可能處於同一個臨界區中同時執行,那麼就是程式包含一個bug,如果這種情況發生了,我們就稱之為競爭條件(race conditions,簡稱競態),避免併發和防止競爭條件被稱為同步(synchronization)。

在linux中,主要的競態發生在如下幾種情況:

(1)對稱多處理器(SMP)多個CPU

特點是多個CPU使用共同的系統匯流排,因此可訪問共同的外設和儲存器。

(2)單CPU內程式與搶佔它的程式

(3)中斷(硬中斷、軟中斷、Tasklet、中斷下半部)與程式之間

只要併發的多個執行單元存在對共享資源的訪問,競態就有可能發生。

如果中斷處理程式訪問程式正在訪問的資源,則競態也會發生。

多箇中斷之間本身也可能引起併發而導致競態(中斷被更高優先順序的中斷打斷)。

互斥鎖,自旋鎖,CAS,原子操作詳細說明點選:「連結」

2、死鎖

死鎖的產生需要一定條件:要有一個或多個執行執行緒和一個或多個資源,每個執行緒都在等待其中的一個資源,但所有的資源都已經被佔用了,所有執行緒都在相互等待,但它們永遠不會釋放已經佔有的資源,於是任何執行緒都無法繼續,這便意味著死鎖的發生。

最簡單的死鎖例子是自死鎖:

  • 獲得鎖

  • 再次試圖獲得鎖

  • 等待鎖重新利用

  • ......

這種情況屬於一個執行緒一把鎖,自己等自己,一般是一個函式等另一個函式,從廣義上說就是一種巢狀使用。我曾經的經驗總結《踩坑經驗總結(四):死鎖》就屬於這種情況。

最常見的死鎖例子是ABBA鎖:

  • 執行緒1

  • 獲得鎖A

  • 試圖獲得鎖B

  • 等待鎖B

  • ......

  • 執行緒2

  • 獲得鎖B

  • 試圖獲得鎖A

  • 等待鎖A

  • ......

這種問題確實很常見,在資料庫《MySQL InnoDB技術內幕:記憶體管理、事務和鎖》出現的往往也是這種型別的死鎖。

3、加鎖規則

預防死鎖非常重要,那該注意些什麼呢?

(1)按順序加鎖。使用巢狀鎖是必須保證以正確的順序獲取鎖,這樣可以阻止致命的擁抱類死鎖,即ABBA鎖。最好能記錄下鎖的順序,後續都按此順序使用。

(2)防止發生飢餓。特別是在一些大迴圈中,儘量將鎖移入內部,否則外面等太久。如果發生死迴圈,就會出現飢餓。

(3)不要重複請求同一把鎖。這是針對自死鎖的情況,但是一旦出現這種情況,往往不明顯,即不是很明顯的巢狀,轉了幾個彎彎,就叫曲線巢狀吧。

(4)設計應力求簡單。越複雜的加鎖方案越有可能造成死鎖。

這裡的每一項都很重要,對於應用程式同樣適合。再重點說下設計。

在最開始設計程式碼的時候,就應該考慮加鎖;越往後考慮,付出代價越大,效果反而越不理想。那麼設計階段加鎖時一定要考慮,為什麼要加鎖,為了保護什麼資料?我認為這是一個定位的問題。需求階段對一個產品的定位,設計階段對資料的定位,決定了後續一系列的動作比如採用的方案、採用的演算法、採用的結構體......開始經驗之談了:)。

那麼到底該如何加鎖,記住:要給資料而不是給程式碼加鎖。我認為這是一個黃金規則,在《死鎖》也這麼強調過。

4、爭用與擴充套件性

鎖的爭用(lock contention),簡稱爭用,是指當鎖正在被佔用時,有其他執行緒試圖獲得該鎖。

  • 說一個鎖處於高度爭用狀態,就是指有多個其他執行緒在等待獲得該鎖。

  • 由於鎖的作用是使程式以序列方式對資源進行訪問,所以使用鎖無疑會降低系統的效能。被高度爭用(頻繁被持有,或者長時間持有——兩者都有就更糟糕)的鎖會成為系統的瓶頸,嚴重降低系統效能。

擴充套件性(scalability)是對系統可擴充套件程度的一個量度。

  • 對於作業系統,我們在談及可擴充套件性時就會和大量程式、大量處理器或是大量記憶體等聯絡起來。其實任何可以被計量的計算機元件都可以涉及可擴充套件性。理想情況下,處理器的數量加倍應該會使系統處理效能翻倍。而實際上, 這是不可能達到的。

  • 自從2.0版核心引入多處理支援後,Linux對叢集處理器的可擴充套件性大大提高了。在Linux剛加入對多處理器支援的時候,一個時刻只能有一個任務在核心中執行;在2.2版本中,當加鎖機制發展到細粒度加鎖後,便取消了這種限制,而在2.4和後續版本中,核心加鎖的粒度變得越來越精細。如今,在Linux 2.6版核心中,核心加的鎖是非常細的粒度,可擴充套件性也很好.

  • 加鎖粒度用來描述加鎖保護的資料規模。

  • 一個過粗的鎖保護大塊資料——比如,一個子系統用到的所有的資料結構:相反,一個過於精細的鎖保護很小的一塊資料——比如,一個大資料結構中的一個元素。在實際使用中,絕大多數鎖的加鎖範圍都處於上述兩種極端之間,保護的既不是一個完整的子系統也不是一個獨立元素,而可能是一個單獨的資料結構。許多鎖的設計在開始階段都很粗,但是當鎖的爭用問題變得嚴重時,設計就向更加精細的加鎖方向進化。

  • 在前面討論過的執行佇列,就是一個鎖從粗到精細化的例項。

  • 在2.4版和更早的核心中,排程程式有一個單獨的排程佇列(回憶一下,排程佇列是一個由可排程程式組成的連結串列),在2.6版核心系列的早期版本中,O(1)排程程式為每個處理器單獨配備一個執行佇列,每個佇列擁有自己的鎖,於是加鎖由一個全域性鎖精化到了每個處理器擁有各自的鎖。這是一種重要的優化,因為執行佇列鎖在大型機器上被爭著用,本質上就是要在排程程式中每次都把整個排程程式下放到單個處理器上執行。在2.6版核心系列的版本中,CFS排程器進一步提升了鎖的可擴充套件性。

  • 一般來說,提高可擴充套件性是件好事,因為它可以提高Linux在更大型的、處理能力更強大的系統上的效能。

  • 但是一味地“提高”可擴充套件性,卻會導Linux在小型SMP和UP機器上的效能降低,這是因為小型機器可能用不到特別精細的鎖,鎖得過細只會增加複雜度,並加大開銷。

  • 考慮一個連結串列,最初的加鎖方案可能就是用一個鎖來保護連結串列,後來發現,在擁有叢集處理器機器上,當各個處理器需要頻繁訪問該連結串列的時候,只用單獨一個鎖卻成了擴充套件性的瓶頸。為解決這個瓶頸,我們將原來加鎖的整個連結串列變成為連結串列中的每一個結點都加入自己的鎖,這樣一來, 如果要對結點進行讀寫,必須先得到這個結點對應的鎖。將加鎖粒度變細後,多處理器訪問同一 個結點時,只會爭用一個鎖。可是這時鎖的爭用仍然沒有完全避免,那麼,能不能為每個結點中的每個元素都提供一個鎖呢?(答案是:不能)嚴格地講,即使這麼細的鎖可以在大規模SMP機器上執行得很好,但它在雙處理器機器上的表現又會怎樣呢?如果在雙處理器機器鎖爭用表現 得並不明顯,那麼多餘的鎖會加大系統開銷,造成很大的浪費。

  • 不管怎麼說,可擴充套件性都是很重要的,需要慎重考慮。關鍵在於,在設計鎖的開始階段就應該考慮到要保證良好的擴充套件性。因為即使在小型機器上,如果對重要資源鎖得太粗,也很容易造成系統效能瓶頸。鎖加得過粗或過細,差別往往只在一線之間。當鎖爭用嚴重時,加鎖太粗會降低可擴充套件性;而鎖爭用不明顯時,加鎖過細會加大系統開銷,帶來浪費,這兩種情況都會造成系統效能下降。但要記住:設計初期加鎖方案應該力求簡單,僅當需要時再進一步細化加鎖方案。 精髓在於力求簡單。

上面這大段話來自書上,分析的很好,介紹了鎖的粒度過粗和過細的危害,同時也介紹了核心加鎖的一個變化和演進。總之,對於我們設計軟體都有參考意義。也是理解核心後面為什麼出現了多種同步方法的原因。

二、同步方法

Linuxc/c++伺服器開發高階視訊學習資料+qun720209036獲取。關注VX公眾號:Linux C後臺伺服器開發

內容包括C/C++,Linux,Nginx,ZeroMQ,MySQL,Redis,MongoDB,ZK,流媒體,P2P,K8S,Docker,TCP/IP,協程,DPDK多個高階知識點。

 

1、原子操作

原子操作是其他同步方法的基石。原子操作可以保證指令以原子的方式執行——執行過程不可中斷。在資料庫事務中這也是基本的要求。

linux核心提供了兩組原子操作介面:一組對整數進行操作,一組針對單獨的位進行操作。

原子整數操作

針對整數的原子操作只能對atomic_t型別的資料進行處理,在這裡之所以引入了一個特殊的資料型別,而沒有直接使用C語言的int型,主要是出於兩個原因:

第一、讓原子函式只接受atomic_t型別的運算元,可以確保原子操作只與這種特殊型別資料一起使用,同時,這也確保了該型別的資料不會被傳遞給其它任何非原子函式;

第二、使用atomic_t型別確保編譯器不對相應的值進行訪問優化——這點使得原子操作最終接收到正確的記憶體地址,而不是一個別名,最後就是在不同體系結構上實現原子操作的時候,使用atomic_t可以遮蔽其間的差異。

atomic_t型別定義在檔案<linux/type.h>中:

typedef struct {
    volatile int counter;  
}atomic_t;
 

原子整數操作最常見的用途就是實現計數器。

另一點需要說明原子操作只能保證操作是原子的,要麼完成,要麼不完成,不會有操作一半的可能,但原子操作並不能保證操作的順序性,即它不能保證兩個操作是按某個順序完成的。如果要保證原子操作的順序性,請使用記憶體屏障指令。

原子操作與更復雜的同步方法相比較,給系統帶來的開銷小,對快取記憶體行的影響也小。

原子位操作

針對位這一級資料進行操作的函式,是對普通的記憶體地址進行操作的。它的引數是一個指標和一個位號。

2、自旋鎖

Linux核心中最常見的鎖是自旋鎖(spin lock)。自旋鎖最多隻能被一個可執行執行緒持有。如果一個執行執行緒試圖獲得一個被爭用(已經被持有)的自旋鎖,那麼該執行緒就會一直進行忙迴圈—旋轉—等待鎖重新可用。要是鎖未被爭用,請求鎖的執行執行緒便能立刻得到它,繼續執行。在任意時間,自旋鎖都可以防止多於一個的執行執行緒同時進入臨界區。同一個鎖可以用在多個位置—例如,對於給定資料的所有訪問都可以得到保護和同步。

一個被爭用的自旋鎖使得請求它的執行緒在等待鎖重新可用時自旋(特別浪費處理器時間),即忙等待,這是自旋鎖的要點。所以自旋鎖不應該被長時間持有。事實上,這點正是使用自旋鎖的初衷,在短期間內進行輕量級加鎖。

自旋鎖的實現和體系密切相關,程式碼往往通過彙編實現。實際用到的介面定義在檔案中。自旋鎖的基本使用形式如下:

DEFINE  SPINLOCK(mr_lock);

spin_lock(&mr_lock);
/*臨界區....*/
spin_unlock(&mr_lock);
 

自旋鎖可以使用在中斷處理程式中(此處不能使用訊號量,因為它們會導致睡眠),在中斷處理程式中使用自旋鎖時,一定要在獲取鎖之前,首先禁止本地中斷(在當前處理器上的中斷請求)。注意,需要關閉的只是當前處理器上的中斷,如果中斷髮生在不同的處理器上,即使中斷處理程式在同一鎖上自旋,也不會妨礙鎖的持有者(在不同處理器上)最終釋放鎖。

3、讀寫自旋鎖

有時,鎖的用途可以明確的分為讀取和寫入兩個場景。那麼讀寫可以分開處理,讀時可以共享資料,寫時進行互斥。為此,Linux核心提供了專門的讀寫自旋鎖。

這種讀寫自旋鎖為讀和寫分別提供了不同的鎖,所以它具有以下特點:

  1. 讀鎖之間是共享的,即一個執行緒持有了讀鎖之後,其他執行緒也可以以讀的方式持有這個鎖。

  2. 寫鎖之間是互斥的,即一個執行緒持有了寫鎖之後,其他執行緒不能以讀或者寫的方式持有這個鎖。

  3. 讀寫鎖之間是互斥的,即一個執行緒持有了讀鎖之後,其他執行緒不能以寫的方式持有這個鎖,寫鎖必須等待讀鎖的釋放。

讀寫自旋鎖的使用用法類似於普通的自旋鎖:

DEFINE_RWLOCK(mr_rwlock);

read_lock(&mr_rwlock);
/*critical region, only for read*/
read_unlock(&mr_rwlock);

write_lock(&mr_lock);
/*critical region, only for write*/
write_unlock(&mr_lock);
 

注意:如果寫和讀不能清晰地進行分離,那麼使用一般的自旋鎖就夠了,不需要使用讀寫自旋鎖。

4、訊號量

訊號量也是一種鎖,和自旋鎖不同的是,執行緒獲取不到訊號量的時候,不會像自旋鎖一樣迴圈去試圖獲取鎖,而是進入睡眠,直至有訊號量釋放出來時,才會喚醒睡眠的執行緒,進入臨界區執行。

由於使用訊號量時,執行緒會睡眠,所以等待的過程不會佔用 CPU 時間。所以訊號量適用於等待時間較長的臨界區。

訊號量消耗CPU時間的地方在於使執行緒睡眠和喚醒執行緒--兩次明顯的上下文切換。

如果(使執行緒睡眠 + 喚醒執行緒)的 CPU 時間 > 執行緒自旋等待 CPU 時間,那麼可以考慮使用自旋鎖。

訊號量有二值訊號量和計數訊號量兩種,其中二值訊號量比較常用。

二值訊號量表示訊號量只有2個值,即0和1。訊號量為1時,表示臨界區可用,訊號量為0時,表示臨界區不可訪問。所以也可以稱為互斥訊號量。

計數訊號量有個計數值,比如計數值為5,表示同時可以有5個執行緒訪問臨界區。所以二值訊號量就是計數等於1的計數訊號量。

5、讀寫訊號量

讀寫訊號量和訊號量的關係與讀寫自旋鎖和自旋鎖的關係差不多。

讀寫訊號量都是二值訊號量,即計數值最大為1,增加讀者時,計數器不變,增加寫者,計數器才減一。

也就是說讀寫訊號量保護的臨界區,最多隻有一個寫者,但可以有多個讀者。

6、互斥

互斥體(mutex)也是一種可以睡眠的鎖,相當於二值訊號量,只是提供的API更加簡單,使用的場景也更嚴格一些,如下所示:

  1. mutex的計數值只能為1,也就是最多隻允許一個執行緒訪問臨界區

  2. 在同一個上下文中上鎖和解鎖

  3. 不能遞迴的上鎖和解鎖

  4. 持有個mutex時,程式不能退出

  5. mutex不能在中斷或者下半部中使用,也就是mutex只能在程式上下文中使用

  6. mutex只能通過官方API來管理,不能自己寫程式碼操作它

在面對互斥體和訊號量的選擇時,只要滿足互斥體的使用場景就儘量優先使用互斥體。

在面對互斥體和自旋鎖的選擇時,參見下表:

 

7、完成變數

完成變數的機制類似於訊號量,比如一個執行緒A進入臨界區之後,另一個執行緒B會在完成變數上等待,執行緒A完成了任務出了臨界區之後,使用完成變數來喚醒執行緒B。

一般在2個任務需要簡單同步的情況下,可以考慮使用完成變數。

8、大核心鎖

大核心鎖已經不再使用,只存在與一些遺留的程式碼中。

9、 順序鎖

順序鎖為讀寫共享資料提供了一種簡單的實現機制。之前提到的讀寫自旋鎖和讀寫訊號量,在讀鎖被獲取之後,寫鎖是不能再被獲取的,也就是說,必須等所有的讀鎖釋放後,才能對臨界區進行寫入操作。

順序鎖則與之不同,讀鎖被獲取的情況下,寫鎖仍然可以被獲取。使用順序鎖的讀操作在讀之前和讀之後都會檢查順序鎖的序列值,如果前後值不符,則說明在讀的過程中有寫的操作發生,那麼讀操作會重新執行一次,直至讀前後的序列值是一樣的。

順序鎖優先保證寫鎖的可用,所以適用於那些讀者很多,寫者很少,且寫優於讀的場景。

10、禁止搶佔

其實使用自旋鎖已經可以防止核心搶佔了,但是有時候僅僅需要禁止核心搶佔,不需要像自旋鎖那樣連中斷都遮蔽掉。

這時候就需要使用禁止核心搶佔的方法了:

這裡的preempt_disable()和preempt_enable()是可以巢狀呼叫的,disable和enable的次數最終應該是一樣的。

11、順序和屏障

對於一段程式碼,編譯器或者處理器在編譯和執行時可能會對執行順序進行一些優化,從而使得程式碼的執行順序和我們寫的程式碼有些區別。

一般情況下,這沒有什麼問題,但是在併發條件下,可能會出現取得的值與預期不一致的情況。

在某些併發情況下,為了保證程式碼的執行順序,引入了一系列屏障方法來阻止編譯器和處理器的優化。

舉例如下:

void thread_worker()
{
    a = 3;
    mb();
    b = 4;
}
 

上述用法就會保證 a 的賦值永遠在 b 賦值之前,而不會被編譯器優化弄反。在某些情況下,弄反了可能帶來難以估量的後果。

12、總結

本節討論了大約11種核心同步方法,除了大核心鎖已經不再推薦使用之外,其他各種鎖都有其適用的場景。

瞭解了各種同步方法的適用場景,才能正確的使用它們,使我們的程式碼在安全的保障下達到最優的效能。

同步的目的就是為了保障資料的安全,其實就是保障各個執行緒之間共享資源的安全,下面根據共享資源的情況來討論一下10種同步方法的選擇。

10種同步方法在圖中分別用藍色框標出。

最後,在此圖基礎上再做個總結。

上述的10多種鎖中,核心中最常見的還是自旋鎖,訊號量和互斥鎖這三種。其中在第二部分第6節中對這三種如何做出選擇已經列出了一個表格,這是全文的重點!

學習核心鎖的實現,有助於我們在程式設計中如何使用鎖,使用什麼型別的鎖以及如何設計鎖。

相關文章