面試官讓你你講講Linux內核競爭的與併發,你該如何回答?

工科生一枚,熱衷於底層技術開發,有強烈的好奇心,感興趣內容包括單片機,嵌入式Linux,Uboot等,歡迎學習交流!
愛好跑步,打籃球,睡覺。
歡迎加我QQ1500836631(備註CSDN),一起學習交流問題,分享各種學習資料,電子書籍,學習視頻等。

內核中的併發和競爭簡介

  在早期的 Linux內核中,併發的來源相對較少。早期內核不支持對稱多處理( symmetric multi processing,SMP),因此,導致併發執行的唯一原因是對硬件中斷的服務。這種情況處理起來較爲簡單,但並不適用於爲獲得更好的性能而使用更多處理器且強調快速響應事件的系統。

  爲了響應現代硬件和應用程序的需求, Linux內核已經發展到同時處理更多事情的時代。Linux系統是個多任務操作系統,會存在多個任務同時訪問同一片內存區域的情況,這些任務可能會相互覆蓋這段內存中的數據,造成內存數據混亂。針對這個問題必須要做處理,嚴重的話可能會導致系統崩潰。現在的 Linux系統併發產生的原因很複雜,總結一下有下面幾個主要原因:

  1. 多線程併發訪問, Linux是多任務(線程)的系統,所以多線程訪問是最基本的原因。
  2. 搶佔式併發訪問,內核代碼是可搶佔的,因此,我們的驅動程序代碼可能在任何時候丟失對處理器的獨佔
  3. 中斷程序併發訪問,設備中斷是異步事件,也會導致代碼的併發執行。
  4. SMP(多核)核間併發訪問,現在ARM架構的多核SOC很常見,多核CPU存在覈間併發訪問。正在運行的多個用戶空間進程可能以一種令人驚訝的組合方式訪問我們的代碼,SMP系統甚至可在不同的處理器上同時執行我們的代碼。

  只要我們的程序在運轉當中,就有可能發生併發和競爭。比如,當兩個執行線程需要訪問相同的數據結構(或硬件資源)時,混合的可能性就永遠存在。因此在設計自己的驅動程序時,就應該避免資源的共享。如果沒有併發的訪問,也就不會有競態的產生。因此,仔細編寫的內核代碼應該具有最少的共享。這種思想的最明顯應用就是避免使用全局變量。如果我們將資源放在多個執行線程都會找到的地方(臨界區),則必須有足夠的理由。

  如何防止我們的數據被併發訪問呢?這個時候就要建立一種保護機制,下面介紹幾種內核提供的幾種併發和競爭的處理方法。

原子操作

原子操作簡介

  原子,在早接觸到是在化學概念中。原子指化學反應不可再分的基本微粒。同樣的,在內核中所說的原子操作表示這一個訪問是一個步驟,必須一次性執行完,不能被打斷,不能再進行拆分。
  例如,在多線程訪問中,我們的線程一對a進行賦值操作,a=1,線程二也對a進行賦值操作a=2,我們理想的執行順序是線程一先執行,線程二再執行。但是很有可能在線程一執行的時候被其他操作打斷,使得線程一最後的執行結果變爲a=2。要解決這個問題,必須保證我們的線程一在對數據訪問的過程中不能被其他的操作打斷,一次性執行完成。

整型原子操作函數

函數 描述
ATOMIC_INIT(int i) 定義原子變量的時候對其初始化。
int atomic_read(atomic_t*v) 讀取 v的值,並且返回
void atomic_set(atomic_t *v, int i) 向 v寫入 i值。
void atomic_add(int i, atomic_t *v) 給 v加上 i值。
void atomic_sub(int i, atomic_t *v) 從 v減去 i值。
void atomic_inc(atomic_t *v) 給 v加 1,也就是自增。
void atomic_dec(atomic_t *v) 從 v減 1,也就是自減 。
int atomic_dec_return(atomic_t *v) 從 v減 1,並且返回v的值 。
int atomic_inc_return(atomic_t *v) 給 v加 1,並且返回 v的值。
int atomic_sub_and_test(int i, atomic_t *v) 從 v減 i,如果結果爲0就返回真,否則就返回假
int atomic_dec_and_test(atomic_t *v) 從 v減 1,如果結果爲0就返回真,否則就返回假
int atomic_inc_and_test(atomic_t *v) 給 v加 1,如果結果爲0就返回真,否則就返回假
int atomic_add_negative(int i, atomic_t *v) 給 v加 i,如果結果爲負就返回真,否則返回假

注:64位的整型原子操作只是將“atomic_”前綴換成“atomic64_”,將int換成long long。

位原子操作函數

函數 描述
void set_bit(int nr, void *p) 將p地址的nr位置1
void clear_bit(int nr,void *p) 將p地址的nr位清零
void change_bit(int nr, void *p) 將p地址的nr位反轉
int test_bit(int nr, void *p) 獲取p地址的nr位的值
int test_and_set_bit(int nr, void *p) 將p地址的nr位置1,並且返回nr位原來的值
int test_and_clear_bit(int nr, void *p) 將p地址的nr位清0,並且返回nr位原來的值
int test_and_change_bit(int nr, void *p) 將p地址的nr位翻轉,並且返回nr位原來的值

原子操作例程

/* 定義原子變量,初值爲1*/
static atomic_t xxx_available = ATOMIC_INIT(1); 
static int xxx_open(struct inode *inode, struct file *filp)
{
 ...
 /* 通過判斷原子變量的值來檢查LED有沒有被別的應用使用 */
 if (!atomic_dec_and_test(&xxx_available)) {
 /*小於0的話就加1,使其原子變量等於0*/
 atomic_inc(&xxx_available);
 /* LED被使用,返回忙*/
 return - EBUSY; 
 }
...
/* 成功 */
 return 0;
static int xxx_release(struct inode *inode, struct file *filp)
{
 /* 關閉驅動文件的時候釋放原子變量 */
 atomic_inc(&xxx_available); 
 return 0;
}

自旋鎖

  上面我們介紹了原子變量,從它的操作函數可以看出,原子操作只能針對整型變量或者位。假如我們有一個結構體變量需要被線程A所訪問,在線程A訪問期間不能被其他線程訪問,這怎麼辦呢?自旋鎖就可以完成對結構體變量的保護。

自旋鎖簡介

  自旋鎖,顧名思義,我們可以把他理解成廁所門上的一把鎖。這個廁所門只有一把鑰匙,當我們進去時,把鑰匙取下來,進去後反鎖。那麼當第二個人想進來,必須等我們出去後纔可以。當第二個人在外面等待時,可能會一直等待在門口轉圈。

  我們的自旋鎖也是這樣,自旋鎖只有鎖定和解鎖兩個狀態。當我們進入拿上鑰匙進入廁所,這就相當於自旋鎖鎖定的狀態,期間誰也不可以進來。當第二個人想要進來,這相當於線程B想要訪問這個共享資源,但是目前不能訪問,所以線程B就一直在原地等待,一直查詢是否可以訪問這個共享資源。當我們從廁所出來後,這個時候就“解鎖”了,只有再這個時候線程B才能訪問。

  假如,在廁所的人待的時間太長怎麼辦?外面的人一直等待嗎?如果換做是我們,肯定不會這樣,簡直浪費時間,可能我們會尋找其他方法解決問題。自旋鎖也是這樣的,如果線程A持有自旋鎖時間過長,顯然會浪費處理器的時間,降低了系統性能。我們知道CPU最偉大的發明就在於多線程操作,這個時候讓線程B在這裏傻傻的不知道還要等待多久,顯然是不合理的。因此,如果自旋鎖只適合短期持有,如果遇到需要長時間持有的情況,我們就要換一種方式了(下文的互斥體)。

自旋鎖操作函數

函數 描述
DEFINE_SPINLOCK(spinlock_t lock) 定義並初始化一個自旋變量
int spin_lock_init(spinlock_t *lock) 初始化自旋鎖
void spin_lock(spinlock_t *lock) 獲取指定的自旋鎖,也叫加鎖
void spin_unlock(spinlock_t *lock) 釋放指定的自旋鎖。
int spin_trylock(spinlock_t *lock) 嘗試獲取指定的鎖,如果沒有獲取到,返回0
int spin_is_locked(spinlock_t *lock) 檢查指定的自旋鎖是否被獲取,如果沒有被獲取返回非0,否則返回0.

  自旋鎖是主要爲了多處理器系統設計的。對於單處理器且內核不支持搶佔的系統,一旦進入了自旋狀態,則會永遠自旋下去。因爲,沒有任何線程可以獲取CPU來釋放這個鎖。因此,在單處理器且內核不支持搶佔的系統中,自旋鎖會被設置爲空操作

  以上列表中的函數適用於SMP或支持搶佔的單CPU下線程之間的併發訪問,也就是用於線程與線程之間,被自旋鎖保護的臨界區一定不能調用任何能夠引起睡眠和阻塞的API函數,否則的話會可能會導致死鎖現象的發生。自旋鎖會自動禁止搶佔,也就說當線程A得到鎖以後會暫時禁止內核搶佔。如果線程A在持有鎖期間進入了休眠狀態,那麼線程A會自動放棄CPU使用權。線程B開始運行,線程B也想要獲取鎖,但是此時鎖被A線程持有,而且內核搶佔還被禁止了!線程B無法被調度岀去,那麼線程A就無法運行,鎖也就無法釋放死鎖發生了!

  當線程之間發生併發訪問時,如果此時中斷也要插一腳,中斷也想訪問共享資源,那該怎麼辦呢?首先可以肯定的是,中斷裏面使用自旋鎖,但是在中斷裏面使用自旋鎖的時候,在獲取鎖之前一定要先禁止本地中斷(也就是本CPU中斷,對於多核SOC來說會有多個CPU核),否則可能導致鎖死現象的發生。看下下面一個例子:

//線程A
spin_lock(&lock);
.......
functionA();
.......
spin_unlock(&lock);

//中斷髮生,運行線程B
spin_lock(&lock);
.......
functionA();
.......
spin_unlock(&lock);

  線程A先運行,並且獲取到了lock這個鎖,當線程A運行 functionA函數的時候中斷髮生了,中斷搶走了CPU使用權。右邊的中斷服務函數也要獲取lock這個鎖,但是這個鎖被線程A佔有着,中斷就會一直自旋,等待鎖有效。但是在中斷服務函數執行完之前,線程A是不可能執行的,線程A說“你先放手”,中斷說“你先放手”,場面就這麼僵持着死鎖發生!

  使用了自旋鎖之後可以保證臨界區不受別的CPU和本CPU內的搶佔進程的打擾,但是得到鎖的代碼在執行臨界區的時候,還可能受到中斷和底半部的影響,爲了防止這種影響,建議使用以下列表中的函數:

函數 描述
void spin_lock_irq(spinlock_t *lock) 禁止本地中斷,並獲取自旋鎖
void spin_unlock_irq(spinlock_t *lock) 激活本地中斷,並釋放自旋鎖
void spin_lock_irqsave(spinlock_t *lock, unsigned long flags) 保存中斷狀態,禁止本地中斷,並獲取自旋鎖
void spin_unlock_irqrestore(spinlock_t *lock, unsigned long flags)       將中斷狀態恢復到以前的狀態,並且激活本地中斷,釋放自旋鎖

  在多核編程的時候,如果進程和中斷可能訪問同一片臨界資源,我們一般需要在進程上下文中調用spin_ lock irqsave()/ spin_unlock_irqrestore(),在中斷上下文中調用 spin_lock()/
spin _unlock()。這樣,在CPU0上,無論是進程上下文,還是中斷上下文獲得了自旋鎖,此後,如果CPU1無論是進程上下文,還是中斷上下文,想獲得同一自旋鎖,都必須忙等待,這避免一切核間併發的可能性。同時,由於每個核的進程上下文持有鎖的時候用的是 spin_lock_irgsave(),所以該核上的中斷是不可能進入的,這避免了核內併發的可能性。

DEFINE_SPINLOCK(lock) /* 定義並初始化一個鎖 */ 
/* 線程A */
void functionA (){ 
unsigned long flags; /* 中斷狀態 */
 spin_lock_irqsave(&lock, flags) /* 獲取鎖 */ 
  /* 臨界區 */ 
spin_unlock_irqrestore(&lock, flags) /* 釋放鎖 */ 
} 
 /* 中斷服務函數 */
 void irq() { 
 spin_lock(&lock) /* 獲取鎖 */ 
   /* 臨界區 */ 
 spin_unlock(&lock) /* 釋放鎖 */ 
}

自旋鎖例程

static int xxx_open(struct inode *inode, struct file *filp)
{
...
	spinlock(&xxx_lock);
	if (xxx_count) {/* 已經打開*/
	spin_unlock(&xxx_lock);
	return -EBUSY;
 }
	 xxx_count++;/* 增加使用計數*/
 	spin_unlock(&xxx_lock);
 ...
	 return 0;/* 成功 */
}

static int xxx_release(struct inode *inode, struct file *filp)
{
	 ...
	 spinlock(&xxx_lock);
	 xxx_count--;/* 減少使用計數*/
	 spin_unlock(&xxx_lock);
 	return 0;
}

讀寫自旋鎖

  當臨界區的一個文件可以被同時讀取,但是並不能被同時讀和寫。如果一個線程在讀,另一個線程在寫,那麼很可能會讀取到錯誤的不完整的數據。自旋鎖是可以允許對臨界區的共享資源進行併發讀操作的。但是並不允許多個線程併發讀寫操作。如果想要併發讀寫,就要用到了自旋鎖。
  讀寫自旋鎖的讀操作函數如下所示:

函數 描述
DEFINE_RWLOCK(rwlock_t lock) 定義並初始化讀寫鎖
void rwlock_init(rwlock_t *lock) 初始化讀寫鎖
void read_lock(rwlock_t *lock) 獲取讀鎖
void read_unlock(rwlock_t *lock 釋放讀鎖
void read_unlock_irq(rwlock_t *lock) 打開本地中斷,並且釋放讀鎖
void read_lock_irqsave(rwlock_t *lock,unsigned long flags) 保存中斷狀態,禁止本地中斷,並獲取讀鎖
void read_unlock_irqrestore(rwlock_t *lock,unsigned long flags) 將中斷狀態恢復到以前的狀態,並且激活本地中斷,釋放讀鎖
void read_lock_bh(rwlock_t *lock) 關閉下半部,並獲取讀鎖
void read_unlock_bh(rwlock_t *lock) 打開下半部,並釋放讀鎖

  讀寫自旋鎖的寫操作函數如下所示:

函數 描述
void write_lock(rwlock_t *lock) 獲取寫鎖
void write_unlock(rwlock_t *lock) 釋放寫鎖
void write_lock_irq(rwlock_t *lock) 禁止本地中斷,並且獲取寫鎖。
void write_unlock_irq(rwlock_t *lock) 打開本地中斷,並且釋放寫鎖
void write_lock_irqsave(rwlock_t *lock,unsigned long flags) 保存中斷狀態,禁止本地中斷,並獲取寫鎖
void write_unlock_irqrestore(rwlock_t *lock,unsigned long flags) 將中斷狀態恢復到以前的狀態,並且激活本地中斷,釋放寫鎖
void write_lock_bh(rwlock_t *lock) 關閉下半部,並獲取寫鎖
void write_unlock_bh(rwlock_t *lock) 打開下半部,並釋放寫鎖

讀寫鎖例程

rwlock_t lock; /* 定義rwlock */
rwlock_init(&lock); /* 初始化rwlock */
/* 讀時獲取鎖*/
read_lock(&lock);
... /* 臨界資源 */
read_unlock(&lock);
/* 寫時獲取鎖*/
write_lock_irqsave(&lock, flags);
... /* 臨界資源 */
write_unlock_irqrestore(&lock, flags);

順序鎖

  順序鎖是讀寫鎖的優化版本,讀寫鎖不允許同時讀寫,而使用順序鎖可以完成同時進行讀和寫的操作,但並不允許同時的讀和寫。雖然順序鎖可以同時進行讀寫操作,但並不建議這樣,讀取的過程並不能保證數據的完整性。

順序鎖操作函數

  順序鎖的讀操作函數如下所示:

函數 描述
DEFINE_SEQLOCK(seqlock_t sl) 定義並初始化順序鎖
void seqlock_ini seqlock_t *sl) 初始化順序鎖
void write_seqlock(seqlock_t *sl) 順序鎖寫操作
void write_sequnlock(seqlock_t *sl) 獲取寫順序鎖
void write_seqlock_irq(seqlock_t *sl) 禁止本地中斷,並且獲取寫順序鎖
void write_sequnlock_irq(seqlock_t *sl) 打開本地中斷,並且釋放寫順序鎖
void write_seqlock_irqsave(seqlock_t *sl,unsigned long flags) 保存中斷狀態,禁止本地中斷,並獲取寫順序
void write_sequnlock_irqrestore(seqlock_t *sl,unsigned long flags) 將中斷狀態恢復到以前的狀態,並且激活本地中斷,釋放寫順序鎖
void write_seqlock_bh(seqlock_t *sl) 關閉下半部,並獲取寫讀鎖
void write_sequnlock_bh(seqlock_t *sl) 打開下半部,並釋放寫讀鎖

  順序鎖的寫操作函數如下所示:

函數 描述
DEFINE_RWLOCK(rwlock_t lock) 讀單元訪問共享資源的時候調用此函數,此函數會返回順序鎖的順序號
unsigned read_seqretry(const seqlock_t *sl,unsigned start) 讀結束以後調用此函數檢查在讀的過程中有沒有對資源進行寫操作,如果有的話就要重讀

自旋鎖使用注意事項

  1. 因爲在等待自旋鎖的時候處於“自旋”狀態,因此鎖的持有時間不能太長,一定要短,否則的話會降低系統性能。如果臨界區比較大,運行時間比較長的話要選擇其他的併發處理方式,比如稍後要講的信號量和互斥體。
  2. 自旋鎖保護的臨界區內不能調用任何可能導致線程休眠的API函數,比如copy_from_user()、copy_to_user()、kmalloc()和msleep()等函數,否則的話可能導致死鎖。
  3. 不能遞歸申請自旋鎖,因爲一旦通過遞歸的方式申請一個你正在持有的鎖,那麼你就必須“自旋”,等待鎖被釋放,然而你正處於“自旋”狀態,根本沒法釋放鎖。結果就是自己把自己鎖死了
  4. 在編寫驅動程序的時候我們必須考慮到驅動的可移植性,因此不管你用的是單核的還是多核的SOC,都將其當做多核SOC來編寫驅動程序。

copy_from_user的使用是結合進程上下文的,因爲他們要訪問“user”的內存空間,這個“user”必須是某個特定的進程。如果在驅動中使用這兩個函數,必須是在實現系統調用的函數中使用,不可在實現中斷處理的函數中使用。如果在中斷上下文中使用了,那代碼就很可能操作了根本不相關的進程地址空間。其次由於操作的頁面可能被換出,這兩個函數可能會休眠,所以同樣不可在中斷上下文中使用。

信號量

信號量簡介

  信號量和自旋鎖有些相似,不同的是信號量會發出一個信號告訴你還需要等多久。因此,不會出現傻傻等待的情況。比如,有100個停車位的停車場,門口電子顯示屏上實時更新的停車數量就是一個信號量。這個停車的數量就是一個信號量,他告訴我們是否可以停車進去。當有車開進去,信號量加一,當有車開出來,信號量減一。
  比如,廁所一次只能讓一個人進去,當A在裏面的時候,B想進去,如果是自旋鎖,那麼B就會一直在門口傻傻等待。如果是信號量,A就會給A一個信號,你先回去吧,我出來了叫你。這就是一個信號量的例子,B聽到A發出的信號後,可以先回去睡覺,等待A出來。
  因此,信號量顯然可以提高系統的執行效率,避免了許多無用功。信號量具有以下特點:

  1. 因爲信號量可以使等待資源線程進入休眠狀態,因此適用於那些佔用資源比較久的場合。
  2. 因此信號量不能用於中斷中,因爲信號量會引起休眠,中斷不能休眠
  3. 如果共享資源的持有時間比較短,那就不適合使用信號量了,因爲頻繁的休眠、切換線程引起的開銷要遠大於信號量帶來的那點優勢。

信號量操作函數

函數 描述
DEFINE_SEAMPHORE(name) 定義一個信號量,並且設置信號量的值爲1
void sema_init(struct semaphore *sem, int val) 初始化信號量sem,設置信號量值爲val
void down(struct semaphore *sem) 獲取信號量,因爲會導致休眠,因此不能在中斷中使用
int down_trylock(struct semaphore *sem); 嘗試獲取信號量,如果能獲取到信號量就獲取,並且返回0.如果不能就返回非0,並且不會進入休眠
int down_interruptible(struct semaphore                       獲取信號量,和down類似,只是使用dow進入休眠狀態的線程不能被信號打斷。而使用此函數進入休眠以後是可以被信號打斷的
void up(struct semaphore *sem) 釋放信號量

信號量例程

struct semaphore sem; /* 定義信號量 */ 
sema_init(&sem, 1)/* 初始化信號量 */
 down(&sem); /* 申請信號量 */
  /* 臨界區 */ 
 up(&sem); /* 釋放信號量 */

互斥體

互斥體簡介

  互斥體表示一次只有一個線程訪問共享資源,不可以遞歸申請互斥體
  信號量也可以用於互斥體,當信號量用於互斥時(即避免多個進程同時在一個臨界區中運行),信號量的值應初始化爲1.這種信號量在任何給定時刻只能由單個進程或線程擁有。在這種使用模式下,一個信號量有時也稱爲一個“互斥體( mutex)”,它是互斥( mutual exclusion)的簡稱。Linux內核中幾平所有的信號量均用於互斥

互斥體操作函數

函數 描述
DEFINE_MUTEX(name) 定義並初始化一個 mutex變量
void mutex_init(mutex *lock) 初始化 mutex
void mutex_lock(struct mutex *lock) 獲取 mutex,也就是給 mutex上鎖。如果獲取不到就進休眠
void mutex_unlock(struct mutex *lock) 釋放 mutex,也就給 mutex解鎖
int mutex_trylock(struct mutex *lock)                       判斷 mutex是否被獲取,如果是的話就返回,否則返回0
int mutex_lock_interruptible(struct mutex *lock) 使用此函數獲取信號量失敗進入休眠以後可以被信號打斷

互斥體例程

struct mutex lock; /* 定義一個互斥體 */ 
mutex_init(&lock); /* 初始化互斥體 */ 
mutex_lock(&lock); /* 上鎖 */ 
/* 臨界區 */
mutex_unlock(&lock); /* 解鎖*/

互斥體與自旋鎖

  互斥體和自旋鎖都是解決互斥問題的一種手段。互斥體是進程級別的,互斥體在使用的時候會發生進程間的切換,因此,使用互斥體資源開銷比較大。自旋鎖可以節省上下文切換的時間,如果持有鎖的時間不長,使用自旋鎖是比較好的選擇,如果持有鎖時間比較長,互斥體顯然是更好的選擇。

互斥體使用注意事項

  1. 當鎖不能被獲取到時,使用互斥體的開銷是進程上下文切換時間,使用自旋鎖的開銷是等待獲取自旋鎖(由臨界區執行時間決定)。若臨界區比較小,宜使用自旋鎖,若臨界區很大,應使用互斥體。
  2. 互斥體所保護的臨界區可包含可能引起阻塞的代碼,而自旋鎖則絕對要避免用來保護包含這樣代碼的臨界區。因爲阻塞意味着要進行進程的切換,如果進程被切換岀去後,另一個進程企圖獲取本自旋鎖,死鎖就會發生。
  3. 互斥體存在於進程上下文。因此,如果被保護的共享資源需要在中斷或軟中斷情況下使用,則在互斥體和自旋鎖之間只能選擇自旋鎖。當然,如果一定要使用互斥體,則只能通過mutex trylock()方式進行,不能獲取就立即返回以避免阻塞。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章