概述
最常見的進程/線程的同步方法有互斥鎖(或稱互斥量Mutex),讀寫鎖(rdlock),條件變量(cond),信號量(Semophore)等。在Windows系統中,臨界區(Critical Section)和事件對象(Event)也是常用的同步方法。
簡單的說,互斥鎖保護了一個臨界區,在這個臨界區中,一次最多隻能進入一個線程。如果有多個進程在同一個臨界區內活動,就有可能產生競態條件(race condition)導致錯誤。
讀寫鎖從廣義的邏輯上講,也可以認爲是一種共享版的互斥鎖。如果對一個臨界區大部分是讀操作而只有少量的寫操作,讀寫鎖在一定程度上能夠降低線程互斥產生的代價。
條件變量允許線程以一種無競爭的方式等待某個條件的發生。當該條件沒有發生時,線程會一直處於休眠狀態。當被其它線程通知條件已經發生時,線程纔會被喚醒從而繼續向下執行。條件變量是比較底層的同步原語,直接使用的情況不多,往往用於實現高層之間的線程同步。使用條件變量的一個經典的例子就是線程池(Thread Pool)了。
在學習操作系統的進程同步原理時,講的最多的就是信號量了。通過精心設計信號量的PV操作,可以實現很複雜的進程同步情況(例如經典的哲學家就餐問題和理髮店問題)。而現實的程序設計中,卻極少有人使用信號量。能用信號量解決的問題似乎總能用其它更清晰更簡潔的設計手段去代替信號量。
本系列文章的目的並不是爲了講解這些同步方法應該如何使用(AUPE的書已經足夠清楚了)。更多的是講解很容易被人忽略的一些關於鎖的概念,以及比較經典的使用與設計方法。文章會涉及到遞歸鎖與非遞歸鎖(recursive mutex和non-recursive mutex),區域鎖(Scoped Lock),策略鎖(Strategized Locking),讀寫鎖與條件變量,雙重檢測鎖(DCL),鎖無關的數據結構(Locking free),自旋鎖等等內容,希望能夠拋磚引玉。
那麼我們就先從遞歸鎖與非遞歸鎖說開去吧:)
1 可遞歸鎖與非遞歸鎖
1.1 概念
在所有的線程同步方法中,恐怕互斥鎖(mutex)的出場率遠遠高於其它方法。互斥鎖的理解和基本使用方法都很容易,這裏不做更多介紹了。
Mutex可以分爲遞歸鎖(recursive mutex)和非遞歸鎖(non-recursive mutex)。可遞歸鎖也可稱爲可重入鎖(reentrant mutex),非遞歸鎖又叫不可重入鎖(non-reentrant mutex)。
二者唯一的區別是,同一個線程可以多次獲取同一個遞歸鎖,不會產生死鎖。而如果一個線程多次獲取同一個非遞歸鎖,則會產生死鎖。
Windows下的Mutex和Critical Section是可遞歸的。Linux下的pthread_mutex_t鎖默認是非遞歸的。可以顯示的設置PTHREAD_MUTEX_RECURSIVE屬性,將pthread_mutex_t設爲遞歸鎖。
在大部分介紹如何使用互斥量的文章和書中,這兩個概念常常被忽略或者輕描淡寫,造成很多人壓根就不知道這個概念。但是如果將這兩種鎖誤用,很可能會造成程序的死鎖。請看下面的程序。
MutexLock mutex;
void foo()
{
mutex.lock();
// do something
mutex.unlock();
}
void bar()
{
mutex.lock();
// do something
foo();
mutex.unlock();
}
foo函數和bar函數都獲取了同一個鎖,而bar函數又會調用foo函數。如果MutexLock鎖是個非遞歸鎖,則這個程序會立即死鎖。因此在爲一段程序加鎖時要格外小心,否則很容易因爲這種調用關係而造成死鎖。
不要存在僥倖心理,覺得這種情況是很少出現的。當代碼複雜到一定程度,被多個人維護,調用關係錯綜複雜時,程序中很容易犯這樣的錯誤。慶幸的是,這種原因造成的死鎖很容易被排除。
但是這並不意味着應該用遞歸鎖去代替非遞歸鎖。遞歸鎖用起來固然簡單,但往往會隱藏某些代碼問題。比如調用函數和被調用函數以爲自己拿到了鎖,都在修改同一個對象,這時就很容易出現問題。因此在能使用非遞歸鎖的情況下,應該儘量使用非遞歸鎖,因爲死鎖相對來說,更容易通過調試發現。程序設計如果有問題,應該暴露的越早越好。
1.2 如何避免
爲了避免上述情況造成的死鎖,AUPE v2一書在第12章提出了一種設計方法。即如果一個函數既有可能在已加鎖的情況下使用,也有可能在未加鎖的情況下使用,往往將這個函數拆成兩個版本---加鎖版本和不加鎖版本(添加nolock後綴)。
例如將foo()函數拆成兩個函數。
// 不加鎖版本
void foo_nolock()
{
// do something
}
// 加鎖版本
void fun()
{
mutex.lock();
foo_nolock();
mutex.unlock();
}
爲了接口的將來的擴展性,可以將bar()函數用同樣方法拆成bar_withou_lock()函數和bar()函數。
在Douglas C. Schmidt(ACE框架的主要編寫者)的“Strategized Locking, Thread-safe Interface, and Scoped Locking”論文中,提出了一個基於C++的線程安全接口模式(Thread-safe interface pattern),與AUPE的方法有異曲同工之妙。即在設計接口的時候,每個函數也被拆成兩個函數,沒有使用鎖的函數是private或者protected類型,使用鎖的的函數是public類型。接口如下:
class T
{
public:
foo(); //加鎖
bar(); //加鎖
private:
foo_nolock();
bar_nolock();
}
作爲對外接口的public函數只能調用無鎖的私有變量函數,而不能互相調用。在函數具體實現上,這兩種方法基本是一樣的。
上面講的兩種方法在通常情況下是沒問題的,可以有效的避免死鎖。但是有些複雜的回調情況下,則必須使用遞歸鎖。比如foo函數調用了外部庫的函數,而外部庫的函數又回調了bar()函數,此時必須使用遞歸鎖,否則仍然會死鎖。AUPE 一書在第十二章就舉了一個必須使用遞歸鎖的程序例子。
1.3 讀寫鎖的遞歸性
讀寫鎖(例如Linux中的pthread_rwlock_t)提供了一個比互斥鎖更高級別的併發訪問。讀寫鎖的實現往往是比互斥鎖要複雜的,因此開銷通常也大於互斥鎖。在我的Linux機器上實驗發現,單純的寫鎖的時間開銷差不多是互斥鎖十倍左右。
在系統不支持讀寫鎖時,有時需要自己來實現,通常是用條件變量加讀寫計數器實現的。有時可以根據實際情況,實現讀者優先或者寫者優先的讀寫鎖。
讀寫鎖的優勢往往展現在讀操作很頻繁,而寫操作較少的情況下。如果寫操作的次數多於讀操作,並且寫操作的時間都很短,則程序很大部分的開銷都花在了讀寫鎖上,這時反而用互斥鎖效率會更高些。
相信很多同學學習了讀寫鎖的基本使用方法後,都寫過下面這樣的程序(Linux下實現)。
#include <pthread.h>
int main()
{
pthread_rwlock_t rwl;
pthread_rwlock_rdlock(&rwl);
pthread_rwlock_wrlock(&rwl);
pthread_rwlock_unlock(&rwl);
pthread_rwlock_unlock(&rwl);
return -1;
}
/*程序2*/
#include <pthread.h>
int main()
{
pthread_rwlock_t rwl;
pthread_rwlock_wrlock(&rwl);
pthread_rwlock_rdlock(&rwl);
pthread_rwlock_unlock(&rwl);
pthread_rwlock_unlock(&rwl);
return -1;
}
你會很疑惑的發現,程序1先加讀鎖,後加寫鎖,按理來說應該阻塞,但程序卻能順利執行。而程序2卻發生了阻塞。
更近一步,你能說出執行下面的程序3和程序4會發生什麼嗎?
/*程序3*/
#include <pthread.h>
int main()
{
pthread_rwlock_t rwl;
pthread_rwlock_rdlock(&rwl);
pthread_rwlock_rdlock(&rwl);
pthread_rwlock_unlock(&rwl);
pthread_rwlock_unlock(&rwl);
return -1;
}
/*程序4*/
#include <pthread.h>
int main()
{
pthread_rwlock_t rwl;
pthread_rwlock_wrlock(&rwl);
pthread_rwlock_wrlock(&rwl);
pthread_rwlock_unlock(&rwl);
pthread_rwlock_unlock(&rwl);
return -1;
}
在POSIX標準中,如果一個線程先獲得寫鎖,又獲得讀鎖,則結果是無法預測的。這就是爲什麼程序1的運行出人所料。需要注意的是,讀鎖是遞歸鎖(即可重入),寫鎖是非遞歸鎖(即不可重入)。因此程序3不會死鎖,而程序4會一直阻塞。
讀寫鎖是否可以遞歸會可能隨着平臺的不同而不同,因此爲了避免混淆,建議在不清楚的情況下儘量避免在同一個線程下混用讀鎖和寫鎖。
在系統不支持遞歸鎖,而又必須要使用時,就需要自己構造一個遞歸鎖。通常,遞歸鎖是在非遞歸互斥鎖加引用計數器來實現的。簡單的說,在加鎖前,先判斷上一個加鎖的線程和當前加鎖的線程是否爲同一個。如果是同一個線程,則僅僅引用計數器加1。如果不是的話,則引用計數器設爲1,則記錄當前線程號,並加鎖。一個例子可以看這裏。需要注意的是,如果自己想寫一個遞歸鎖作爲公用庫使用,就需要考慮更多的異常情況和錯誤處理,讓代碼更健壯一些。
區域鎖
確切的說,區域鎖(Scoped locking)不是一種鎖的類型,而是一種鎖的使用模式(pattern)。這個名詞是Douglas C. Schmidt於1998年在其論文Scoped Locking提出,並在ACE框架裏面使用。但作爲一種設計思想,這種鎖模式應該在更早之前就被業界廣泛使用了。
區域鎖實際上是RAII模式在鎖上面的具體應用。RAII(Resource Acquisition Is Initialization)翻譯成中文叫“資源獲取即初始化”,最早是由C++的發明者 Bjarne Stroustrup爲解決C++中資源分配與銷燬問題而提出的。RAII的基本含義就是:C++中的資源(例如內存,文件句柄等等)應該由對象來管理,資源在對象的構造函數中初始化,並在對象的析構函數中被釋放。STL中的智能指針就是RAII的一個具體應用。RAII在C++中使用如此廣泛,甚至可以說,不會RAII的裁縫不是一個好程序員。
問題提出
先看看下面這段程序,Cache是一個可能被多個線程訪問的緩存類,update函數將字符串value插入到緩存中,如果插入失敗,則返回-1。
Cache *cache = new Cache;
ThreadMutex mutex;
int update(string value)
{
mutex.lock();
if (cache == NULL)
{
mutex.unlock();
return -1;
}
If (cache.insert(value) == -1)
{
mutex.unlock();
return -1;
}
mutex.unlock();
return 0;
}
從這個程序中可以看出,爲了保證程序不會死鎖,每次函數需要return時,都要需要調用unlock函數來釋放鎖。不僅如此,假設cache.insert(value)函數內部突然拋出了異常,程序會自動退出,鎖仍然能不會釋放。實際上,不僅僅是return,程序中的goto, continue, break語句,以及未處理的異常,都需要程序員檢查鎖是否需要顯示釋放,這樣的程序是極易出錯的。
同樣的道理,不僅僅是鎖,C++中的資源釋放都面臨同樣的問題。例如前一陣我在閱讀wget源碼的時候,就發現雖然一共只有2萬行C代碼,但是至少有5處以上的return語句忘記釋放內存,因此造成了內存泄露。
區域鎖的實現
但是自從C++有了有可愛的RAII設計思想,資源釋放問題就簡單了很多。區域鎖就是把鎖封裝到一個對象裏面。鎖的初始化放到構造函數,鎖的釋放放到析構函數。這樣當鎖離開作用域時,析構函數會自動釋放鎖。即使運行時拋出異常,由於析構函數仍然會自動運行,所以鎖仍然能自動釋放。一個典型的區域鎖
class Thread_Mutex_Guard
{
public:
Thread_Mutex_Guard (Thread_Mutex &lock)
: lock_ (&lock)
{
// 如果加鎖失敗,則返回-1
owner_= lock_->lock();
}
~Thread_Mutex_Guard (void)
{
// 如果鎖獲取失敗,就不釋放
if (owner_ != -1)
lock_->unlock ();
}
private:
Thread_Mutex *lock_;
int owner_;
};
將策略鎖應用到前面的update函數如下
Cache *cache = new Cache;
ThreadMutex mutex;
int update(string value)
{
Thread_Mutex_Guard (mutex)
if (cache == NULL)
{
return -1;
}
If (cache.insert(value) == -1)
{
return -1;
}
return 0;
}
基本的區域鎖就這麼簡單。如果覺得這樣鎖的力度太大,可以用中括號來限定鎖的作用區域,這樣就能控制鎖的力度。如下
{
Thread_Mutex_Guard guard (&lock);
...............
// 離開作用域,鎖自動釋放
}
上面設計的區域鎖一個缺點是靈活行,除非離開作用域,否則不能夠顯式釋放鎖。如果爲一個區域鎖增加顯式釋放接口,一個最突出的問題是有可能會造成鎖的二次釋放,從而引發程序錯誤。
例如
{
Thread_Mutex_Guard guard (&lock);
If (…)
{
//顯式釋放(第一次釋放)
guard.release();
// 自動釋放(第二次釋放)
return -1;
}
}
爲了避免二次釋放鎖引發的錯誤,區域鎖需要保證只能夠鎖釋放一次。一個改進的區域鎖如下:
class Thread_Mutex_Guard
{
public:
Thread_Mutex_Guard (Thread_Mutex &lock)
: lock_ (&lock)
{
acquire();
}
int acquire()
{
// 加鎖失敗,返回-1
owner_= lock_->lock();
return owner;
}
~Thread_Mutex_Guard (void)
{
release();
}
int release()
{
// 第一次釋放
if (owner_ != -1)
{
owner = -1;
return lock_->unlock ();
}
// 第二次釋放
return 0;
}
private:
Thread_Mutex *lock_;
int owner_;
};
可以看出,這種方案在加鎖失敗或者鎖的多次釋放情況下,不會引起程序的錯誤。
缺點:
區域鎖固然好使,但也有不可避免的一些缺點
(1) 對於非遞歸鎖,有可能因爲重複加鎖而造成死鎖。
(2) 線程的強制終止或者退出,會造成區域鎖不會自動釋放。應該儘量避免這種情形,或者使用一些特殊的錯誤處理設計來確保鎖會釋放。
(3) 編譯器會產生警告說有變量只定義但沒有使用。有些編譯器選項甚至會讓有警告的程序無法編譯通過。在ACE中,爲了避免這種情況,作者定義了一個宏如下
#define UNUSED_ARG(arg) { if (&arg) /* null */; }
使用如下:
Thread_Mutex_Guard guard (lock_);
UNUSED_ARG (guard);
這樣編譯器就不會再警告了。
擴展閱讀:小技巧--如何在C++中實現Java的synchronized關鍵字
藉助於區域鎖的思想,再定義一個synchronized宏,可以在C++中實現類似Java中的synchronized關鍵字功能。鏈接:http://www.codeproject.com/KB/threads/cppsyncstm.aspx
原文地址: