淺析Linux核心同步機制(轉)
很早之前就接觸過同步這個概念了,但是一直都很模糊,沒有深入地學習瞭解過,近期有時間了,就花時間研習了一下《linux核心標準教程》和《深入linux裝置驅動程式核心機制》這兩本書的相關章節。趁剛看完,就把相關的內容總結一下。為了弄清楚什麼事同步機制,必須要弄明白以下三個問題:
- 什麼是互斥與同步?
- 為什麼需要同步機制?
- Linux核心提供哪些方法用於實現互斥與同步的機制?
1、什麼是互斥與同步?(通俗理解)
- 互斥與同步機制是計算機系統中,用於控制程序對某些特定資源的訪問的機制。
- 同步是指用於實現控制多個程序按照一定的規則或順序訪問某些系統資源的機制。
- 互斥是指用於實現控制某些系統資源在任意時刻只能允許一個程序訪問的機制。互斥是同步機制中的一種特殊情況。
- 同步機制是linux作業系統可以高效穩定執行的重要機制。
2、Linux為什麼需要同步機制?
在作業系統引入了程序概念,程序成為排程實體後,系統就具備了併發執行多個程序的能力,但也導致了系統中各個程序之間的資源競爭和共享。另外,由於中斷、異常機制的引入,以及核心態搶佔都導致了這些核心執行路徑(程序)以交錯的方式執行。對於這些交錯路徑執行的核心路徑,如不採取必要的同步措施,將會對一些關鍵資料結構進行交錯訪問和修改,從而導致這些資料結構狀態的不一致,進而導致系統崩潰。因此,為了確保系統高效穩定有序地執行,linux必須要採用同步機制。
3、Linux核心提供了哪些同步機制?
在學習linux核心同步機制之前,先要了解以下預備知識:(臨界資源與併發源)
在linux系統中,我們把對共享的資源進行訪問的程式碼片段稱為臨界區。把導致出現多個程序對同一共享資源進行訪問的原因稱為併發源。
Linux系統下併發的主要來源有:
- 中斷處理:例如,當程序在訪問某個臨界資源的時候發生了中斷,隨後進入中斷處理程式,如果在中斷處理程式中,也訪問了該臨界資源。雖然不是嚴格意義上的併發,但是也會造成了對該資源的競態。
- 核心態搶佔:例如,當程序在訪問某個臨界資源的時候發生核心態搶佔,隨後進入了高優先順序的程序,如果該程序也訪問了同一臨界資源,那麼就會造成程序與程序之間的併發。
- 多處理器的併發:多處理器系統上的程序與程序之間是嚴格意義上的併發,每個處理器都可以獨自排程執行一個程序,在同一時刻有多個程序在同時執行 。
如前所述可知:採用同步機制的目的就是避免多個程序併發併發訪問同一臨界資源。
Linux核心同步機制:
(1)禁用中斷 (單處理器不可搶佔系統)
由前面可以知道,對於單處理器不可搶佔系統來說,系統併發源主要是中斷處理。因此在進行臨界資源訪問時,進行禁用/使能中斷即可以達到消除非同步併發源的目的。Linux系統中提供了兩個巨集local_irq_enable與 local_irq_disable來使能和禁用中斷。在linux系統中,使用這兩個巨集來開關中斷的方式進行保護時,要確保處於兩者之間的程式碼執行時間不能太長,否則將影響到系統的效能。(不能及時響應外部中斷)
(2)自旋鎖
應用背景:自旋鎖的最初設計目的是在多處理器系統中提供對共享資料的保護。
自旋鎖的設計思想:在多處理器之間設定一個全域性變數V,表示鎖。並定義當V=1時為鎖定狀態,V=0時為解鎖狀態。自旋鎖同步機制是針對多處理器設計的,屬於忙等機制。自旋鎖機制只允許唯一的一個執行路徑持有自旋鎖。如果處理器A上的程式碼要進入臨界區,就先讀取V的值。如果V!=0說明是鎖定狀態,表明有其他處理器的程式碼正在對共享資料進行訪問,那麼此時處理器A進入忙等狀態(自旋);如果V=0,表明當前沒有其他處理器上的程式碼進入臨界區,此時處理器A可以訪問該臨界資源。然後把V設定為1,再進入臨界區,訪問完畢後離開臨界區時將V設定為0。
注意:必須要確保處理器A“讀取V,半段V的值與更新V”這一操作是一個原子操作。所謂的原子操作是指,一旦開始執行,就不可中斷直至執行結束。
自旋鎖的分類:
2.1、普通自旋鎖
普通自旋鎖由資料結構spinlock_t來表示,該資料結構在檔案src/include/linux/spinlock_types.h中定義。定義如下:
typedef struct { raw_spinklock_t raw_lock;
#ifdefined(CONFIG_PREEMPT) && defined(CONFIG_SMP)
unsigned int break_lock;
#endif
} spinlock_t;
成員raw_lock:該成員變數是自旋鎖資料型別的核心,它展開後實質上是一個Volatileunsigned型別的變數。具體的鎖定過程與它密切相關,該變數依賴於核心選項CONFIG_SMP。(是否支援多對稱處理器)
成員break_lock:同時依賴於核心選項CONFIG_SMP和CONFIG_PREEMPT(是否支援核心態搶佔),該成員變數用於指示當前自旋鎖是否被多個核心執行路徑同時競爭、訪問。
在單處理器系統下:CONFIG_SMP沒有選中時,變數型別raw_spinlock_t退化為一個空結構體。相應的介面函式也發生了退化。相應的加鎖函式spin_lock()和解鎖函式spin_unlock()退化為只完成禁止核心態搶佔、使能核心態搶佔。
在多處理器系統下:選中CONFIG_SMP時,核心變數raw_lock的資料型別raw_lock_t在檔案中src/include/asm-i386/spinlock_types.h中定義如下:
typedef struct { volatileunsigned int slock;} raw_spinklock_t;
從定義中可以看出該資料結構定義了一個核心變數,用於計數工作。當結構中成員變數slock的數值為1時,表示自旋鎖處於非鎖定狀態,可以使用。否則,表示處於鎖定狀態,不可以使用。
普通自旋鎖的介面函式:
spin_lock_init(lock) //宣告自旋鎖是,初始化為鎖定狀態
spin_lock(lock) //鎖定自旋鎖,成功則返回,否則迴圈等待自旋鎖變為空閒
spin_unlock(lock) //釋放自旋鎖,重新設定為未鎖定狀態
spin_is_locked(lock) //判斷當前鎖是否處於鎖定狀態。若是,返回1.
spin_trylock(lock) //嘗試鎖定自旋鎖lock,不成功則返回0,否則返回1
spin_unlock_wait(lock) //迴圈等待,直到自旋鎖lock變為可用狀態。
spin_can_lock(lock) //判斷該自旋鎖是否處於空閒狀態。
普通自旋鎖總結:自旋鎖設計用於多處理器系統。當系統是單處理器系統時,自旋鎖的加鎖、解鎖過程分為別退化為禁止核心態搶佔、使能核心態搶佔。在多處理器系統中,當鎖定一個自旋鎖時,需要首先禁止核心態搶佔,然後嘗試鎖定自旋鎖,在鎖定失敗時執行一個死迴圈等待自旋鎖被釋放;當解鎖一個自旋鎖時,首先釋放當前自旋鎖,然後使能核心態搶佔。
2.2、自旋鎖的變種
在前面討論spin_lock很好的解決了多處理器之間的併發問題。但是如果考慮如下一個應用場景:處理器上的當前程序A要對某一全域性性連結串列g_list進行操作,所以在操作前呼叫了spin_lock獲取鎖,然後再進入臨界區。如果在臨界區程式碼當中,程序A所在的處理器上發生了一個外部硬體中斷,那麼這個時候系統必須暫停當前程序A的執行轉入到中斷處理程式當中。假如中斷處理程式當中也要操作g_list,由於它是共享資源,在操作前必須要獲取到鎖才能進行訪問。因此當中斷處理程式試圖呼叫spin_lock獲取鎖時,由於該鎖已經被程序A持有,中斷處理程式將會進入忙等狀態(自旋)。從而就會出現大問題了:中斷程式由於無法獲得鎖,處於忙等(自旋)狀態無法返回;由於中斷處理程式無法返回,程序A也處於沒有執行完的狀態,不會釋放鎖。因此這樣導致了系統的死鎖。即spin_lock對存在中斷源的情況是存在缺陷的,因此引入了它的變種。
spin_lock_irq(lock)
spin_unlock_irq(lock)
相比於前面的普通自旋鎖,它在上鎖前增加了禁用中斷的功能,在解鎖後,使能了中斷。
2.3、讀寫自旋鎖rwlock
應用背景:前面說的普通自旋鎖spin_lock類的函式在進入臨界區時,對臨界區中的操作行為不細分。只要是訪問共享資源,就執行加鎖操作。但是有時候,比如某些臨界區的程式碼只是去讀這些共享的資料,並不會改寫,如果採用spin_lock()函式,就意味著,任意時刻只能有一個程序可以讀取這些共享資料。如果系統中有大量對這些共享資源的讀操作,很明顯spin_lock將會降低系統的效能。因此提出了讀寫自旋鎖rwlock的概念。對照普通自旋鎖,讀寫自旋鎖允許多個讀者程序同時進入臨界區,交錯訪問同一個臨界資源,提高了系統的併發能力,提升了系統的吞吐量。
讀寫自旋鎖有資料結構rwlock_t來表示。定義在…/spinlock_types.h中
讀寫自旋鎖的介面函式:
DEFINE_RWLOCK(lock) //宣告讀寫自旋鎖lock,並初始化為未鎖定狀態
write_lock(lock) //以寫方式鎖定,若成功則返回,否則迴圈等待
write_unlock(lock) //解除寫方式的鎖定,重設為未鎖定狀態
read_lock(lock) //以讀方式鎖定,若成功則返回,否則迴圈等待
read_unlock(lock) //解除讀方式的鎖定,重設為未鎖定狀態
讀寫自旋鎖的工作原理:
對於讀寫自旋鎖rwlock,它允許任意數量的讀取者同時進入臨界區,但寫入者必須進行互斥訪問。一個程序要進行讀,必須要先檢查是否有程序正在寫入,如果有,則自旋(忙等),否則獲得鎖。一個程序要程序寫,必須要先檢查是否有程序正在讀取或者寫入,如果有,則自旋(忙等)否則獲得鎖。即讀寫自旋鎖的應用規則如下:
(1)如果當前有程序正在寫,那麼其他程序就不能讀也不能寫。
(2)如果當前有程序正在讀,那麼其他程式可以讀,但是不能寫。
2.4、順序自旋鎖seqlock
應用背景:順序自旋鎖主要用於解決自旋鎖同步機制中,在擁有大量讀者程序時,寫程序由於長時間無法持有鎖而被餓死的情況,其主要思想是:為寫程序提高更高的優先順序,在寫鎖定請求出現時,立即滿足寫鎖定的請求,無論此時是否有讀程序正在訪問臨界資源。但是新的寫鎖定請求不會,也不能搶佔已有寫程序的寫鎖定。
順序鎖的設計思想:對某一共享資料讀取時不加鎖,寫的時候加鎖。為了保證讀取的過程中不會因為寫入者的出現導致該共享資料的更新,需要在讀取者和寫入者之間引入一個整形變數,稱為順序值sequence。讀取者在開始讀取前讀取該sequence,在讀取後再重新讀取該值,如果與之前讀取到的值不一致,則說明本次讀取操作過程中發生了資料更新,讀取操作無效。因此要求寫入者在開始寫入的時候更新。
順序自旋鎖由資料結構seqlock_t表示,定義在src/include/linux/seqlcok.h
順序自旋鎖訪問介面函式:
seqlock_init(seqlock) //初始化為未鎖定狀態
read_seqbgin()、read_seqretry() //保證資料的一致性
write_seqlock(lock) //嘗試以寫鎖定方式鎖定順序鎖
write_sequnlock(lock) //解除對順序鎖的寫方式鎖定,重設為未鎖定狀態。
順序自旋鎖的工作原理:寫程序不會被讀程序阻塞,也就是,寫程序對被順序自旋鎖保護的臨界資源進行訪問時,立即鎖定並完成更新工作,而不必等待讀程序完成讀訪問。但是寫程序與寫程序之間仍是互斥的,如果有寫程序在進行寫操作,其他寫程序必須迴圈等待,直到前一個寫程序釋放了自旋鎖。順序自旋鎖要求被保護的共享資源不包含有指標,因為寫程序可能使得指標失效,如果讀程序正要訪問該指標,將會出錯。同時,如果讀者在讀操作期間,寫程序已經發生了寫操作,那麼讀者必須重新讀取資料,以便確保得到的資料是完整的。
(3)訊號量機制(semaphore)
應用背景:前面介紹的自旋鎖同步機制是一種“忙等”機制,在臨界資源被鎖定的時間很短的情況下很有效。但是在臨界資源被持有時間很長或者不確定的情況下,忙等機制則會浪費很多寶貴的處理器時間。針對這種情況,linux核心中提供了訊號量機制,此型別的同步機制在程序無法獲取到臨界資源的情況下,立即釋放處理器的使用權,並睡眠在所訪問的臨界資源上對應的等待佇列上;在臨界資源被釋放時,再喚醒阻塞在該臨界資源上的程序。另外,訊號量機制不會禁用核心態搶佔,所以持有訊號量的程序一樣可以被搶佔,這意味著訊號量機制不會給系統的響應能力,實時能力帶來負面的影響。
訊號量設計思想:除了初始化之外,訊號量只能通過兩個原子操作P()和V()訪問,也稱為down()和up()。down()原子操作通過對訊號量的計數器減1,來請求獲得一個訊號量。如果操作後結果是0或者大於0,獲得訊號量鎖,任務就可以進入臨界區。如果操作後結果是負數,任務會放入等待佇列,處理器執行其他任務;對臨界資源訪問完畢後,可以呼叫原子操作up()來釋放訊號量,該操作會增加訊號量的計數器。如果該訊號量上的等待佇列不為空,則喚醒阻塞在該訊號量上的程序。
訊號量的分類:
3.1、普通訊號量
普通訊號量由資料結構struct semaphore來表示,定義在src/inlcude/ asm-i386/semaphore.h中.
訊號量(semaphore)定義如下:
<include/linux/semaphore.h>
struct semaphore{
spinlock_t lock; //自旋鎖,用於實現對count的原子操作
unsigned int count; //表示通過該訊號量允許進入臨界區的執行路徑的個數
struct list_head wait_list; //用於管理睡眠在該訊號量上的程序
};
普通訊號量的介面函式:
sema_init(sem,val) //初始化訊號量計數器的值為val
int_MUTEX(sem) //初始化訊號量為一個互斥訊號量
down(sem) //鎖定訊號量,若不成功,則睡眠在等待佇列上
up(sem) //釋放訊號量,並喚醒等待佇列上的程序
DOWN操作:linux核心中,對訊號量的DOWN操作有如下幾種:
void down(struct semaphore *sem); //不可中斷
int down_interruptible(struct semaphore *sem);//可中斷
int down_killable(struct semaphore *sem);//睡眠的程序可以因為受到致命訊號而被喚醒,中斷獲取訊號量的操作。
int down_trylock(struct semaphore *sem);//試圖獲取訊號量,若無法獲得則直接返回1而不睡眠。返回0則 表示獲取到了訊號量
int down_timeout(struct semaphore *sem,long jiffies);//表示睡眠時間是有限制的,如果在jiffies指明的時間到期時仍然無法獲得訊號量,則將返回錯誤碼。
在以上四種函式中,驅動程式使用的最頻繁的就是down_interruptible函式
UP操作:LINUX核心只提供了一個up函式
void up(struct semaphore *sem)
加鎖處理過程:加鎖過程由函式down()完成,該函式負責測試訊號量的狀態,在訊號量可用的情況下,獲取該訊號量的使用權,否則將當前程序插入到當前訊號量對應的等待佇列中。函式呼叫關係如下:down()->__down_failed()->__down.函式說明如下:
down()功能介紹:該函式用於對訊號量sem進行加鎖,在加鎖成功即獲得訊號的使用權是,直接退出,否則,呼叫函式__down_failed()睡眠到訊號量sem的等待佇列上。__down()功能介紹:該函式在加鎖失敗時被呼叫,負責將程序插入到訊號量 sem的等待佇列中,然後呼叫排程器,釋放處理器的使用權。
解鎖處理過程:普通訊號量的解鎖過程由函式up()完成,該函式負責將訊號計數器count的值增加1,表示訊號量被釋放,在有程序阻塞在該訊號量的情況下,喚醒等待佇列中的睡眠程序。
3.2讀寫訊號量(rwsem)
應用背景:為了提高核心併發執行能力,核心提供了讀入者訊號量和寫入者訊號量。它們的概念和實現機制類似於讀寫自旋鎖。
工作原理:該訊號量機制使得所有的讀程序可以同時訪問訊號量保護的臨界資源。當程序嘗試鎖定讀寫訊號量不成功時,則這些程序被插入到一個先進先出的佇列中;當一個程序訪問完臨界資源,釋放對應的讀寫訊號量是,該程序負責將該佇列中的程序按一定的規則喚醒。
喚醒規則:喚醒排在該先進先出佇列中隊首的程序,在被喚醒程序為寫程序的情況下,不再喚醒其他程序;在喚醒程序為讀程序的情況下,喚醒其他的讀程序,直到遇到一個寫程序(該寫程序不被喚醒)
讀寫訊號量的定義如下:
<include/linux/rwsem-spinlock.h>
sturct rw_semaphore{
__s32 activity; //用於表示讀者或寫者的數量
spinlock_t wait_lock;
struct list_head wait_list;
};
讀寫訊號量相應的介面函式
讀者up、down操作函式:
void up_read(Sturct rw_semaphore *sem);
void __sched down_read(Sturct rw_semaphore *sem);
Int down_read_trylock(Sturct rw_semaphore *sem);
寫入者up、down操作函式:
void up_write(Sturct rw_semaphore *sem);
void __sched down_write(Sturct rw_semaphore *sem);
int down_write_trylock(Sturct rw_semaphore *sem);
3.3、互斥訊號量
在linux系統中,訊號量的一個常見的用途是實現互斥機制,這種情況下,訊號量的count值為1,也就是任意時刻只允許一個程序進入臨界區。為此,linux核心原始碼提供了一個巨集DECLARE_MUTEX,專門用於這種用途的訊號量定義和初始化
<include/linux/semaphore.h>
#define DECLARE_MUTEX(name) \
structsemaphore name=__SEMAPHORE_INITIALIZER(name,1)
(4)互斥鎖mutex
Linux核心針對count=1的訊號量重新定義了一個新的資料結構struct mutex,一般都稱為互斥鎖。核心根據使用場景的不同,把用於訊號量的down和up操作在struct mutex上做了優化與擴充套件,專門用於這種新的資料型別。
(5)RCU
RCU概念:RCU全稱是Read-Copy-Update(讀/寫-複製-更新),是linux核心中提供的一種免鎖的同步機制。RCU與前面討論過的讀寫自旋鎖rwlock,讀寫訊號量rwsem,順序鎖一樣,它也適用於讀取者、寫入者共存的系統。但是不同的是,RCU中的讀取和寫入操作無須考慮兩者之間的互斥問題。但是寫入者之間的互斥還是要考慮的。
RCU原理:簡單地說,是將讀取者和寫入者要訪問的共享資料放在一個指標p中,讀取者通過p來訪問其中的資料,而讀取者則通過修改p來更新資料。要實現免鎖,讀寫雙方必須要遵守一定的規則。
讀取者的操作(RCU臨界區)
對於讀取者來說,如果要訪問共享資料。首先要呼叫rcu_read_lock和rcu_read_unlock函式構建讀者側的臨界區(read-side critical section),然後再臨界區中獲得指向共享資料區的指標,實際的讀取操作就是對該指標的引用。
讀取者要遵守的規則是:(1)對指標的引用必須要在臨界區中完成,離開臨界區之後不應該出現任何形式的對該指標的引用。(2)在臨界區內的程式碼不應該導致任何形式的程序切換(一般要關掉核心搶佔,中斷可以不關)。
寫入者的操作
對於寫入者來說,要寫入資料,首先要重新分配一個新的記憶體空間做作為共享資料區。然後將老資料區內的資料複製到新資料區,並根據需要修改新資料區,最後用新資料區指標替換掉老資料區的指標。寫入者在替換掉共享區的指標後,老指標指向的共享資料區所在的空間還不能馬上釋放(原因後面再說明)。寫入者需要和核心共同協作,在確定所有對老指標的引用都結束後才可以釋放老指標指向的記憶體空間。為此,寫入者要做的操作是呼叫call_rcu函式向核心註冊一個回撥函式,核心在確定所有對老指標的引用都結束時會呼叫該回調函式,回撥函式的功能主要是釋放老指標指向的記憶體空間。Call_rcu函式的原型如下:
Void call_rcu(struct rcu_head *head,void (*func)(struct rcu_head *rcu));
核心確定沒有讀取者對老指標的引用是基於以下條件的:系統中所有處理器上都至少發生了一次程序切換。因為所有可能對共享資料區指標的不一致引用一定是發生在讀取者的RCU臨界區,而且臨界區一定不能發生程序切換。所以如果在CPU上發生了一次程序切換切換,那麼所有對老指標的引用都會結束,之後讀取者再進入RCU臨界區看到的都將是新指標。
老指標不能馬上釋放的原因:這是因為系統中愛可能存在對老指標的引用,者主要發生在以下兩種情況:(1)一是在單處理器範圍看,假設讀取者在進入RCU臨界區後,剛獲得共享區的指標之後發生了一箇中斷,如果寫入者恰好是中斷處理函式中的行為,那麼當中斷返回後,被中斷程序RCU臨界區中繼續執行時,將會繼續引用老指標。(2)另一個可能是在多處理器系統,當處理器A上的一個讀取者進入RCU臨界區並獲得共享資料區中的指標後,在其還沒來得及引用該指標時,處理器B上的一個寫入者更新了指向共享資料區的指標,這樣處理器A上的讀取者也餓將引用到老指標。
RCU特點:由前面的討論可以知道,RCU實質上是對讀取者與寫入者自旋鎖rwlock的一種優化。RCU的可以讓多個讀取者和寫入者同時工作。但是RCU的寫入者操作開銷就比較大。在驅動程式中一般比較少用。
為了在程式碼中使用RCU,所有RCU相關的操作都應該使用核心提供的RCU API函式,以確保RCU機制的正確使用,這些API主要集中在指標和連結串列的操作。
下面是一個RCU的典型用法範例:
假設struct shared_data是一個在讀取者和寫入者之間共享的受保護資料
Struct shared_data{
Int a;
Int b;
Struct rcu_head rcu;
};
//讀取者側的程式碼
Static void demo_reader(struct shared_data *ptr)
{
Struct shared_data *p=NULL;
Rcu_read_lock();
P=rcu_dereference(ptr);
If(p)
Do_something_withp(p);
Rcu_read_unlock();
}
//寫入者側的程式碼
Static void demo_del_oldptr(struct rcu_head *rh) //回撥函式
{
Struct shared_data *p=container_of(rh,struct shared_data,rcu);
Kfree(p);
}
Static void demo_writer(struct shared_data *ptr)
{
Struct shared_data *new_ptr=kmalloc(…);
…
New_ptr->a=10;
New_ptr->b=20;
Rcu_assign_pointer(ptr,new_ptr);//用新指標更新老指標
Call_rcu(ptr->rcu,demo_del_oldptr); 向核心註冊回撥函式,用於刪除老指標指向的記憶體空間
}
(6)完成介面completion
Linux核心還提供了一個被稱為“完成介面completion”的同步機制,該機制被用來在多個執行路徑間作同步使用,也即協調多個執行路徑的執行順序。在此就不展開了。