Linux核心設計與實現——核心同步,linux核心實現同步
核心同步
同步介紹
同步的概念
臨界區:也稱為臨界段,就是訪問和操作共用資料的程式碼片段。
競爭條件: 2個或2個以上線程在臨界區裡同時執行的時候,就構成了競爭條件。
所謂同步,其實防止在臨界區中形成競爭條件。
如果臨界區裡是原子操作(即整個操作完成前不會被打斷),那麼自然就不會出競爭條件。但在實際應用中,臨界區中的代碼往往不會那麼簡單,所以為了保持同步,引入了鎖機制。但又會產生一些關於鎖的問題。
死結產生的條件:要有一個或多個執行線程和一個或多個資源,每個線程都在等待其中的一個資源,但所有資源都已被佔用。所以線程相互等待,但它們永遠不會釋放已經佔有的資源。於是任何線程都無法繼續,死結發生。
自死結:如果一個執行線程試圖去獲得一個自己已經持有的鎖,它不得不等待鎖被釋放。但因為它正在忙著等待這個鎖,所以自己永遠也不會有機會釋放鎖,死結產生。
饑餓(starvation) 是一個線程長時間得不到需要的資源而不能執行的現象。
造成並發的原因
中斷——中斷幾乎可以在任何時刻非同步發生,也就是可能隨時打斷當前正在啟動並執行代碼。
非強制中斷和tasklet ——核心能在任何時刻喚醒或調度中斷和tasklet,打斷當前正在執行的代碼。
核心搶佔——因為核心具有搶佔性,所以核心中的任務可能會被另一任務搶佔。
睡眠及使用者空間的同步——在核心執行的進程可能會睡眠,這就會喚醒發送器從而導致調度一個新的使用者進程執行。
對稱式多處理——兩個或多個處理器可以同時執行代碼。
避免死結的簡單規則
加鎖的順序是關鍵。使用嵌套的鎖時必須保證以相同的順序擷取鎖,這樣可以阻止致命擁抱類型的死結。最好能記錄下鎖的順序,以便其他人能照此順序使用。
防止發生饑餓。判斷這個代碼的執行是否會結束。如果A不發生,B要一直等待下去嗎?
不要重複請求同一個鎖。
越複雜的加鎖方案越可能造成死結。---設計應力求簡單。
鎖的粒度
加鎖的粒度用來描述加鎖保護的資料規模。一個過粗的鎖保護大塊資料,比如一個子系統的所有資料結構;一個過細的鎖保護小塊資料,比如一個大資料結構中的一個元素。
在加鎖的時候,不僅要避免死結,還需要考慮加鎖的粒度。
鎖的粒度對系統的可擴充性有很大影響,在加鎖的時候,要考慮一下這個鎖是否會被多個線程頻繁的爭用。
如果鎖有可能會被頻繁爭用,就需要將鎖的粒度細化。
細化後的鎖在多處理器的情況下,效能會有所提升。
同步方法
原子操作
原子操作指的是在執行過程中不會被別的代碼路徑所中斷的操作,核心代碼可以安全的調用它們而不被打斷。
原子操作分為整型原子操作和位原子操作。
spinlock自旋鎖
自旋鎖的特點就是當一個線程擷取了鎖之後,其他試圖擷取這個鎖的線程一直在迴圈等待擷取這個鎖,直至鎖重新可用。
由於線程實在一直迴圈的擷取這個鎖,所以會造成CPU處理時間的浪費,因此最好將自旋鎖用於能很快處理完的臨界區。
自旋鎖使用時有2點需要注意:
1.自旋鎖是不可遞迴的,遞迴的請求同一個自旋鎖會自己鎖死自己。
2.線程擷取自旋鎖之前,要禁止當前處理器上的中斷。(防止擷取鎖的線程和中斷形成競爭條件)比如:當前線程擷取自旋鎖後,在臨界區中被中斷處理常式打斷,中斷處理常式正好也要擷取這個鎖,於是中斷處理常式會等待當前線程釋放鎖,而當前線程也在等待中斷執行完後再執行臨界區和釋放鎖的代碼。
中斷處理下半部的操作中使用自旋鎖尤其需要小心:
1. 下半部處理和進程上下文共用資料時,由於下半部的處理可以搶佔進程內容相關的代碼,所以進程上下文在對共用資料加鎖前要禁止下半部的執行,解鎖時再允許下半部的執行。
2. 中斷處理常式(上半部)和下半部處理共用資料時,由於中斷處理(上半部)可以搶佔下半部的執行,所以下半部在對共用資料加鎖前要禁止中斷處理(上半部),解鎖時再允許中斷的執行。
3. 同一種tasklet不能同時運行,所以同類tasklet中的共用資料不需要保護。
4. 不同類tasklet中共用資料時,其中一個tasklet獲得鎖後,不用禁止其他tasklet的執行,因為同一個處理器上不會有tasklet相互搶佔的情況
5. 同類型或者非同類型的非強制中斷在共用資料時,也不用禁止下半部,因為同一個處理器上不會有非強制中斷互相搶佔的情況
讀-寫自旋鎖
如果臨界區保護的資料是可讀可寫的,那麼只要沒有寫操作,對於讀是可以支援並行作業的。對於這種只要求寫操作是互斥的需求,如果還是使用自旋鎖顯然是無法滿足這個要求(對於讀操作實在是太浪費了)。為此核心提供了另一種鎖-讀寫自旋鎖,讀自旋鎖也叫共用自旋鎖,寫自旋鎖也叫排他自旋鎖。
讀寫自旋鎖是一種比自旋鎖粒度更小的鎖機制,它保留了“自旋”的概念,但是在寫操作方面,只能最多有一個寫進程,在讀操作方面,同時可以有多個讀執行單元,當然,讀和寫也不能同時進行。
自旋鎖提供了一種快速簡單的所得實現方法。如果加鎖時間不長並且代碼不會睡眠,利用自旋鎖是最佳選擇。如果加鎖時間可能很長或者代碼在持有鎖時有可能睡眠,那麼最好使用訊號量來完成加鎖功能。
訊號量
Linux中的訊號量是一種睡眠鎖,如果有一個任務試圖獲得一個已經被佔用的訊號量時,訊號量會將其推進一個等待隊列,然後讓其睡眠,這時處理器能重獲自由,從而去執行其它代碼,當持有訊號量的進程將訊號量釋放後,處於等待隊列中的哪個任務被喚醒,並獲得該訊號量。
1)由於爭用訊號量的過程在等待鎖重新變為可用時會睡眠,所以訊號量適用於鎖會被長時間持有的情況;相反,鎖被短時間持有時,使用訊號量就不太適宜了。因為睡眠、維護等待隊列以及喚醒所花費的開銷可能比鎖被佔用的全部時間還要長。
2)由於執行線程在鎖被爭用時會睡眠,所以只能在進程上下文中才能擷取訊號量鎖,因為中斷上下文中是不能進行調度的。
3)你可以在持有訊號量時去睡眠,因為當其他進程試圖獲得同一訊號量時不會因此而死結(因為該進程也只是去睡眠而已,最終會繼續執行的)。
4)在你佔用訊號量的同時不能佔用自旋鎖。因為在你等待訊號量時可能會睡眠,而在持有自旋鎖時是不允許睡眠的。
5)訊號量同時允許任意數量的鎖持有人,而自旋鎖在一個時刻最多允許一個任務持有它。原因是訊號量有個計數值,比如計數值為5,表示同時可以有5個線程訪問臨界區。如果訊號量的初始值始1,這訊號量就是互斥訊號量(MUTEX)。對於大於1的非0值訊號量,也可稱為計數訊號量(counting semaphore)。對於一般的驅動程式使用的訊號量都是互斥訊號量。
訊號量支援兩個原子操作:P/V原語操作(也有叫做down操作和up操作的):
P:如果訊號量值大於0,則遞減訊號量的值,程式繼續執行,否則,睡眠等待訊號量大於0。
V:遞增訊號量的值,如果遞增的訊號量的值大於0,則喚醒等待的進程。
down操作有兩個版本,分別對於睡眠可中斷和睡眠不可中斷。
讀-寫訊號量
讀寫訊號量和訊號量之間的關係 與 讀寫自旋鎖和普通自旋鎖之間的關係 差不多。
讀寫訊號量都是二值訊號量,即計數值最大為1,增加讀者時,計數器不變,增加寫者,計數器才減一。也就是說讀寫訊號量保護的臨界區,最多隻有一個寫者,但可以有多個讀者。
所有讀-寫鎖的睡眠都不會被訊號打斷,所以它只有一個版本的down操作。
瞭解何時使用自旋鎖和訊號量對編寫優良代碼很重要,但是多數情況下,並不需要太多考慮,因為在中斷上下文只能使用自旋鎖,而在任務睡眠時只能使用訊號量。
完成變數 |
建議的加鎖方法 |
低開銷加鎖 |
優先使用自旋鎖 |
短期加鎖 |
優先使用自旋鎖 |
長期加鎖 |
優先使用訊號量 |
中斷上下文加鎖 |
使用自旋鎖 |
持有鎖需要睡眠 |
使用訊號量 |
完成變數
如果在核心中一個任務需要發出訊號通知另一任務發生了某個特定事件,利用完成變數(completion variable)是使兩個任務得以同步的簡單方法。如果一個任務要執行一些工作時,另一個任務就會在完成變數上等待。當這個任務完成工作後,會使用完成變數去喚醒在等待的任務。例如,當子進程執行或者退出時,vfork()系統調用使用完成變數喚醒父進程。
Seq鎖(順序鎖)
這種鎖提供了一種很簡單的機制,用於讀寫共用資料。實現這種鎖主要依靠一個序列計數器。當有疑義的資料被寫入時,會得到一個鎖,並且序列值會增加。在讀取資料之前和之後,序號都被讀取。如果讀取的序號值相同,說明在讀操作進行的過程中沒有被寫操作打斷過。此外,如果讀取的值是偶數,那麼就表明寫操作沒有發生(要明白因為鎖的初值是0,所以寫鎖會使值成奇數,釋放的時候變成偶數)。
在多個讀者和少數寫者共用一把鎖的時候,seq鎖有助於提供一種非常輕量級和具有可擴充性的外觀。但是 seq 鎖對寫者更有利,只要沒有其他寫者,寫鎖總是能夠被成功獲得。掛起的寫者會不斷地使得讀操作迴圈(前一個例子),直到不再有任何寫者持有鎖為止。
禁止搶佔
由於核心是搶佔性的,核心中的進程在任何時刻都可能停下來以便另一個具有更高優先權的進程運行。這意味著一個任務與被搶佔的任務可能會在同一個臨界區內運行。為了避免這種情況,核心搶佔代碼使用自旋鎖作(可以防止多處理器機器上的真並發和核心搶佔)為非搶佔地區的標記。如果一個自旋鎖被持有,核心便不能進行搶佔。
實際中,某些情況(不需要模擬多處理器機器上的真並發,但需要防止核心搶佔)並不需要自旋鎖,但是仍然需要關閉核心搶佔。為瞭解決這個問題,可以通過 preempt_disable 禁止核心搶佔。這是一個可以嵌套調用的函數,可以調用任意次。每次調用都必須有一個相應的 preempt_enable 調用。當最後一次 preempt_enable 被調用後,核心搶佔才重新佔用。
順序和屏障
對於一段代碼,編譯器或者處理器在編譯和執行時可能會對執行順序進行一些最佳化,從而使得代碼的執行順序和我們寫的代碼有些區別。
一般情況下,這沒有什麼問題,但是在並發條件下,可能會出現取得的值與預期不一致的情況,比如下面的代碼:
/* * 線程A和線程B共用的變數 a和b * 初始值 a=1, b=2 */int a = 1, b = 2;/* * 假設線程A 中對 a和b的操作 */void Thread_A(){ a = 5; b = 4;}/* * 假設線程B 中對 a和b的操作 */void Thread_B(){ if (b == 4) printf("a = %d\n", a);}
由於編譯器或者處理器的最佳化,線程A中的賦值順序可能是b先賦值後,a才被賦值。
所以如果線程A中 b=4; 執行完,a=5; 還沒有執行的時候,線程B開始執行,那麼線程B列印的是a的初始值1。
這就與我們預期的不一致了,我們預期的是a在b之前賦值,所以線程B要麼不列印內容,如果列印的話,a的值應該是5。
在某些並發情況下,為了保證代碼的執行順序,引入了一系列屏障方法來阻止編譯器和處理器的最佳化。
方法 |
描述 |
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() |
阻止編譯器跨越屏障對載入或儲存操作進行最佳化 |
為了使得上面的小例子能正確執行,用上表中的函數修改線程A的函數即可:
/* * 假設線程A 中對 a和b的操作 */void Thread_A(){ a = 5; mb(); /* * mb()保證在對b進行載入和儲存值(值就是4)的操作之前 * mb()代碼之前的所有載入和儲存值的操作全部完成(即 a = 5;已經完成) * 只要保證a的賦值在b的賦值之前進行,那麼線程B的執行結果就和預期一樣 */ b = 4;}
總結:
來自http://www.cnblogs.com/wang_yb/archive/2013/05/01/3052865.html
參考:
http://www.cnblogs.com/wang_yb/archive/2013/05/01/3052865.html
http://www.cnblogs.com/pennant/archive/2012/12/28/2833383.html
Linux核心設計與實現
Linux核心設計與實現 深入理解linux核心 哪個好
都是好書啊,我都有,linux核心設計比較理論,講述作業系統的一些基本概念但結合linux這個特定的OS,從總體上把握linux核心的設計思想,而深入理解Linux核心則比較具體的講解核心的設計實現引用的代碼比較多,比較細,比較雜,要細細分析。都挺不錯,如何你比較入門,可以先看linux核心設計,但要結合程式碼分析,不然也很難入門,深入理解比較難,如果有一定水平,可以入手。
深入理解Linux核心與linux核心設計與實現那本好
兩本都不錯,好好學習吧,這兩本的話最好是看英文版的,當然你的英語能力要不錯!!!