概述
最常見的進程/線程的同步方法有互斥鎖(或稱互斥量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鎖是個非遞迴鎖,則這個程式會立即死結。因此在為一段程式加鎖時要格外小心,否則很容易因為這種調用關係而造成死結。
不要存在僥倖心理,覺得這種情況是很少出現的。當代碼複雜到一定程度,被多個人維護,調用關係錯綜複雜時,程式中很容易犯這樣的錯誤。慶幸的是,這種原因造成的死結很容易被排除。
但是這並不意味著應該用遞迴鎖去代替非遞迴鎖。遞迴鎖用起來固然簡單,但往往會隱藏某些代碼問題。比如調用函數和被調用函數以為自己拿到了鎖,都在修改同一個對象,這時就很容易出現問題。因此在能使用非遞迴鎖的情況下,應該盡量使用非遞迴鎖,因為死結相對來說,更容易通過調試發現。程式設計如果有問題,應該暴露的越早越好。