Linux內核之 內核同步

上文咱們介紹過進程調度,Linux內核從2.6版本開始支持內核搶佔,因此內核不少代碼也須要同步保護。html

 

1、同步介紹

一、臨界區與競爭條件

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

 linux中,主要的競態發生在以下幾種狀況:算法

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

特色是多個CPU使用共同的系統總線,所以可訪問共同的外設和存儲器。編程

(2)單CPU內進程與搶佔它的進程緩存

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

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

若是中斷處理程序訪問進程正在訪問的資源,則競態也會發生。併發

多箇中斷之間自己也可能引發併發而致使競態(中斷被更高優先級的中斷打斷)。函數

二、死鎖

死鎖的產生須要必定條件:要有一個或多個執行線程和一個或多個資源,每一個線程都在等待其中的一個資源,但全部的資源都已經被佔用了,全部線程都在相互等待,但它們永遠不會釋放已經佔有的資源,因而任何線程都沒法繼續,這便意味着死鎖的發生。

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

  • 得到鎖
  • 再次試圖得到鎖
  • 等待鎖從新利用
  • ......

這種狀況屬於一個線程一把鎖,本身等本身,通常是一個函數等另外一個函數,從廣義上說就是一種嵌套使用。我曾經的經驗總結《踩坑經驗總結(四):死鎖》就屬於這種狀況。

最多見的死鎖例子是ABBA鎖

  • 線程1 
  • 得到鎖A
  • 試圖得到鎖B
  • 等待鎖B
  • ......
  • 線程2 
  • 得到鎖B
  • 試圖得到鎖A
  • 等待鎖A
  • ......

這種問題確實很常見,在數據庫《MySQL InnoDB技術內幕:內存管理、事務和鎖》出現的每每也是這種類型的死鎖。

三、加鎖規則

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

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

(2)防止發生飢餓。特別是在一些大循環中,儘可能將鎖移入內部,不然外面等過久。若是發生死循環,就會出現飢餓。

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

(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機器上執行得很好,但它在雙處理器機器上的表現又會怎樣呢?若是在雙處理器機器鎖爭用表現 得並不明顯,那麼多餘的鎖會加大系統開銷,形成很大的浪費。

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

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

2、同步方法

 一、原子操做

原子操做是其餘同步方法的基石。原子操做能夠保證指令以原子的方式執行——執行過程不可中斷。在數據庫事務中這也是基本的要求。

linux內核提供了兩組原子操做接口:一組對整數進行操做,一組針對單獨的位進行操做。

原子整數操做

針對整數的原子操做只能對atomic_t類型的數據進行處理,在這裏之因此引入了一個特殊的數據類型,而沒有直接使用C語言的int型,主要是出於兩個緣由:

第1、讓原子函數只接受atomic_t類型的操做數,能夠確保原子操做只與這種特殊類型數據一塊兒使用,同時,這也確保了該類型的數據不會被傳遞給其它任何非原子函數;

第2、使用atomic_t類型確保編譯器對相應的值進行訪問優化——這點使得原子操做最終接收到正確的內存地址,而不是一個別名,最後就是在不一樣體系結構上實現原子操做的時候,使用atomic_t能夠屏蔽其間的差別。

atomic_t類型定義在文件<linux/type.h>中:

typedef struct {
    volatile int counter;  
}atomic_t;

原子整數操做最多見的用途就是實現計數器

另外一點須要說明原子操做只能保證操做是原子的,要麼完成,要麼不完成,不會有操做一半的可能,但原子操做並不能保證操做的順序性,即它不能保證兩個操做是按某個順序完成的。若是要保證原子操做的順序性,請使用內存屏障指令。

原子操做與更復雜的同步方法相比較,給系統帶來的開銷小,對高速緩存行的影響也小。

原子位操做

針對位這一級數據進行操做的函數,是對普通的內存地址進行操做的。它的參數是一個指針和一個位號。

二、自旋鎖

Linux內核中最多見的鎖是自旋鎖(spin lock)。自旋鎖最多只能被一個可執行線程持有。若是一個執行線程試圖得到一個被爭用(已經被持有)的自旋鎖,那麼該線程就會一直進行忙循環—旋轉—等待鎖從新可用。要是鎖未被爭用,請求鎖的執行線程便能馬上獲得它,繼續執行。在任意時間,自旋鎖均可以防止多於一個的執行線程同時進入臨界區。同一個鎖能夠用在多個位置—例如,對於給定數據的全部訪問均可以獲得保護和同步。

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

自旋鎖的實現和體系密切相關,代碼每每經過彙編實現。實際用到的接口定義在文件中。自旋鎖的基本使用形式以下:

DEFINE  SPINLOCK(mr_lock);

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

自旋鎖能夠使用在中斷處理程序中(此處不能使用信號量,由於它們會致使睡眠),在中斷處理程序中使用自旋鎖時,必定要在獲取鎖以前,首先禁止本地中斷(在當前處理器上的中斷請求)。注意,須要關閉的只是當前處理器上的中斷,若是中斷髮生在不一樣的處理器上,即便中斷處理程序在同一鎖上自旋,也不會妨礙鎖的持有者(在不一樣處理器上)最終釋放鎖。

三、讀寫自旋鎖

有時,鎖的用途能夠明確的分爲讀取和寫入兩個場景。那麼讀寫能夠分開處理,讀時能夠共享數據,寫時進行互斥。爲此,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);

注意:若是寫和讀不能清晰地進行分離,那麼使用通常的自旋鎖就夠了,不須要使用讀寫自旋鎖。

四、信號量

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

因爲使用信號量時,線程會睡眠,因此等待的過程不會佔用 CPU 時間。因此信號量適用於等待時間較長的臨界區

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

若是(使線程睡眠 + 喚醒線程)的 CPU 時間 > 線程自旋等待 CPU 時間,那麼能夠考慮使用自旋鎖。 

 

信號量有二值信號量和計數信號量兩種,其中二值信號量比較經常使用

二值信號量表示信號量只有2個值,即0和1。信號量爲1時,表示臨界區可用,信號量爲0時,表示臨界區不可訪問。因此也能夠稱爲互斥信號量。

計數信號量有個計數值,好比計數值爲5,表示同時能夠有5個線程訪問臨界區。因此二值信號量就是計數等於1的計數信號量。

五、讀寫信號量

讀寫信號量和信號量的關係與讀寫自旋鎖和自旋鎖的關係差很少。

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

也就是說讀寫信號量保護的臨界區,最多隻有一個寫者,但能夠有多個讀者

六、互斥 

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

  1. mutex的計數值只能爲1,也就是最多隻容許一個線程訪問臨界區

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

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

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

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

  6. mutex只能經過官方API來管理,不能本身寫代碼操做它

在面對互斥體信號量的選擇時,只要知足互斥體的使用場景就儘可能優先使用互斥體

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

需求

建議的加鎖方法

低開銷加鎖 優先使用自旋鎖
短時間鎖定 優先使用自旋鎖
長期加鎖 優先使用互斥體
中斷上下文中加鎖 使用自旋鎖
持有鎖須要睡眠 使用互斥體

七、完成變量

完成變量的機制相似於信號量,好比一個線程A進入臨界區以後,另外一個線程B會在完成變量上等待,線程A完成了任務出了臨界區以後,使用完成變量來喚醒線程B。

通常在2個任務須要簡單同步的狀況下,能夠考慮使用完成變量。

八、大內核鎖

大內核鎖已經再也不使用,只存在與一些遺留的代碼中。

九、 順序鎖

順序鎖爲讀寫共享數據提供了一種簡單的實現機制。以前提到的讀寫自旋鎖和讀寫信號量,在讀鎖被獲取以後,寫鎖是不能再被獲取的,也就是說,必須等全部的讀鎖釋放後,才能對臨界區進行寫入操做。

 順序鎖則與之不一樣,讀鎖被獲取的狀況下,寫鎖仍然能夠被獲取。使用順序鎖的讀操做在讀以前和讀以後都會檢查順序鎖的序列值,若是先後值不符,則說明在讀的過程當中有寫的操做發生,那麼讀操做會從新執行一次,直至讀先後的序列值是同樣的。

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

十、禁止搶佔

其實使用自旋鎖已經能夠防止內核搶佔了,可是有時候僅僅須要禁止內核搶佔,不須要像自旋鎖那樣連中斷都屏蔽掉。

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

方法

描述

preempt_disable() 增長搶佔計數值,從而禁止內核搶佔
preempt_enable() 減小搶佔計算,並當該值降爲0時檢查和執行被掛起的需調度的任務
preempt_enable_no_resched() 激活內核搶佔但再也不檢查任何被掛起的需調度的任務
preempt_count() 返回搶佔計數

這裏的preempt_disable()和preempt_enable()是能夠嵌套調用的,disable和enable的次數最終應該是同樣的。

十一、順序和屏障

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

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

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

方法 描述
rmb 阻止跨越屏障的載入動做發生重排序
read_barrier_depends() 阻止跨越屏障的具備數據依賴關係的載入動做重排序
wmb() 阻止跨越屏障的存儲動做發生重排序
mb() 阻止跨越屏障的載入和存儲動做從新排序
smp_rmb() 在SMP上提供rmb()功能,在UP上提供barrier()功能
smp_read_barrier_depends() 在SMP上提供read_barrier_depends()功能,在UP上提供barrier()功能
smp_wmb() 在SMP上提供wmb()功能,在UP上提供barrier()功能
smp_mb 在SMP上提供mb()功能,在UP上提供barrier()功能
barrier

阻止編譯器跨越屏障對載入或存儲操做進行優化

舉例以下:

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

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

十二、總結

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

瞭解了各類同步方法的適用場景,才能正確的使用它們,使咱們的代碼在安全的保障下達到最優的性能。 

同步的目的就是爲了保障數據的安全,其實就是保障各個線程之間共享資源的安全,下面根據共享資源的狀況來討論一下10種同步方法的選擇。

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

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

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

學習內核鎖的實現,有助於咱們在程序設計中如何使用鎖,使用什麼類型的鎖以及如何設計鎖。

 

 

參考資料:

《Linux內核設計與實現》原書第三版

https://www.cnblogs.com/wang_yb/archive/2013/05/01/3052865.html

相關文章
相關標籤/搜索