原子操作(atomic),自旋鎖(spinlock),互斥鎖(mutex),是常用的保護共享數據的方法,用於高並發程序裡的進程線程同步。
自旋鎖和互斥鎖的基礎是原子操作。
1,原子操作,
原子操作,是一條彙編代碼,它會保證CPU對內存的原子訪問,不受其他CPU核(多核處理器)和進程調度的影響。
如果只是實現鎖而不進行複雜運算的話,彙編指令xchg就可以達到效果。
它可以交換一個寄存器與一個內存整數的值,並且保證這種交換不會受到其他CPU核和調度器的幹擾。
volatile int lock = 0;
如果變量lock的指針&lock放在寄存器rdi裡,整數1放在變量eax裡,那麼交換這兩個值就是一個可以實現鎖的原子操作。
xchg %eax,(%rdi)
實際的C內嵌彙編是如下圖這麼寫:
「r」表示寄存器,「m」表示內存,帶「=」表示輸出,不帶表示輸入。
%後的數字編號從左到右、從上到下,以0開始。
變量r放在0號位置可以寫為 「0」(r)。
作為鎖的int變量一定要聲明為volatile,以防編譯器胡亂優化。
傳給xchg函數的一定是指針,只有通過指針修改的才是鎖的變量本身,而不是xchg()函數的形參。
2,自旋鎖和互斥鎖,
在xchg()函數的基礎上實現鎖,就是先把鎖初始化為0,表示沒有加鎖,然後用1去與鎖的值交換:
如果返回的是0,表示鎖之前是開著的,加鎖成功(此時鎖的值為1);
如果返回的是1,表示鎖之前已經鎖住(值為1),加鎖失敗(鎖的值保持不變,還是1)。
所以,spin_lock()可以這麼實現:
void spin_lock(volatile int* p)
{
while (1 == xchg(p))
sched_yield();
}
sched_yield()函數表示放棄CPU,調度其他線程執行,有利於持有鎖的線程儘快處理完數據,釋放鎖。
如果是獲取鎖失敗之後不放棄CPU,而是一直嘗試獲取,那麼另一個持有鎖的線程就不能及時得到執行,降低效率。
這種自旋鎖是用戶態自旋鎖,只用於進程間的同步。
線程間的同步還是使用pthread_mutex,它是基於Linux的futex()系統調用實現的,效率要更高。
futex在獲取鎖失敗之後會進入內核,讓當前線程掛在鎖的等待隊列(wait queue)上休眠。當有其他線程釋放這個鎖時再喚醒它運行,不至於循環調用sched_yield()放棄CPU,效率要更高。
釋放鎖,自然是把鎖的變量賦值為0就行,賦值只需要一條指令,本身就是原子的。
void spin_unlock(volatile int* p)
{
*p = 0;
}
這個相當於mov $0, (%rdi)。
如果是spin_trylock(),則可以直接返回xchg()值並且取反,因為xchg()返回0時加鎖成功,與if的條件true正好反著。
也可以嘗試一定次數(例如1000次)之後再返回結果。
int spin_trylock(volatile int* p)
{
int n = 0;
while(1 == xchg(p)) {
n++;
if (n < 1000)
sched_yield();
else
return 0;
}
return 1;
}
如果是基於futex實現的,則在獲取鎖失敗之後不需要自旋等待,而是進入內核的等待隊列,在其他線程釋放鎖時被喚醒,即通常的互斥鎖pthread_mutex,它在多線程程序裡廣泛使用。
用戶態自旋鎖,在nginx裡用來做多進程worker之間的負載均衡,讓負載最低的worker進程去監聽用戶接入的socket並執行accept,而不是所有的worker都去監聽。
否則,在新用戶接入時會導致epoll的驚群,即所有worker都被喚醒,但只有一個的accept會成功,其他全跑了空循環。
因為監聽的socket也是進程間的共享資源,所以要用鎖去保護它,避免並發訪問。
3,內核自旋鎖,
Linux內核中的高並發異步運行要比用戶態程序複雜的多,而且內核裡不能隨便休眠,也不能隨便調度,所以在保護共享數據時大量使用spinlock自旋鎖。
內核的運行上下文分為:進程(含線程)上下文,中斷上下文,軟中斷上下文。
只有在進程上下文時才可以使用調度器schedule()函數放棄CPU,切換進程。
中斷和軟中斷上下文,是不能放棄CPU的,否則執行路徑會被打亂。
進程上下文包括:進程,線程,內核線程,工作隊列。
軟中斷和tasklete都是軟中斷上下文,網絡數據的收發都是通過軟中斷softirq處理的。
鍵盤、滑鼠、時鐘、網卡之類的都是中斷,中斷服務例程irq只處理最緊急的工作,這是中斷上下文,也叫中斷的頂半部。
不太緊急的工作放到底半部,可以用軟中斷、tasklete、內核線程、工作隊列處理。
不展開了:(
最後,鎖要保護數據,不是保護代碼。