最常見的進程/線程的同步方法有互斥鎖(或稱互斥量Mutex),讀寫鎖(rdlock),條件變量(cond),信號量(Semophore)等。在Windows系統中,臨界區(Critical Section)和事件對象(Event)也是常用的同步方法。
- 簡單地說,互斥鎖保護了一個臨界區,在這個臨界區中,一次最多進入一個線程。如果有多個線程在同一個臨界區內活動,就有可能產生競態(race condition)導致錯誤
- 讀寫鎖從廣義的邏輯上講,也可以認為是一種共享版的互斥鎖。如果對臨界區大部分的操作是讀操作而只有少部分是寫操作,讀寫鎖在一定程度上能夠降低線程互斥產生的代價
- 條件變量允許線程以一種無競爭的方式去等待某個條件的發生。當該條件沒有發生時,線程會一直處於休眠狀態,當被其他線程通知條件已經發生時,線程才會被喚醒從而繼續向下執行。條件變量是比較底層的同步原語,直接使用的情況不多。使用條件變量的一個經典的例子就是線程池(ThreadPool)了。
- 在學習操作系統的進程同步原理時,講的最多的就是信號量了。通過精心設計信號量的PV操作,可以實現很復雜的進程同步情況(例如哲學家進餐問題和理發店問題)。而在現實的程序設計中,卻極少有人使用信號量。能用信號量解決的問題似乎總能找到其他更清晰更簡潔的設計手段去代替信號量。
本文的目的不是講解如何使用這些鎖,更多的是講解容易被人忽略的一些關於鎖的概念,以及比較經典的使用和設計方法。文章會涉及到遞歸鎖和非遞歸鎖(recursive mutex和non-recursive mutex),區域鎖(Scoped Lock),策略鎖(Strategized Lock),讀寫鎖和條件變量,雙重檢測鎖(DCL),鎖無關的數據結構(Lockng free),自旋鎖等等內容。
一、可遞歸鎖和非遞歸鎖
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鎖是個非遞歸鎖,則這個程序會立即死鎖。因此在為一段程序加鎖時要格外小心,否則很容易因為這種調用關系而造成死鎖。
不要存在僥幸心理,覺得這種情況是很少出現的。當代碼復雜到一定程度,被多個人維護,調用關系錯綜復雜時,程序中很容易犯這樣的錯誤。慶幸的是,這種原因造成的死鎖很容易被排除。
但是這並不意味着應該用遞歸鎖去代替非遞歸鎖。遞歸鎖用起來固然簡單,但往往會隱藏某些代碼問題。比如調用函數和被調用函數以為自己拿到了鎖,都在修改同一個對象,這時就很容易出現問題。因此在能使用非遞歸鎖的情況下,應該盡量使用非遞歸鎖,因為死鎖相對來說,更容易通過調試發現。程序設計如果有問題,應該暴露的越早越好。
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 一書在第十二章就舉了一個必須使用遞歸鎖的程序例子。
3. 讀寫鎖的遞歸性
讀寫鎖(例如linux中的pthread_rwlock_t)提供了一個比互斥鎖更加高級別的並發訪問。讀寫鎖的實現往往是比互斥鎖要復雜的,因此開銷也常常大於互斥鎖。在我的linux機器上,單純的寫鎖的時間開銷超差多是互斥鎖的10倍左右。
#include <pthread.h> #include<iostream> using namespace std; int main() { pthread_rwlock_t rwl; cout << pthread_rwlock_init(&rwl, NULL) << endl; cout << pthread_rwlock_rdlock(&rwl) << endl; cout << pthread_rwlock_wrlock(&rwl) << endl; cout << pthread_rwlock_unlock(&rwl) << endl; cout << pthread_rwlock_unlock(&rwl) << endl; return -1; }
先加讀鎖再加寫鎖程序會阻塞,那如果先加讀鎖再加寫鎖,我們會發現程序奔潰了...
rogn@ubuntu:~/suo$ cat test.cpp #include <pthread.h> #include<iostream> using namespace std; int main() { pthread_rwlock_t rwl; cout << "init: " << pthread_rwlock_init(&rwl, NULL) << endl; cout << "write: " << pthread_rwlock_wrlock(&rwl) << endl; cout << "read: " << pthread_rwlock_rdlock(&rwl) << endl; cout << "unlock: " << pthread_rwlock_unlock(&rwl) << endl; cout << "unlock: " << pthread_rwlock_unlock(&rwl) << endl; return -1; } rogn@ubuntu:~/suo$ ./test init: 0 write: 0 read: 35 unlock: 0 Illegal instruction (core dumped)
程序會死鎖在接下來的寫鎖定上. 35錯誤號為EDEADLK, 意為出現死鎖. 仔細研究pthread讀寫鎖的文檔, 才發現原來如果一個線程寫鎖定后, 又調用pthread_rwlock_rdlock函數來讀鎖定,結果將無法預測。
/*程序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; }
讀鎖是遞歸鎖(即可重入),寫鎖是非遞歸鎖(即不可重入)。因此程序3不會死鎖,而程序4會一直阻塞。
讀寫鎖是否可以遞歸會可能隨着平台的不同而不同,因此為了避免混淆,建議在不清楚的情況下盡量避免在同一個線程下混用讀鎖和寫鎖。
在系統不支持遞歸鎖,而又必須要使用時,就需要自己構造一個遞歸鎖。通常,遞歸鎖是在非遞歸互斥鎖加引用計數器來實現的。簡單的說,在加鎖前,先判斷上一個加鎖的線程和當前加鎖的線程是否為同一個。如果是同一個線程,則僅僅引用計數器加1。如果不是的話,則引用計數器設為1,則記錄當前線程號,並加鎖。一個例子可以看這里。需要注意的是,如果自己想寫一個遞歸鎖作為公用庫使用,就需要考慮更多的異常情況和錯誤處理,讓代碼更健壯一些。
版權聲明:本文為CSDN博主「zouxinfox」的原創文章,遵循 CC 4.0 BY-SA 版權協議,轉載請附上原文出處鏈接及本聲明。
原文鏈接:https://blog.csdn.net/zouxinfox/article/details/5838861