前言
如何正確有效的保護共享數據是編寫并行程序必須面臨的一個難題,通常的手段就是同步。同步可分為阻塞型同步(Blocking Synchronization)和非阻塞型同步( Non-blocking Synchronization)。
阻塞型同步是指當一個線程到達臨界區時,因另外一個線程已經持有訪問該共享數據的鎖,從而不能獲取鎖資源而阻塞,直到另外一個線程釋放鎖。常見的同步原語有 mutex、semaphore 等。如果同步方案采用不當,就會造成死鎖(deadlock),活鎖(livelock)和優先級反轉(priority inversion),以及效率低下等現象。而且阻塞型同步,當獲取臨界資源失敗時,當前線程會被交出執行權,存在上下文切換的開銷。
而非阻塞型同步,主要指類似SpinLock的一種方式,當前線程無法獲取鎖的時候,并不會被阻塞,而是原地等待,直到獲取鎖,在這期間并不會發生線程的上下文切換。為了進一步降低風險程度和提高程序運行效率,業界又提出了lock-free的同步方案,其本質特征就是一個線程的執行不會阻礙系統中其他并行執行實體的運行。
本文串行介紹了mutex, semaphone, spinlock, lock-free這幾種機制的特性和使用場景,我們可以根據具體需求選擇合適的方式。
1 互斥鎖(mutex)
1.1 互斥鎖介紹
互斥鎖是通過保證多個線程對臨界區的串行訪問來達到同步的效果,針對多個線程都要修改或者訪問共享內存區的場景,我們可以把針對這塊共享內存訪問和修改代碼封裝在一個代碼段中,并使用互斥鎖來進行保護,從而確保共享內存的一致性。
互斥鎖其實包含了很多種類型:例如普通的互斥鎖,遞歸鎖,帶有定時功能的互斥鎖等等,而我們最常用到就是普通的互斥鎖,普通互斥鎖的接口如下所示:
int pthread_mutex_init(pthread_mutex_t * mutex,const pthread_mutexattr_t *attr);
int pthread_mutex_lock(pthread_mutex_t *mutex)
int pthread_mutex_unlock(pthread_mutex_t *mutex)
int pthread_mutex_trylock(pthread_mutex_t *mutex)
int pthread_mutex_destroy(pthread_mutex_t *mutex);
pthread_mutex_trylock()語義與pthread_mutex_lock()類似,不同的是在鎖已經被占據時返回EBUSY而不是掛起等待,所以原則上Mutex也是可以用于非阻塞性的方式,只是比較少使用這種方式而已。 通常情況下我們使用的互斥鎖是采用阻塞性同步方式,在無法獲取到鎖的情況下,當前線程或者進程就會被阻塞,會發生上下文切換,而使用pthread_mutex_trylock則不會。
這里需要注意的是,互斥鎖在不同使用場景可以設置不同的屬性,從而可以達到更好的效果,如下所示:
屬性 | 定義 |
---|---|
PTHREAD_PROCESS_SHARED | 用于同步該進程和其他進程中的線程 |
PTHREAD_PROCESS_PRIVATE | 用于僅同步該進程中的線程 |
C++ 11中針對互斥鎖在STD增加很多新的特性,例如std::lock_guard,std::unique_lock,使用的時候將會更加的方便,大家有興趣可以了解學習。
1.2 應用介紹
通常情況下,互斥鎖的使用方式如下所示:
/* thread 1 */
pthread_mutex_lock(&lock);
// critical area code;
pthread_mutex_unlock(&lock);
/* thread 2 */
pthread_mutex_lock(&lock);
// critical area code;
pthread_mutex_unlock(&lock);
2 信號量(semaphone)
2.1 概念
Semaphore是負責協調各個線程, 以保證它們能夠正確、合理的使用公共資源,也是操作系統中用于控制進程同步互斥的量。
最簡單的信號量是一個只有0與1兩個值的變量,二值信號量,而具有多個正數值的信號量被稱之為通用信號量。而針對信號量的P、V操作的定義,與我們大學操作系統課本上的P,V操作定義是一樣的,假定我們有一個信號量變量sv,P操作和V操作定義如下:
- P(sv) 如果sv大于0,減小sv。如果sv為0,掛起這個進程的執行。
- V(sv) 如果有進程被掛起等待sv,使其恢復執行。如果沒有進行被掛起等待sv,增加sv。
在linux上關于信號量的接口如下所示:
#include <sys/sem.h>
int semctl(int sem_id, int sem_num, int command, ...);
int semget(key_t key, int num_sems, int sem_flags);
int semop(int sem_id, struct sembuf *sem_ops, size_t num_sem_ops);
大家可能覺得信號量的接口與我們的PV操作不一致,其中semget主要是用于創建信號量,而semop是對信號量的操作,可以通過對這個接口簡單封裝來實現更貼合我們語義的接口,如下所示:
static int semaphore_p(void)
{
struct sembuf sem_b;
sem_b.sem_num = 0;
sem_b.sem_op = -1;
sem_b.sem_flag = SEM_UNDO;
if(semop(sem_id, &sem_b, 1) == -1)
{
return 0;
}
return 1;
}
static int semaphore_v(void)
{
struct sembuf sem_b;
sem_b.sem_num = 0;
sem_b.sem_op = 1;
sem_b.sem_flag = SEM_UNDO;
if(semop(sem_id, &sem_b, 1) == -1)
{
return 0;
}
return 1;
}
2.2 semaphone VS mutex
大家和容易混要這兩個概念,這里簡單對比一下信號量與互斥量的三點差異:
- 互斥量主要用于線程的互斥,信號線主要用于線程的同步
這是互斥量和信號量的根本區別,也就是互斥和同步之間的區別
互斥:是指某一資源同時只允許一個訪問者對其進行訪問,具有唯一性和排它性。但互斥無法限制訪問者對資源的訪問順序,即訪問是無序的
同步:是指在互斥的基礎上(大多數情況),通過其它機制實現訪問者對資源的有序訪問。在大多數情況下,同步已經實現了互斥,特別是所有寫入資源的情況必定是互斥的。少數情況是指可以允許多個訪問者同時訪問資源 - 互斥量值只能為0/1,信號量值可以為非負整數
也就是說,一個互斥量只能用于一個資源的互斥訪問,它不能實現多個資源的多線程互斥問題。信號量可以實現多個同類資源的多線程互斥和同步。當信號量為單值信號量是,也可以完成一個資源的互斥訪問 - 互斥量的加鎖和解鎖必須由同一線程分別對應使用,信號量一般情況下由一個線程釋放,另一個線程得到。
2.3 應用介紹
信號量的一個最典型的應用場景就是生產者消費者模型,偽代碼如下所示:
void *productor(void *arg) //生產者線程
{
while(1)
{
semaphore_p(&p_sem);//生產信號量減1
write_buffer();// 生產消息
semaphore_v(&c_sem); //消費信號量加1
}
}
void *consumer(void *arg) //消費者線程
{
while(1)
{
semaphore_p(&c_sem); //消費者信號量減1
read_buffer();//接收消息
semaphore_v(&p_sem);//生產者信號量加1
}
}
其中p_sem設置為buffer可以容納的消息個數,c_sem設置為0,從而可以實現當buffer被寫滿的時候,生產者線程被阻塞,而當Buffer為空的時候,消費者線程被阻塞。
3 spinlock
3.1 spinlock 概念
spinlock又稱自旋鎖,線程通過busy-wait-loop的方式來獲取鎖,任何時刻時刻只有一個線程能夠獲得鎖,其他線程忙等待直到獲得鎖,下面簡單介紹一下spinlock 的特點:
spin lock是一種死等的鎖機制。當發生訪問資源沖突的時候,可以有兩個選擇:一個是死等;一個是掛起當前進程,調度其他進程執行。spin lock是一種死等的機制,當前的執行thread會不斷的重新嘗試直到獲取鎖進入臨界區。
只允許一個thread進入。semaphore可以允許多個thread進入,spin lock不行,一次只能有一個thread獲取鎖并進入臨界區,其他的thread都是在門口不斷的嘗試。
執行時間短。由于spin lock死等這種特性,因此它使用在那些代碼不是非常復雜的臨界區(當然也不能太簡單,否則使用原子操作或者其他適用簡單場景的同步機制就OK了),如果臨界區執行時間太長,那么不斷在臨界區門口“死等”的那些thread是多么的浪費CPU啊,這種情況最好使用Mutex。
可以在中斷上下文執行。由于不睡眠,因此spin lock可以在中斷上下文中適用。
由于spinlock的實時性比較好,也可以在中斷相應程序中執行,所以spin_lock有很多種對等接口用來支持各種的需求,下面簡單列舉了常用到的spin_lock接口。
含義 | spinlock中接口函數 |
---|---|
動態初始化spin lock | spin_lock_init |
獲取指定的spin lock | spin_lock |
獲取指定的spin lock同時disable本CPU中斷 | spin_lock_irq |
保存本CPU當前的irq狀態,disable本CPU中斷并獲取指定的spin lock | spin_lock_irqsave |
獲取指定的spin lock同時disable本CPU的bottom half | spin_lock_bh |
釋放指定的spin lock | spin_unlock |
釋放指定的spin lock同時enable本CPU中斷 | spin_unlock_irq |
獲取指定的spin lock同時enable本CPU的bottom half | spin_unlock_bh |
嘗試去獲取spin lock,如果失敗,不會spin,而是返回非零值 | spin_trylock |
判斷spin lock是否是locked,如果其他的thread已經獲取了該lock,那么返回非零值,否則返回0 | spin_is_locked |
3.1 spinlock VS mutex
差異點:
spinlock不會使線程狀態發生切換,mutex在獲取不到鎖的時候會選擇sleep,會發生上下文切換。mutex獲取鎖分為兩階段,第一階段在用戶態采用鎖總線的方式獲取一次鎖,如果成功立即返回;否則進入第二階段,調用系統的futex鎖去sleep,當鎖可用后被喚醒,繼續競爭鎖。
spinlock 沒有昂貴的系統調用,一直處于用戶態,執行速度快,但是要一直占用cpu,而且在執行過程中還會鎖bus總線,鎖總線時其他處理器不能使用總線(目前是否在新的處理架構中有一些優化,例如軟件鎖,可以實現加鎖的過程中不需要鎖Bus總線);而mutex不會忙等,得不到鎖會sleep, 在進入sleep時會陷入到內核態,需要昂貴的系統調用。
3.3 應用介紹
spin_lock的使用方式與Mutex是比較類似的,只是中間臨界區的代碼比較少,處理速度比較快。在通信產品代碼中,在實時性的要求比較高的很多場景都可以用到,例如共享內存的分配,IPI中斷信號量的處理等等。
4. lock-free
4.1 概念澄清
lock free (中文一般叫“無鎖”,一般指的都是基于CAS指令的無鎖技術) 是利用處理器的一些特殊的原子指令來避免傳統并行設計中對鎖(lock)的使用。鎖在解決并行過程中資源訪問問題的同時可能會引入諸多新的問題,比如死鎖(dead lock),另外鎖的申請/釋放對性能也有不小的影響,當然最大的問題還在于使用鎖的代碼模塊通常難以進行組合,lock free的目標就是要消除鎖對編程帶來的不利影響。
這里簡單介紹一下Lock-Free 算法,通常由下面的compare_and_swap來實現,偽碼如下面所示:
Bool CAS(T* addr, T expected, T newValue)
{
if( *addr == expected )
{
*addr = newValue;
return true;
}
else
return false;
}
上面代碼僅是描述了語義,本身這個接口是需要硬件支持的,從而保證這個接口可以在SMP架構下能嚴格滿足語義。在實際開發過程中,利用 CAS 進行同步時通常會采用下面的方式:
do{
備份舊數據;
基于舊數據構造新數據;
}while(!CAS( 內存地址,備份的舊數據,新數據 ))
就是指當兩者進行比較時,如果相等證明共享數據沒有被其他并行實體修改,則替換成新值成功,然后繼續往下運行;如果不相等說明共享數據已經被修改,則放棄已經所做的操作,然后重新執行剛才的操作。可以看出使用這種方式,當同步沖突出現的機會很少時,這種方式能帶來較大的性能提升。
這里簡單列舉了常用的無鎖編程的一些接口,可以很容易根據接口的名字來了解接口的功能,如下所示:
type __sync_fetch_and_add (type *ptr, type value);
type __sync_fetch_and_sub (type *ptr, type value);
type __sync_fetch_and_or (type *ptr, type value);
type __sync_fetch_and_and (type *ptr, type value);
type __sync_fetch_and_xor (type *ptr, type value);
type __sync_fetch_and_nand (type *ptr, type value);
type __sync_add_and_fetch (type *ptr, type value);
type __sync_sub_and_fetch (type *ptr, type value);
type __sync_or_and_fetch (type *ptr, type value);
type __sync_and_and_fetch (type *ptr, type value);
type __sync_xor_and_fetch (type *ptr, type value);
type __sync_nand_and_fetch (type *ptr, type value);
bool __sync_bool_compare_and_swap (type *ptr, type oldval type newval, ...);
type __sync_val_compare_and_swap (type *ptr, type oldval type newval, ...);
type __sync_lock_test_and_set (type *ptr, type value, ...);
4.3 應用介紹
目前關于無鎖隊列的開發的資料已經比較多,網上應該還能找到無鎖MAP,無鎖鏈表,無鎖棧的算法或者源碼,大家有興趣可以看看。在之前學習無鎖編程的過程中,簡單寫過一個基于無鎖的單向隊列,從而支持多個線程的同時讀寫操作,部分源碼如下:
bool UnlockQueue::push(void* unit)
{
U32 curIndex;
do
{
curIndex = writeIndex;
if((curIndex +1) % maxUnitNum == readIndex) return false;
} while (!__sync_bool_compare_and_swap(&writeIndex, curIndex, NEXT_INDEX(curIndex));
memcpy(getUnitByIndex(writeIndex), unit, unitSize);
do
{
}while(!__sync_bool_compare_and_swap(&writeDoneIndex, curIndex, NEXT_INDEX(curIndex));
return true;
}
bool UnlockQueue::pop(void* unit)
{
U32 curIndex;
do
{
curIndex = readIndex;
if(writeDoneIndex == curIndex) return false;
} while (!__sync_bool_compare_and_swap(&readIndex, curIndex, NEXT_INDEX(curIndex));
memcpy(unit, getUnitByIndex(curIndex), unitSize);
return true;
}
針對這些單向隊列的操作,絕大部分情況下,都是一次插入隊列成功,偶爾碰到幾個線程同時插入的操作,也能保證每次都有一個線程插入成功,導致并發插入的線程數目很快下降,性能應該是比較高的。
4.3 硬件支持
針對無鎖編程是需要硬件支持的,主要有下面兩種方式:
- CPU提供Load-Link/Store-Conditional(LL/SC)這對指令,從而實現變量的CPU級別無鎖同步,PowerPC、MIPS 和 ARM是采用這種實現方式。
- LL [addr],dst:從內存[addr]處讀取值到dst。
- SC value,[addr]:對于當前線程,自從上次的LL動作后內存值沒有改變,就更新成新值。
- 還有一種類借助compare_and_swap,一個X86下CAS的匯編支持如下:
__asm {
mov edx, dest
mov ecx, exchange_value
mov eax, compare_value
LOCK_IF_MP(mp)//判斷是否是多核,是則添加LOCK指令維護順序一致性
cmpxchg dword ptr [edx], ecx
}
結束語
本文簡單介紹了Mutex, semaphone, spin_lock, lock-free等四種方式。首先Mutex 與 semaphone 使用場景不一樣,Mutex主要解決并行實體之間的互斥的問題,而semaphone主要解決并行實體之間的同步問題。針對一些臨界區代碼比較少,處理開銷比較小,而且實時性要求比較高的場景可以使用spin_lock來替代mutex實現互斥, 而如果需要同步的數據只有一個字段的情況下,可以使用lock-free的方式來替代spin_lock從而達到更高的性能。