C#多線程編程(7)--鎖

  一提到線程同步,就會提到鎖,做爲線程同步的手段之一,鎖老是飽受質疑。一方面鎖的使用很簡單,只要在代碼不想被重入的地方(多個線程同時執行的地方)加上鎖,就能夠保證不管什麼時候,該段代碼最多有一個線程在執行;另外一方面,鎖又不像它看起來那樣簡單,鎖會形成不少問題:性能降低、死鎖等。使用volatile關鍵字或者Interlocked中提供的方法可以避開鎖的使用,可是這些原子操做的方法功能有限,不少操做實現起來很麻煩,如無序的線程安全集合。我在本系列的序中已經介紹了鎖的總類,自旋鎖、內核鎖(內核構造)、混合鎖,他們各有優缺點,下面就來一一介紹。html

  • 自旋鎖

  我在 C#多線程編程(6) 中已經介紹了簡單的利用Interlocked實現的自旋鎖,這種鎖的優勢是單線程時很是快,可是在竟態時會形成等待的線程「自旋」--無限while循環,在循環中只是在不斷的嘗試得到鎖,其餘什麼也不作。若是得到鎖的線程執行的很是快,那麼等待的線程在那自旋一會是值得的,由於當鎖被釋放時,自旋的線程可以立馬得到鎖。可是當得到鎖的線程執行的時間很長,等待線程的自旋就毫無心義了。設想這樣一個場景,A線程得到了鎖,B和C線程想要得到鎖,發現鎖已被其餘線程得到,就會開始自旋,而且A線程遲遲不願交出鎖,這至關於一個鎖佔用了3個線程,這是多麼大的浪費!編程

  • 內核鎖(在C#多線程編程序中,我曾把內核鎖叫成了信號量,是錯誤,現已更正。《CLR via C#》中叫內核構造)

  其實準確的說,內核鎖是內核維護的變量,《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屬性中。這是個抽象類,全部的內核鎖都繼承於此。簡單介紹下上面列出的幾個方法。數組

  1. WaitOne方法:等待內核對象收到信號,若是收到,就返回true,若是超時就返回false.
  2. WaitAll方法:等待waitHandles數組中任何一個內核變量的信號,返回的int值爲waitHandles的元素索引,若是超時,則返回WaitHandle.WaitTimeout。
  3. WaitAny方法:等待waitHandles所有返回信號。WaitAll和WaitAny接受的WaitHandle數組的最大數量爲64,超過64會拋出NotSupportedException。
  4. Dispose方法:釋放底層內核對象。不建議使用,留給GC本身處理什麼時候釋放內核對象爲好。

  下面介紹幾個從WaitHandle派生的類:AutoResetEvent、Semaphore、Mutex。安全

  • AutoResetEvent

  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

  信號量(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

   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(),而是自旋一段時間,若是得到鎖的線程執行了一段很短的程序,那麼在自旋的過程當中,鎖已經被釋放了,這時就能夠避免調用內核鎖來浪費資源。這是一種有針對性的修改。使鎖更適合那些較短的任務。

  • Monitor

  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#》時,有點讀不明白,由於知識點不少,並且太細了,反而有些無法掌握,偶然翻開《果殼》,簡單的看了一下多線程部分,發現這本書講的提綱挈領,我一會兒就明白很多。

  若是您對本文有任何問題,歡迎在評論區與我互動。 

相關文章
相關標籤/搜索