漫畫 | Linux 並發和競態問題究竟是什麼?
作者 | 寫代碼的籃球球痴
責編 | 郭芮
學習Linux的時候,肯定會遇到各種和鎖相關的知識,有時候自己學好了一點,感覺半桶水的自己已經可以華山論劍了,又突然冒出一個新的知識點,我看到新知識點的時候,有時間也是一臉的懵逼。在大學開始寫單片機的跑裸機代碼,完全不懂這個鎖在操作系統裡面是什麼鬼,從單片機到嵌入式Linux,還有一個多任務系統,不懂的同學建議百度看看。
什麼是並發和競態?
在早期的Linux內核中,並發源相對較少。內核不支持對稱多處理器(SMP)系統,唯一導致並發問題的原因是中斷。
隨著處理器的CPU核越來越多,這要求系統對事件迅速做出響應。為適應現代硬體和應用的需求,Linux內核已經發展到可以同時進行更多事情的地步。這種演變帶來了更大的可伸縮性。但是,這也大大複雜化了內核編程的任務。設備驅動程序員現在必須從一開始就將並發性考慮到他們的設中,而且他們需要深刻的理解並發問題,並利用內核提供的工具處理這類問題。
上面扯完了進入正題。並發是指多個執行任務同時、並行被執行;競態的字面意思是競爭,並發的執行單元對共享資源(硬體資源和軟體上的全局變數,靜態變數等)的訪問容易發生競態。
舉例一個字元設備的缺陷:對於一個虛擬的字元設備驅動,假設一個執行單元A對其寫入300個字元『a』,而另一個執行單元B對其寫入300個字元『b』,第三個執行單元讀取所有字元。如果A、B被順序串列執行那麼C讀出的則不會出錯,但如果A、B並發執行,那結果則是我們不可料想的。
競態發生的情況
- 對稱多處理器(SMP)的多個CPU: SMP是一種緊耦合、共享存儲的系統模型,它的特點是多個CPU使用共同的系統匯流排,因此可以訪問共同的外設和存儲器。
- 單CPU內進程與搶佔它的進程: Linux 2.6的內核支持搶佔調度,一個進程在內核執行的時候可能被另一高優先順序進程打斷。
- 中斷(硬中斷、軟中斷、tasklet、低半部)與進程之間:中斷可以打斷正在執行的進程,處理中斷的程序和被打斷的進程間也可能發生競態。
競態的解決辦法
解決競態問題的途徑是保證對共享資源的互斥訪問。訪問共享資源的代碼區域稱為臨界區,臨界區要互斥機制保護。Linux設備驅動中常見的互斥機制有以下方式:中斷屏蔽、原子操作、自旋鎖和信號量等。
Most readers would agree that this scenario is best avoided.
Therefore, the core rule that applies to spinlocks is that any code
must, while holding a spinlock, be atomic.It cannot sleep; in fact, it cannot relinquish the processor for any reason except toservice
interrupts (and sometimes not even then).
對上面死鎖理解不夠深入的,可以細細評味這段英文。
死鎖
死鎖的問題是開發中稍不小心就可能遇到的,在SMP系統裡面,如果有一個CPU被死鎖了,還有其他CPU可以繼續運行,就像一個車子,有一個輪子爆胎了,理論上還是可以跑的,就是開不快,或者開快的話就會容易掛逼。
多進程調度導致死鎖
以下四種情況會產生死鎖:
- 相互排斥。一個線程或進程永遠佔有共享資源,比如獨佔該資源。
- 循環等待。例如進程A在等待進程B,進程B在等待進程C,而進程C又在等待進程A。
- 部分分配。資源被部分分配,例如,進程A和B都需要訪問一個文件,同時需要用到印表機,進程A得到了這個文件資源,進程B得到了印表機資源,但兩個進程都不能獲得全部的資源了。
- 缺少優先權。一個進程獲得了該資源但是一直不釋放該資源,即使該進程處於阻塞狀態。
具體使用的場景會更加複雜,要需要按實際分析,對號入座。
單線程導致死鎖
單線程導致死鎖的情況一般是由於調用了引起阻塞的函數,比如(copy_from_user()、copy_to_ser()、和kmalloc()),阻塞後進行系統調度,調度的過程中有可能又調用了之前獲取鎖的函數,這樣必然導致死鎖。
還有一種就是自旋鎖函數在沒有釋放鎖馬上又進行申請同一個自旋鎖,這樣的低級問題也是會導致自旋鎖。
互斥鎖和自旋鎖、信號量的區別?
互斥鎖和互斥量在我的理解里沒啥區別,不同叫法。
廣義上講可以值所有實現互斥作用的同步機制。狹義上講指的就是mutex這種特定的二元鎖機制。互斥鎖的作用就是互斥,mutual exclusive,是用來保護臨界區(critical section)的 。所謂臨界區就是代碼的一個區間,如果兩個線程同時執行就有可能出問題,所以需要互斥鎖來保護。
信號量(semaphore) 是一種更高級的同步機制,mutex(互斥鎖) 可以說是 semaphore(信號量) 在僅取值0/1時的特例。Semaphore可以有更多的取值空間,用來實現更加複雜的同步,而不單單是線程間互斥。
自旋鎖 是一種 互斥鎖 的實現方式而已,相比一般的互斥鎖會在等待期間放棄cpu,自旋鎖(spinlock) 則是不斷循環並測試鎖的狀態,這樣就一直占著cpu。所以相比於自旋鎖和信號量,在申請鎖失敗的話,自旋鎖會不斷的查詢,申請線程不會進入休眠,信號量和互斥鎖如果申請鎖失敗的話線程進入休眠,如果申請鎖被釋放後會喚醒休眠的線程。
同步鎖 好像沒啥特殊說法,你可以理解為能實現同步作用的都可以叫同步鎖,比如信號量。最後,不要鑽這些名詞的牛角尖,更重要的是理解這些東西背後的原理,叫什麼名字並沒有什麼好說的。這些東西在不同的語言和平台上又有可能會有不同的叫法,其實本質上就這麼回事。
如何解決競態引起的問題?
上面我們已經分析了競態產生的原因、發生的情況以及解決辦法,下面我們對常見的解決辦法一一分析。
中斷屏蔽
即在單CPU中避免競態的一種簡單方法是在進入臨界區之前屏蔽系統的中斷。由於linux的非同步I/O、進程調度等很多內容都依靠中斷,所以我們應該儘快的執行完臨界區的代碼,換句話就是臨界區代碼應該盡量少。
Linux內核提供了下面具體方法:
Local_irq_disable();//屏蔽中斷
Local_irq_enable();//打開中斷
Local_irq_save(flags);//禁止中斷並保存當前cpu的中斷位信息
原子操作
原子操作指在執行過程中不會被別的代碼中斷的操作。
Linux內核提供了一系列的函數來實現內核中的原子操作,這些操作分為兩類,一類是整型原子操作,另一類是位原子操作,其都依賴底層CPU的原子操作實現,所以這些函數與CPU架構有密切關係。
1、整型原子操作
- a)設置原子變數的值
atomic_t v = ATOMIC_INIT(0);//定義原子變數v並初始化為0
void atomic_set(atomic_t *v, int i);//設置原子變數值為i
- b)獲取原子變數的值
atomic_read(atomic_t *v);//返回原子變數v的值
- c)原子變數加、減操作
void atomic_add(int i, atomic_t *v);//原子變數v增加i
void atomic_sub(int I, atomic_t *v);//原子變數v減少i
- d)原子變數自增、自減
void atomic_inc(atomic_t *v);//原子變數v自增1
void atomic_dec(atomic_t *v);//原子變數v自減1
- e)操作並測試
int atomic_inc_and_test(atomic_t *v);
int atomic_dec_and_test(atomic_t *v);
int atomic_sub_and_test(int i,atomic_t *v);
/*上述三個函數對原子變數v自增、自減和減操作(沒有加)後測試其是否為0,如果為0返回true,否則返回false*/
- f) 操作並返回
int atomic_add_return(int i,atomic_t *v);
int atomic_sub_return(int i,atomic_t *v);
int atomic_inc_return(atomic_t *v);
int atomic_dec_return(atomic_t *v);
/*上述函數對原子變數v進行自增、自減、加、減操作,並返回新的值*/
2、位原子操作
- a)設置位
void set_bit(nr,void *addr);//設置addr地址的第nr位,即向該位寫入1。
- b)清除位
void clear_bit(nr,void *addr);//清除addr地址的第nr位,即向該位寫入0。
- c)改變位
void change_bit(nr,void *addr);//對addr地址的第nr取反
- d)測試位
int test_bit(nr,void *addr);//返回addr地址的第nr位
- e) 測試並操作位
int test_and_set_bit(nr,void *addr);
int test_and_clear_bit(nr,void *addr);
int test_and_change_bit(nr,void *addr);
/*上述函數等同於執行test_bit後,再執行xxx_bit函數*/
自旋鎖
自旋鎖是一種對臨界資源進行互斥訪問的手段。
為獲得自旋鎖,在某CPU上運行的代碼需先執行一個原子操作,該操作測試並設置某個內存變數,由於其為原子操作,所以在該操作完成之前其他執行單元不可能訪問這個內存變數,如果測試結果表明已經空閑,則程序獲得這個自旋鎖並繼續執行,如果測試結果表明該鎖仍被佔用,程序將在一個小的循環內重複這個「測試並設置」操作,即進行所謂的「自旋」,通俗的說就是在「原地打轉」。
Linux內核中與自旋鎖相關的操作主要有:
- 1)定義自旋鎖
spinlock_t lock;
- 2)初始自旋鎖
spin_lock_init(lock);
- 3)獲得自旋鎖
spin_lock(lock);//獲得自旋鎖lock
spin_trylock(lock);//嘗試獲取lock如果不能獲得鎖,返回假值,不在原地打轉。
- 4)釋放自旋鎖
spin_unlock(lock);//釋放自旋鎖
為保證我們執行臨界區代碼的時候不被中斷等影響我們的自旋鎖又衍生了下面的內容。
- 5)自旋鎖衍生
spin_lock_irq() = spin_lock() + local_irq_disable()
spin_unlock_irq() = spin_unlock() + local_irq_enable()
spin_lock_irqsave() = spin_lock() + local_irq_save()
spin_unlock_irqrestore() = spin_unlock() + local_irq_restore()
spin_lock_bh() = spin_lock() + local_bh_disable()
spin_unlock_bh() = spin_unlock() + local_bh_disable()
使用注意事項:
- 自旋鎖實質是忙等鎖,因此在佔用鎖時間極短的情況下,使用鎖才是合理的,反之則會影響系統性能;
- 自旋鎖可能導致系統死鎖;
- 自旋鎖鎖定期間不能調用可能引起進程調度的函數。
讀寫自旋鎖
為解決自旋鎖中不能允許多個單元並發讀的操作,衍生出了讀寫自旋鎖,其不允許寫操作並發,但允許讀操作並發。
Linux內核中與讀寫自旋鎖相關的操作主要有:
- 1)定義和初始化讀寫自旋鎖
rwlock_t my_rwlock = RW_LOCK_UNLOCKED;//靜態初始化
rwlock_t my_rwlock;
rwlock_init(&my_rwlock);//動態初始化
- 2)讀鎖定
read_lock();
read_lock_irqsave();
read_lock_irq();
read_lock_bh();
- 3)讀解鎖
read_unlock();
read_unlock_irqrestore();
read_unlock_irq();
read_unlock_bh();
- 4)寫鎖定
write_lock();
write_lock_irqsave();
write_lock_irq();
write_lock_bh();
write_trylock();
- 5)寫解鎖
write_unlock();
write_unlock_irqrestore();
write_unlock_irq();
write_unlock_bh();
順序鎖
順序鎖是對讀寫鎖的一種優化,如果使用順序鎖,讀執行單元在寫執行單元對被順序鎖保護的共享資源進行寫操作時仍然可以繼續讀,不必等待寫執行單元的完成,寫執行單元也不需等待讀執行單元完成在進行寫操作。
順序鎖保護的共享資源不含有指針,因為在寫執行單元可能使得指針失效,但讀執行單元如果此時訪問該指針,將導致oops。
Linux內核中與順序鎖相關的操作主要有:
- 1)寫執行單元獲得順序鎖
write_seqlock();
write_tryseqlock();
write_seqlock_irqsave();
write_seqlock_irq();
write_seqlock_bh();
- 2)寫執行單元釋放順序鎖
write_sequnlock();
write_sequnlock_irqrestore();
write_sequnlock_irq();
write_sequnlock_bh();
- 3)讀執行單元開始
read_seqbegin();
read_seqbegin_irqsave();//local_irq_save + read_seqbegin
- 4)讀執行單元重讀
read_seqbegin();
read_seqretry ();
read_seqretry_irqrestore ();
RCU(讀-拷貝-更新)
RCU可以看做是讀寫鎖的高性能版本,相比讀寫鎖,RCU的優點在於即允許多個讀執行單元同時訪問被保護數據,又允許多個讀執行單元和多個寫執行單元同時訪問被保護的數據。
RCU不能代替讀寫鎖。
Linux內核中與RCU相關的操作主要有:
- 1)讀鎖定
rcu_read_lock ();
rcu_read_lock_bh ();
- 2)讀解鎖
rcu_read_unlock ();
rcu_read_unlock_bh ();
- 3)同步RCU
synchronize_rcu ();//由RCU寫執行單元調用
synchronize_sched();//可以保證中斷處理函數處理完畢,不能保證軟中斷處理結束
- 4)掛接回調
call_rcu ();
call_rcu_bh ();
有關RCU的操作還有很多,大家可以參考網路。
信號量
信號量用於保護臨界區的常用方法與自旋鎖類似,但不同的是當獲取不到信號量時,進程不會原地打轉而是進入休眠等待狀態。
Linux內核中與信號量相關的操作主要有:
- 1)定義信號量
Struct semaphore sem;
- 2)初始化信號量
void sema_init(struct semaphore *sem, int val);//初始化sem為val,當然還有系統定義的其他宏初始化,這裡不列舉
- 3)獲得信號量
void down(struct semaphore *sem);//獲得信號量sem,其會導致睡眠,並不能被信號打斷
int down_interruptible(struct semaphore *sem);//進入睡眠可以被信號打斷
int down_trylock(struct semaphore *sem);//不會睡眠
- 4)釋放信號量
void up(struct semaphore *sem);//釋放信號量,喚醒等待進程
註:當信號量被初始為0時,其可以用於同步。
Completion用於同步
即Linux中的同步機制,Linux內核中與Completion相關的操作主要有:
- 1)定義Completion
struct completion *my_completion;
- 2)初始化Completion
void init_completion(struct completion *x);
- 3)等待Completion
void wait_for_completion(struct completion *);
- 4)喚醒Completion
void complete(struct completion *);//喚醒一個
void complete_all(struct completion *);//喚醒該Completion的所有執行單元
讀寫信號量
與自旋鎖和讀寫自旋鎖的關係類似,Linux內核中與讀寫信號量相關的操作主要有:
- 1)定義和初始化讀寫自旋鎖
struct rw_semaphore sem;
init_rwsem(&sem);
- 2)讀信號量獲取
down_read ();
down_read_trylock();
- 3)讀信號量釋放
up_read ();
- 4)寫信號量獲取
down_write ();
down_write_trylock ();
- 5)寫信號量釋放
up_write();
互斥體
用來實現互斥操作,Linux內核中與互斥體相關的操作主要有:
- 1)定義和初始化互斥體
struct mutex lock;
mutex_init(&lock);
- 2)獲取互斥體
void mutex_lock(struct mutex *lock);
int mutex_lock_interruptible(struct mutex *lock);
int mutex_lock_killable(struct mutex *lock);
- 3)釋放互斥體
void mutex_unlock(struct mutex *lock);
作者:寫代碼的籃球球痴,十餘年嵌入式開發經驗,喜歡嵌入式也喜歡籃球,從開始的STC51單片機、AVR、STM32到現在的ARM,從開始的裸機到現在的嵌入式Linux和安卓系統,做過不少項目也踩過很多大坑小坑,希望認識更多嵌入式的朋友一起交流技術,共同提高和進步。
聲明:本文為作者投稿,版權歸其個人所有。文中除封面圖之外,所有圖片均為原作者所發,CSDN 不因此承擔任何責任。
※東北到底有沒有互聯網?!
※零基礎程序員如何花 8 個月時間獲得特斯拉實習機會?
TAG:CSDN |