淺析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,一般都稱爲互斥鎖。內核根據使用場景的不同,把用於信號量的downup操作在struct mutex上做了優化與擴展,專門用於這種新的數據類型。

5RCU

RCU概念:RCU全稱是Read-Copy-Update(/-複製-更新),linux內核中提供的一種免鎖的同步機制。RCU與前面討論過的讀寫自旋鎖rwlock,讀寫信號量rwsem,順序鎖一樣,它也適用於讀取者、寫入者共存的系統。但是不同的是,RCU中的讀取和寫入操作無須考慮兩者之間的互斥問題。但是寫入者之間的互斥還是要考慮的。

RCU原理:簡單地說,是將讀取者和寫入者要訪問的共享數據放在一個指針p中,讀取者通過p來訪問其中的數據,而讀取者則通過修改p來更新數據。要實現免鎖,讀寫雙方必須要遵守一定的規則。

讀取者的操作(RCU臨界區)

對於讀取者來說,如果要訪問共享數據。首先要調用rcu_read_lockrcu_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的典型用法範例:

<span style="font-size:14px;">//<span style="font-size:14px;">假設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); 向內核註冊回調函數,用於刪除老指針指向的內存空間

}

</span></span>

6)完成接口completion

Linux內核還提供了一個被稱爲“完成接口completion”的同步機制,該機制被用來在多個執行路徑間作同步使用,也即協調多個執行路徑的執行順序。在此就不展開了。

 

 

 

 

 

   

發佈了18 篇原創文章 · 獲贊 8 · 訪問量 4萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章