一提到線程同步,就會提到鎖,做爲線程同步的手段之一,鎖老是飽受質疑。一方面鎖的使用很簡單,只要在代碼不想被重入的地方(多個線程同時執行的地方)加上鎖,就能夠保證不管什麼時候,該段代碼最多有一個線程在執行;另外一方面,鎖又不像它看起來那樣簡單,鎖會形成不少問題:性能降低、死鎖等。使用volatile關鍵字或者Interlocked中提供的方法可以避開鎖的使用,可是這些原子操做的方法功能有限,不少操做實現起來很麻煩,如無序的線程安全集合。我在本系列的序中已經介紹了鎖的總類,自旋鎖、內核鎖(內核構造)、混合鎖,他們各有優缺點,下面就來一一介紹。html
我在 C#多線程編程(6) 中已經介紹了簡單的利用Interlocked實現的自旋鎖,這種鎖的優勢是單線程時很是快,可是在竟態時會形成等待的線程「自旋」--無限while循環,在循環中只是在不斷的嘗試得到鎖,其餘什麼也不作。若是得到鎖的線程執行的很是快,那麼等待的線程在那自旋一會是值得的,由於當鎖被釋放時,自旋的線程可以立馬得到鎖。可是當得到鎖的線程執行的時間很長,等待線程的自旋就毫無心義了。設想這樣一個場景,A線程得到了鎖,B和C線程想要得到鎖,發現鎖已被其餘線程得到,就會開始自旋,而且A線程遲遲不願交出鎖,這至關於一個鎖佔用了3個線程,這是多麼大的浪費!編程
其實準確的說,內核鎖是內核維護的變量,《CLR via C#》中叫內核構造,更準確一些,可是我感受內核構造這個名字太奇怪了,仍是內核鎖好理解一些。內核鎖是一組繼承了WaitHandle基類的一組類windows
public abstract class WaitHandle : MarshalByRefObject, IDisposable{ public virtual IntPtr Handle { get; set; } public SafeWaitHandle SafeWaitHandle { get; set; } public void Dispose(); public static bool SignalAndWait(WaitHandle toSignal, WaitHandle toWaitOn); public static bool WaitAll(WaitHandle[] waitHandles); public static int WaitAny(WaitHandle[] waitHandles); public virtual bool WaitOne(); }
我只列出了基本的方法,重載的版本沒有列出。每構造一個內核鎖,都是調用windows方法建立一個內核變量保存在SafeWaitHandle屬性中。這是個抽象類,全部的內核鎖都繼承於此。簡單介紹下上面列出的幾個方法。數組
下面介紹幾個從WaitHandle派生的類:AutoResetEvent、Semaphore、Mutex。安全
AutoResetEvent是內核維護的一個bool型變量,當鎖爲自由狀態時,值爲true,調用WaitOne方法,值變爲false(至關於得到鎖)。在調用Set()方法後(至關於釋放鎖)值恢復爲true,並喚醒一個等待的線程。,咱們來看一個利用AutoResetEvent來重寫 C#多線程編程(6) 中的SimpleSpinLock類,新類的名字爲SimpleWaitLock。多線程
class SimpleWaitLock : IDisposable{ private AutoResetEvent _lockState = new AutoResetEvent(true); public void Enter() { _lockState.WaitOne(); } public void Leave() { _lockState.Set(); } public void Dispose() { _lockState.Dispose(); } }
能夠看到鎖和SimpleSpinLock很是像,可是這兩個鎖的性能大相徑庭。我前面說過,自旋鎖在串行下效率很是高,可是內核鎖在訪問內核變量並判斷是否能夠得到鎖時,要先從託管代碼切換到內核代碼,得到值後,在切換回託管代碼,這些消耗不管是否竟態都要花費。可是在竟態時,自旋鎖會白白浪費CPU,而內核鎖會阻塞等待的線程,而不會自旋而浪費CPU。性能
用一個例子來對比下在串行下兩個鎖的性能差距。this
static void Main(string[] args){ int x = 0; int count = 10000000; Stopwatch sw = Stopwatch.StartNew(); for (int i = 0; i < count; i++){ x++; } Console.WriteLine("NoMethod: {0} ms", sw.ElapsedMilliseconds); sw = Stopwatch.StartNew(); for (int i = 0; i < count; i++){ VoidMothed(); x++; VoidMothed(); } Console.WriteLine("VoidMethod:{0} ms", sw.ElapsedMilliseconds); SpinLock spinLock = new SpinLock(false); sw = Stopwatch.StartNew(); for (int i = 0; i < count; i++){ bool taken = false; spinLock.Enter(ref taken); x++; spinLock.Exit(); } Console.WriteLine("SpinLock:{0} ms", sw.ElapsedMilliseconds); using (SimpleWaitLock simpleWaitLock = new SimpleWaitLock()){ sw = Stopwatch.StartNew(); for (int i = 0; i < count; i++){ simpleWaitLock.Enter(); x++; simpleWaitLock.Leave(); } Console.WriteLine("WaitLock: {0} ms", sw.ElapsedMilliseconds); } Console.ReadLine(); }
運行結果: spa
NoMethod: 22 ms
VoidMethod:56 ms
SpinLock:216 ms
WaitLock: 11683 mspwa
執行一千萬次的x++只須要22ms,帶一個空方法VoidMethod的是56ms,spinlock是216ms,而內核鎖執行了11683ms,是spinlock的54倍(11683/216),而比NoMethod慢了531倍(11683/22),確實直接訪問內核鎖要慢不少,所以能避免使用內核鎖就要避免。
信號量(Semaphore)就是內核維護的一個int型變量,其用法和AutoResetEvent相似。Release(int)方法支持同時釋放多個阻塞的線程。與AutoResetEvent相比,AutoResetEvent.Set方法調用屢次,只有一個線程接觸阻塞,可是Release()會一直解除其餘線程的阻塞,直到全部線程所有解除等待。若將全部等待的線程所有解除阻塞後,繼續調用Release方法,會拋出一個SemaphoreFullException異常。
public sealed class Semaphore:WaitHandle{ public Semaphore(int initialCount, int maximumCount); public int Release()//調用Release(1);釋放1個線程的等待(阻塞) public int Release(int releaseCount);//釋放releaseCount個的線程的等待 }
能夠用信號量來從新實現SimpleWaitLock:
class SimpleWaitLock : IDisposable{ private readonly Semaphore _lockState; public SimpleWaitLock(int maxCount) { _lockState = new Semaphore(maxCount, maxCount); } public void Enter() { _lockState.WaitOne(); } public void Leave() { _lockState.Release(); } public void Dispose() { _lockState.Dispose(); }
Mutex是一個互斥鎖,該鎖的功能和AutoResetEvent還有SimpleWaitLock,
public class Mutex:WaitHandle{ public Mutex(); public void Release(); }
可是Mutex還有一些額外的功能:該鎖支持遞歸調用。鎖的遞歸調用就是單線程屢次加鎖,以下所示:
var smLock = new SmLock(); void M1(){ smLock.Enter(); M2();//M2中會再次加鎖,即「遞歸」 smLock.Exit(); } void M2(){ smLock.Enter(); //一些操做 smLock.Exit(); }
爲了支持這個功能,Mutex中會記錄當前得到鎖的線程Id,容許已經得到鎖的線程再次加鎖,並對加鎖次數進行計數。當計數爲0時,調用Release()方法重置鎖。
前面介紹了自旋鎖和內核鎖,下面來介紹混合鎖。前面介紹了自旋鎖的優點在串行時的性能消耗較小,內核鎖的優點在竟態時不佔用CPU資源,而混合鎖就是這二者的結合體,咱們來實現一個簡單的混合鎖。
public class SimpleHybridLock : IDisposable { private readonly AutoResetEvent _coreLock = new AutoResetEvent(true); private volatile int _outLock = 0; public void Enter() { //嘗試得到鎖 if (++_outLock == 1) //鎖無人使用,直接返回 return; //有其餘線程已得到鎖,則在此等待該鎖釋放。 _coreLock.WaitOne(); } public void Leave() { //嘗試釋放鎖,若是沒有其餘線程在等待鎖,則直接返回 if (--_outLock == 0) return; //喚醒一個等待的線程,比較浪費性能 _coreLock.Set(); } /// <summary> /// 釋放內核鎖 /// </summary> public void Dispose() { _coreLock.Dispose(); } }
在SimpleHybridLock中,我聲明瞭一個int變量和一個AutoResetEvent變量,當調用Enter方法時,使_outLock++,並判斷新值是否爲1,若是是1,則表示該鎖是自由的,沒有被其餘線程得到。由於當該鎖被其餘線程得到後,再次調用Enter方法會使_outLock的值變爲2,。若是是1,則直接返回,表示該線程已經得到了該鎖。若是大於1,表示該鎖已經被其餘線程得到,這時就會繼續執行,調用_coreLock.WaitOne()方法,來等待該鎖的釋放。在調用Leave方法時,會先判斷--_outLock 是否爲0,若返回0,則該鎖已經被釋放。由於當返回值大於0時,表明有其餘線程在等待該鎖,這時就要調用_coreLock.Set來喚醒一個等待的線程。
SimpleHybridLock鎖的優點在:當單線程執行時,永遠不會出現另一個線程來嘗試得到鎖,那麼該鎖只有很小的開支,對_outLock++,並判斷新值是否爲1,而後直接返回,在釋放鎖的時候,對_outLock--,而後判斷是否爲0。_coreLock.WaitOne()永遠不會被執行,所以該鎖有自旋鎖的優勢,單線程快。當多線程時,會調用_coreLock.WaitOne()來等待該鎖的釋放,而不會「自旋」,從而浪費CPU。
該鎖的問題是,沒有記錄哪一個線程得到了鎖,若是想要得到鎖的線程直接調用Leave方法,就會形成錯誤的鎖釋放。不光如此,該鎖也沒有實現鎖的遞歸。實現這些功能就會形成資源的消耗,就看你是否有能力來保證不會出現上述狀況。
還能夠在Enter方法中,if(++_outLock == 1){}處添加一個較短的自旋,該自旋是爲了處理那些很是短的得到鎖和釋放所的程序。當判斷鎖已經被獲取時,不會直接調用_coreLock.WaitOne(),而是自旋一段時間,若是得到鎖的線程執行了一段很短的程序,那麼在自旋的過程當中,鎖已經被釋放了,這時就能夠避免調用內核鎖來浪費資源。這是一種有針對性的修改。使鎖更適合那些較短的任務。
FCL提供了幾個混合鎖,其中最經常使用的是Monitor,它支持自旋、遞歸、鎖的全部權,咱們在調用lock(object)時,編譯器會將其編譯成Monitor.Enter和Exist塊。那到底Monitor是怎麼實現的?
Monitor的基本原理是利用class在堆中初始化時,會初始化一個同步塊。該同步塊是用來記錄有哪些指針引用了該對象,以及引用的個數。咱們在調用Monitor的時候,會這樣
Monitor.Enter(obj); x++; Monitor.Exist(obj);
簡單理解,在調用Monitor.Enter(obj)方法後,會形成obj沒法再被其餘線程調用,直到Monitor.Leave(obj)被調用。這樣會形成Monitor的BUG,例子以下:
class MonitorExample { private int m; public void Test() { Monitor.Enter(this); m = 5; Monitor.Exit(this); } public int M { get { Monitor.Enter(this); //保證對m訪問的安全性 int n = m; Monitor.Exit(this); return n; } } } public static void TestMethod() { var t = new MonitorExample(); Monitor.Enter(t); //注意,線程池會阻塞,直到TestMethod調用Monitor.Exist(t)! //由於已經對t添加了Monitor.Enter,其餘線程沒法訪問t。 ThreadPool.QueueUserWorkItem(o => {Console.WriteLine(t.M); });
//執行其餘代碼
Monitor.Exist(t); }
TestMethod方法會形成線程池的阻塞,緣由是已經對t添加了Monitor.Enter,其餘線程沒法訪問t,究其緣由,Monitor不應設計成靜態類,而是應該和其餘鎖同樣,設計成正常的類,而後初始化Monitor,就不會形成上述問題,由於鎖是獨有的。解決的辦法是單獨聲明一個只讀的字段用來鎖定,以下:
object readonly obj = new object(); Monitor.Enter(obj); //執行其餘操做 Monitor.Exist(obj);
避免對訪問對象調用Monitor.Enter,而是對單獨的字段來進行鎖定。
以上,就是本文的所有內容,至此,我完成了C#多線程編程系列的所有內容。本文的知識點可能是來自《CLR via C#》,說是該書最後一部分線程基礎的總結和梳理也不爲過。還有一部分是來自《果殼中的C#》,雖然該書內容較淺,不少知識都是淺嘗輒止,可是我在開始看《CLR via C#》時,有點讀不明白,由於知識點不少,並且太細了,反而有些無法掌握,偶然翻開《果殼》,簡單的看了一下多線程部分,發現這本書講的提綱挈領,我一會兒就明白很多。
若是您對本文有任何問題,歡迎在評論區與我互動。