.NET面試題系列[18] - 多線程同步(1)

多線程:線程同步

同步基本概念

多個線程同時訪問共享資源時,線程同步用於防止數據損壞或發生沒法預知的結果。對於僅僅是讀取或者多個線程不可能同時接觸到數據的狀況,則徹底不須要進行同步。編程

線程同步一般是使用同步鎖來實現的。經過實現各類各樣構造的鎖,保證在一個特定的時間內,只有一個或有限個線程進入關鍵代碼段訪問資源。當線程進入代碼段時,它得到鎖,或將信號量減小1,當線程離開時,它釋放鎖,或將信號量增長1。鎖也能夠當作是一個信號量。數組

線程同步既繁瑣又容易出錯,並且對鎖的獲取和釋放是須要時間的。鎖的開銷具體要損耗多少時間,取決於選擇的鎖的種類。鎖能夠分爲自旋鎖,互斥鎖和混合鎖。自旋鎖一般由用戶模式構造實現,互斥鎖則由內核模式構造實現。安全

若是多個線程同時訪問只讀數據(例如具備不可變性的數據,如字符串),則是沒有任何問題的,不須要進行同步。在使用值類型時,由於它們老是會被複制,因此每一個線程操做的都是它本身的副本。線程安全不意味着必定會有鎖的出現。性能優化

自旋鎖,互斥鎖和鎖的遞歸調用

自旋鎖和互斥鎖的區別相似輪詢和回調。前者不停請求,後者等待通知。自旋鎖與互斥鎖相似,它們都是爲了解決對某項資源的互斥使用。不管是互斥鎖仍是自旋鎖,在任什麼時候刻,最多隻能有一個保持者,可是二者在調度機制上略有不一樣。對於互斥鎖,若是資源已經被佔用,資源申請者只能進入睡眠狀態,等待以後被喚醒。可是自旋鎖不會引發調用者睡眠,若是自旋鎖已經被別的執行單元保持,調用者就一直循環在那裏看是否該自旋鎖的保持者已經釋放了鎖,"自旋"一詞就是所以而得名。由此咱們能夠看出,自旋鎖是一種比較低級的保護數據結構或代碼片斷的原始方式,這種鎖可能存在兩個問題:活鎖和過多佔用cpu資源。數據結構

自旋鎖比較適用於鎖使用者保持鎖時間比較短的狀況。正是因爲自旋鎖使用者通常保持鎖時間很是短,所以選擇自旋而不是睡眠是很是必要的。多線程

互斥鎖適用於鎖使用者保持鎖時間比較長的狀況,它們會致使調用者睡眠。ide

另外格外注意一點:自旋鎖不能遞歸使用。函數

某些互斥鎖例如Mutex支持遞歸使用。若是一個鎖能夠遞歸使用,它須要維護一個整型變量,其意義爲,擁有這個鎖的線程擁有了它多少次。若是一個線程當前擁有一個遞歸鎖,而後它又在這個鎖上等待,那麼它再次持有該鎖,整型變量的值加一。當它釋放鎖時,整型變量的值減一,只有整型變量的值爲0時,另外一個線程纔可以得到鎖。你徹底能夠本身寫一個支持遞歸的鎖,而不是使用Mutex。工具

基元構造線程同步

Windows的線程同步方式可分爲2種,用戶模式構造和內核模式構造。經過C#,咱們還能夠創造出混合構造,它吸取了上面兩種方式的優勢,但Windows不具有產生混合構造鎖的能力。性能

內核模式構造是由Windows系統自己使用,內核對象進行調度協助的。內核對象是系統地址空間中的一個內存塊,由系統建立維護。內核對象爲內核所擁有,而不爲進程所擁有,因此不一樣進程能夠訪問同一個內核對象(因此內核模式構造的鎖能夠跨進程同步), 如WaitHandle,信號量,互斥量等都是Windows專門用來幫助咱們進行線程同步的內核對象。

用戶模式構造是由特殊CPU指令來協調線程,一般用於進行原子操做。volatile實現就是一種,Interlocked也是。用戶模式構造的速度要顯著快於內核模式的構造,這是由於他們使用了特殊CPU指令來協調線程,協調是在硬件中發生的。

混合構造兼具用戶模式和內核模式的特色。

用戶模式構造(User Mode Constructs)

用戶模式構造使用的是自旋鎖。它利用特殊的CPU指令,實現原子操做,因此它的速度遠快於內核模式構造。它的缺點是:當一個線程在一個以用戶模式構造建立的鎖(以及得到鎖的線程)上阻塞了,Windows不會知道這個狀況的發生(操做系統只知道內核模式構造的鎖中發生的事情)。因此,操做系統會認爲這個線程正在良好的運行,從而不會將屬於它的時間片分給其餘線程。

在極端狀況下,若是這個被阻塞的線程永遠拿不到鎖,它將永遠自旋下去(輪詢鎖的狀態),從而浪費CPU資源,這種現象稱爲活鎖。活鎖既浪費CPU又浪費內存(由於這個悲劇的線程自己也佔用必定的內存)。和死鎖相比,死鎖更好一些,由於它不會浪費CPU。

.NET中爲咱們提供了兩種用戶模式構造:

  1. Thread.VolatileRead 和 Thread.VolatileWrite:易失構造,它在包含一個簡單數據類型的變量上執行原子性的讀寫操做。
  2. System.Threading.Interlocked:互鎖構造,它在包含一個簡單數據類型的變量上執行原子性的讀寫操做。

對於易失構造,C#提供了volatile關鍵字,確保該關鍵字修飾的字段在讀或寫時,是原子的,也就是說一次只能有一個線程對其進行讀寫。固然,也能夠經過互鎖構造修改字段,此時不須要volatile關鍵字,只須要調用Interlocked的方法來修改便可,它保證了操做是原子的。Interlocked雖然只提供了Add方法,可是咱們也能夠實現諸如乘除等其餘方式對值進行更改,能夠參考CLR via C#的Interlocked Anything模式這一節,這裏就略過。

這兩種構造僅會讀或寫一個字段,而這是一個耗時很短的操做。因此對這種狀況,使用用戶模式構造是合理的,由於阻塞的線程只會自旋很短一段的時間,以後就能夠正常工做。使用內核模式反而會過於臃腫,光是維護信號量或Event構造,發送通知的代價就遠遠大於自旋了。

使用用戶模式構造的例子

最多見的例子,即是對整型變量不斷累加了。首先是沒有使用鎖的作法。這種作法獲得的結果是不穩定的。

number = 0;
            Stopwatch sw = Stopwatch.StartNew();

            Parallel.For(0, 100000, (i) =>
            {
                number++;
            });
            Console.WriteLine("Result: " + number);
            Console.WriteLine("Time is :{0} ms", sw.ElapsedMilliseconds);
View Code

易失構造沒有add方法,因此不能進行累加的動做。使用用戶模式構造(互鎖構造)的add方法,能夠獲得正確的結果100000:

sw.Restart();
            number = 0;

            Parallel.For(0, 100000, (i) =>
            {
                Interlocked.Add(ref number, 1);
            });
            Console.WriteLine("Result: " + number);
            Console.WriteLine("Time is :{0} ms", sw.ElapsedMilliseconds);
View Code

雖然每次的耗時並不相同,但這2種作法耗時並無太大的差異。

實現自旋鎖

實現自旋鎖須要藉助Interlocked.Exchange方法。它會將其一個變量修改成某個值,而後返回原來的值

實現一個鎖理論上只須要兩個方法:得到鎖和釋放鎖。

    public class SimpleSpinLock
    {
        private int _flag;
        public void Enter()
        {
            //若是flag不等於1,則將它置爲1並離開while循環
            //不然就一直在循環裏面自旋,直到有一個線程把flag改爲0爲止
            while (Interlocked.Exchange(ref _flag, 1)!=0)
            {
               //性能優化代碼
            }
        }

        public void Exit()
        {
            //離開關鍵代碼段,將flag置爲0
            Thread.VolatileWrite(ref _flag, 0);
        }
    }
View Code

若是兩個線程同時調用Enter,Interlocked.Exchange會確保其中一個線程將flag的值從0變成1,而後,它發現flag的原始值是0,因而它退出while,離開Enter,進入關鍵代碼段,繼續執行其餘代碼(在下面的例子中,它會給number的值增長1)。另外一個線程會將flag從1變成1。可是它發現flag的原始值是1。此時,它沒法離開while,會不停的調用Exchange(開始旋轉)直到第一個線程調用Exit。調用Exit以後flag的值就又變成0了,這將把其餘旋轉的線程中的某個幸運兒解放出來。

使用自旋鎖:

sw.Restart();
            SimpleSpinLock ssl = new SimpleSpinLock();
            number = 0;
            Parallel.For(0, 1000000, (i) =>
            {
                ssl.Enter();
                number++;
                ssl.Exit();
            });
            Console.WriteLine("Result: " + number);
            Console.WriteLine("Time is :{0} ms", sw.ElapsedMilliseconds);
View Code

咱們仍然能夠獲得正確的結果。不過,此次咱們的代碼用了比較多的時間完成(相比以前幾回,都不會超過100毫秒,而此次使用本身實現的鎖,一般都好幾百毫秒才完成)。.NET爲咱們提供了一個現成的自旋鎖SpinLock,經過使用它,代碼的耗時會少一些,也就比100毫秒多一點。

SpinLock ss2 = new SpinLock();            
            number = 0;

            Parallel.For(0, 1000000, (i) =>
            {
                bool lockTaken = false;
                try
                {
                    ss2.Enter(ref lockTaken);
                    number++;
                }
                finally
                {
                    if (lockTaken)
                        ss2.Exit();
                }
            });

            Console.WriteLine("Result: " + number);
            Console.WriteLine("Time is :{0} ms", sw.ElapsedMilliseconds);
View Code

在咱們本身實現的鎖的Enter方法中,while循環內部什麼都沒作。可是.NET的自旋鎖SpinLock,while循環內部作了一些時間片方面的優化(使用了一個叫作SpinWait的東東),這是它的性能好於咱們本身實現的鎖的緣由。具體是如何優化的我也不清楚。

正如上面已經說過的,自旋鎖只適合保護那些關鍵代碼段執行的很是快的情形。當佔有鎖的線程優先級較低時,可能會發生活鎖。此時佔有鎖的線程沒有機會運行。

內核模式構造(Kernel Mode Constructs)

內核模式構造須要一個內核對象,以及操做系統自身的協做,它是經過調用操做系統的API來管理線程的,詳細資料,能夠參考《WINDOWS核心編程》這本書。

它比VolatileRead,VolatileWrite,Interlocked等用戶模式的構造慢不少。這是由於在內核對象上調用的每一個方法都會形成調用線程從託管代碼(例如你的代碼調用了WaitOne)轉換到本地用戶模式的代碼,再轉換爲本地內核模式代碼。而後,還要朝相反的方向一路返回。內核模式構造須要經過調用操做系統自己的API。

然而內核模式的構造也具備一些優勢:

  1. 若是檢測到資源競爭,使用內核模式的構造會令操做系統阻塞未能獲取鎖的線程,讓它到等待隊列去,而不是令它一直自旋下去,無謂的浪費處理器資源。當鎖再次變得可用時,能夠經過發送一個通知(例如Event構造中的Set)喚醒等待隊列中的線程。
  2. 內核模式的構造能夠同步不一樣進程中運行的線程。用戶模式構造不是經過WaitHandle實現的,不能跨進程。
  3. 因爲內核模式構造同時須要託管代碼和本地代碼,因此內核模式構造能夠同步在本地和在託管代碼中的線程(用戶模式構造不會經本地代碼,直接從託管代碼跑到CPU指令)。
  4. 一個線程能夠一直阻塞,直到一個線程集合中的全部內核對象所有可用,或部分可用。(WaitAll,WaitAny)用戶模式構造不能模擬這種一等多的情景。
  5. 內核模式的構造上阻塞的一個線程能夠指定一個超時值,若是過了這段時間,線程能夠解除阻塞並執行其餘任務。

全部的內核模式構造均可以當作是事件和信號量的某種特殊狀況。事件構造能夠當作是內核維護一個布爾型的內核對象,信號量構造(互斥體是信號量=1的狀況)能夠當作是內核維護一個整型的內核對象。因此,內核模式構造須要WaitHandle類型,它包裝了一個內核對象。

內核模式構造適合:

1.  某個線程須要較長時間佔用資源的情形

2.  跨進程同步,例如保證任何給定時刻,只容許程序的一個實例運行

經過WaitHandle操做內核對象

在Windows編程中,經過Windows API建立一個內核對象後,會返回一個句柄,句柄是每一個進程句柄表的索引,然後能夠拿到內核對象的指針、掩碼、標示等。而WaitHandle抽象基類的做用是:包裝了一個Windows內核對象的句柄,使得咱們不須要直接和Windows API打交道。在內部,WaitHandle抽象基類擁有一個字段SafeWaitHandle,它就是Windows內核對象的句柄。

WaitHandle在線程同步時被全部線程共享。不管是事件構造仍是信號量,它們都派生自WaitHandle類型。

WaitHandle提供了下列共同的靜態方法:

  • WaitOne:阻塞調用線程,直到調用線程本身收到一個信號(經過Set方法,由於WaitHandle沒有Set方法,因此通常用它的派生類AutoResetEvent或ManualResetEvent)。
  • WaitAny:須要傳入一個WaitHandle數組,阻塞調用線程,直到WaitHandle數組中任意一個WaitHandle收到一個信號。若是你沒有指定等待時間,則時間是無限長。
  • WaitAll:須要傳入一個WaitHandle數組,阻塞調用線程,直到WaitHandle數組中全部WaitHandle收到信號。若是你沒有指定等待時間,則時間是無限長。

這些方法會繼續調用Windows中的對應API。

WaitHandle的派生類: 

WaitHandle

  |——EventWaitHandle                    事件構造

    |——AutoResetEvent

    |——ManualResetEvent

  |——Semaphore                         信號量構造

  |——Mutex                                 互斥體構造

Event構造

「Events are simply Boolean variables maintained by the kernel.」

Event構造維護一個布爾型的內核對象,它被全部線程共享。它若是爲false,在事件上等待的線程就阻塞,不然就解除阻塞。Set方法將布爾對象置爲true,Reset方法則置爲false。

在調用了AutoResetEvent的Set方法以後,布爾對象置爲true,等待隊列(包括全部呼叫了WaitOne方法的線程)中的第一個線程被容許進入(再也不是阻塞狀態),以後,AutoResetEvent自動調用Reset,將布爾對象置爲false,使得僅有一個線程解除阻塞。在調用了ManualResetEvent的Set方法以後,全部等待隊列中的線程都解除阻塞。你必須本身調用Reset方法將布爾對象從新置爲false。

若是有任何線程呼叫了方法WaitOne,呼叫線程將進入阻塞狀態,並加入等待隊列,直到有任何其餘線程呼叫了方法Set,或者布爾對象自己就是true。Set就像打開門的動做同樣,把等待隊列的第一個成員放進門裏來(ManualResetEvent的話則是等待隊列中的全部成員),解除它的阻塞狀態。若是布爾對象一開始就是true,則WaitOne的阻塞當即解除,而後線程繼續運做,AutoResetEvent自動調用Reset將門關閉。

AutoResetEvent的合適應用場景爲:一條線程開門,只解除一條線程阻塞的狀況。

ManualResetEvent的合適應用場景爲:一條線程開門,能夠解除多條線程阻塞的狀況。

CountdownEvent的合適應用場景爲:多條線程所有完畢才能夠(自動致使)開門,也就是一條線程等待多條線程的狀況。

AutoResetEvent

AutoResetEvent就像一個插票的旋轉門:插入一張票只能讓一我的經過。在這裏,Auto的意思是旋轉門的行爲是自動的(即經過以後,自動關門,下一我的必需要再插票才能經過)。當一我的(一條線程)到達旋轉門以後,它必須等待/被阻塞(經過呼叫WaitOne方法,在門前等待),直到有另一條線程通知他爲止(經過呼叫Set方法),此時,門開,它才能得以經過旋轉門。

每個呼叫WaitOne方法的線程都會被記錄,從而造成一個隊列。當某個線程呼叫了Set以後,隊列中的第一個線程被容許進入(再也不是阻塞狀態)。若是呼叫Set以後,隊列中沒有任何線程,則句柄將一直開着,等待有線程呼叫WaitOne方法。隊列中沒有任何線程時,屢次呼叫Set,並不會致使多個線程能夠一塊兒經過旋轉門,只有最後一個Set有用,以前的會被抵消。

class BasicWaitHandle
        {
            //創建一個新的AutoResetEvent對象
            //若是參數爲true,至關於馬上呼叫了set方法一次
            static EventWaitHandle _waitHandle = new AutoResetEvent(false);
 
            static void Main()
            {
                new Thread(Waiter).Start();
                Thread.Sleep(1000);                  // Pause for a second...
                _waitHandle.Set();                    // Wake up the Waiter.
            }

            static void Waiter()
            {
                Console.WriteLine("Waiting...");
                _waitHandle.WaitOne();                // Wait for notification
                Console.WriteLine("Notified");
            }
        }
View Code

如圖所示。注意咱們的AutoResetEvent的構造函數中,咱們傳入了false,這意味着一開始旋轉門是關着的。因此當子線程呼叫WaitOne以後,只能一直等待(注意WaitOne不會改變布爾內核對象的值!若是內核對象爲true,阻塞就解除了),直到主線程呼叫Set,就如同給子線程一個信號通常,子線程就離開阻塞狀態,從新回到Running狀態。

 

若是AutoResetEvent的構造函數中傳入true,則意味着一開始門是開着的,此時,第一個呼叫WaitOne的線程不會被阻塞,它會直接進入旋轉門,而後,根據AutoResetEvent的性質,它自動調用Reset,旋轉門會關閉,第二個(以及後面全部)呼叫WaitOne的線程會被阻塞,這也就是鎖的功能。因此,當咱們使用AutoResetEvent實現鎖時,要爲構造函數中傳入true,不然,這個鎖一開始就不讓任何人進入,也就不叫鎖了。

WaitOne方法接受一個Timeout參數。若是該參數爲0,則至關於測試目前的AutoResetEvent是否處於「開啓狀態」。

若是你用完了一個句柄,應該用Close方法將他丟棄。固然你也能夠丟棄全部這個句柄的reference從而令垃圾收集器將其視爲垃圾。

ManualResetEvent

ManualResetEvent如同一個普通的大門。呼叫Set方法將會打開大門,讓全部以前呼叫過WaitOne的線程都得以經過。呼叫Reset關閉大門。ManualResetEventSlim做爲其輕量級的版本,一樣不能跨進程。但其的開門時間較ManualResetEvent短些,並且容許全部WaitOne的線程取消等待。

ManualResetEvent的合適應用場景爲一條線程開門,能夠解除多條線程阻塞的狀況。

ManualResetEvent與AutoResetEvent的區別

ManualResetEvent也能夠作到上文中模擬場景的效果。代碼如出一轍,除了要把事件體的類型改變爲ManualResetEvent。

ManualResetEvent與AutoResetEvent的區別在於,當某個線程調用了Set以後,全部以前調用過WaitOne的線程都會恢復工做,至關於門開了以後一直不關,任何人均可以進來。此時,能夠調用ManualResetEvent的Reset方法,從新把門關上,此時,若是等待隊列還有線程,則它們要再等待某個線程調用Set。而AutoResetEvent中,當某個線程調用了Set以後,等待隊列的第一個線程會恢復工做,並與此同時,自動調用Reset方法把門關上。

使用AutoResetEvent實現鎖

注意事項:

1. 要實現IDisposable接口

2. 初始化時將參數設爲true,會獲得咱們想要的效果。

3. 通常實現一個鎖只須要實現進入和離開兩個方法。進入時,旋轉門自動關閉,阻塞其餘全部也想進入的線程,離開時則提供一個信號供其餘線程進入。

代碼以下:

class AutoResetEventLock : IDisposable
    {
        //initialize: true
        private readonly AutoResetEvent _are = new AutoResetEvent(true);

        public void Enter()
        {
            //The calling thread blocks others
            _are.WaitOne();
        }
        public void Exit()
        {
            //Now the first thread in waiting queue may come in
            _are.Set();
        }
        public void Dispose()
        {
            _are.Dispose();
        }
    }
View Code

測試。結果應當是預期的(數字逐漸增長)。用時大約300 - 400毫秒。比起用戶模式構造,用時確實長了不少。

int number = 0;

            Stopwatch sw = Stopwatch.StartNew();
            AutoResetEventLock l = new AutoResetEventLock();

            System.Threading.Tasks.Parallel.For(0, 100000, (i) =>
            {
                l.Enter();
                number++;
                l.Exit();
            });
            Console.WriteLine("Result: " + number);
            Console.WriteLine("Time is :{0} ms", sw.ElapsedMilliseconds);
            Console.ReadKey();
View Code

信號量(Semaphore)構造

「Semaphore are simply Int32 variables maintained by the kernel.」

信號量的歷史十分久遠。1965年荷蘭科學家Dijkstra提出了信號量,做爲一個卓有成效的進程同步工具(那時候尚未出現線程)。固然,信號量也適用於線程。信號量是內核維護的一個整型變量,因此也是內核對象。它容許最多n個線程在關鍵代碼段中。互斥量則是n最大爲1的信號量。和互斥量不一樣的是,任何一個在關鍵代碼段中的線程均可以釋放信號量(離開關鍵代碼段)。這並不會對其餘的線程形成影響,而因爲容量不夠而在外面排隊的線程則得以進入。

使用信號量就是不斷修改整型變量的過程。修改只有兩種方式,稱爲V(又稱signal())與P(wait())。V操做會增長信號量S的數值,P操做會減小它。信號量的值初始爲n。當一個線程進入關鍵代碼段時,經過P操做爲信號量的值減一,當一個線程離開關鍵代碼段時,經過V操做爲信號量的值加一。當信號量爲0時,在外面排隊的線程就被阻塞,直到有線程離開關鍵代碼段,因此信號量的值永遠不會小於0。

V與P操做是歷史術語,在C#中,FCL提供了Release和WaitOne。

信號量和互斥量都是內核對象,能夠做用於多個進程。SemaphoreSlim是輕量級的信號量實現,於.NET 4.0中出現。它的釋放和佔有速度較快,但不能像互斥量同樣做用於多個進程。

使用信號量實現鎖

使用信號量實現鎖十分簡單。在此我就以信號量最大爲1(其實是一個互斥體)作例子。使用信號量實現鎖和直接用Semaphore類基本沒區別,因此一般直接使用C#提供的Semaphore類就能夠了。

public class SemaphoreLock : IDisposable
    {
        private readonly Semaphore s = new Semaphore(0, 1);

        public void Enter()
        {
            s.WaitOne();
        }
        public void Exit()
        {
            s.Release(1);
        }
        public void Dispose()
        {
            s.Dispose();
        }
    }
View Code

調用也是很是簡單,咱們的累加確定會獲得正確的值。耗時也是300-400毫秒左右。

int number = 0;

            Stopwatch sw = Stopwatch.StartNew();
            SemaphoreLock s = new SemaphoreLock();
            System.Threading.Tasks.Parallel.For(0, 100000, (i) =>
            {
                s.Enter();
                number++;
                s.Exit();
            });
            Console.WriteLine("Result: " + number);
            Console.WriteLine("Time is :{0} ms", sw.ElapsedMilliseconds);
            Console.ReadKey();
View Code

互斥量(Mutex)構造

Mutex的工做方式和AutoResetEvent或者計數爲1的信號量類似。由於這三種構造一次都只釋放一個正在等待的線程。

互斥量也能夠用於多個進程。對互斥量Mutex的進入和離開比lock慢一些(50倍長的時間)。加鎖的方法是使用WaitOne(),解鎖則是ReleaseMutex()。和鎖同樣,釋放鎖的對象必須是持有鎖的對象。一個使用互斥量的典型場景是保證同一時間,只有一個程序的實例在運行。

咱們能夠直接使用C#的Mutex類,固然,本身用互斥的方式實現鎖也很簡單,在上一節,實際上咱們實現的就是一個互斥量。一個必需要提的事情是,Mutex是支持遞歸的,因此若是你並不須要遞歸得到鎖,不要使用Mutex,由於支持遞歸須要額外維護一些變量,這會損失性能。即便你須要遞歸鎖,CLR via C#也推薦你本身實現一個(書中使用AutoResetEvent模擬了一個),實現遞歸鎖只須要額外維護一個整型變量,以及當前擁有鎖的線程ID便可,難度不大。

混合構造線程同步

混合線程同步構造合併了基元構造的用戶模式和內核模式,吸收了它們的優勢。在沒有競爭的狀況下,線程將會快速的進入關鍵代碼段(就像用戶模式的構造),若是存在競爭,阻塞線程的應當是操做系統內核,這樣該線程將會睡眠而不是自旋

一個簡單的混合鎖

下面是一個混合鎖的例子。這個鎖包含一個整型變量,令其可使用用戶模式的構造來使線程快速得到鎖,另外還借用了AutoResetEvent結構,使得線程在等待時睡眠而不是自旋。

public class SimpleHybridLock : IDisposable{
        private int waiters = 0;
        private readonly AutoResetEvent are = new AutoResetEvent(false);

        public void Enter()
        {
            //沒有爭用時,將int變量增長1,從而得到鎖
            //當大部分時候都沒有爭用時,不須要呼叫WaitOne,這是一個內核模式構造的方法,它會影響性能
            //相比之下,用戶模式的原子操做速度快得多,這裏吸取了用戶模式的優勢
            if(Interlocked.Increment(ref waiters) == 1) return;
            
            //發生爭用時,阻塞當前線程,直到收到通知
            //阻塞而不是自旋,這裏吸取了內核模式的優勢
            are.WaitOne();
        }

        public void Exit()
        {
            //理由同上,這裏就不重複了
            if (Interlocked.Decrement(ref waiters) == 0) return;
            are.Set();
        }

        public void Dispose()
        {
            are.Dispose();
        }
    }
View Code

調用Enter的第一個線程形成Interlocked.Increment在m_waiters字段上加1,使它的值變成1。這個線程發現之前有0個線程在等待這個鎖,因此它從Enter返回。(得到了鎖)這是若是另外一個線程進入並調用Enter,它會將m_waiters遞增到2,從而不知足if判斷,故它會調用WaitOne阻塞本身(而不是自旋)。

調用Exit時,道理相同。若是擁有鎖的進程調用Leave,發現m_waiters不是1,其會知道必然有至少一個線程因爲本身擁有鎖而被阻塞在外,它將喚醒一個線程。

使用混合鎖疊加10萬次消耗的時間大概爲200-300毫秒,相比AutoResetEvent的300-400毫秒,它的時間縮短了大概四分之一。若是大部分時間都沒有爭用,那麼AutoResetEvent的方法不會被調用,它也就不須要被初始化。將AutoResetEvent改成延遲初始化,會進一步加強性能。

使鎖支持遞歸和自旋

有的鎖支持遞歸調用。若是一個鎖能夠遞歸使用,它須要維護一個整型變量,其意義爲,擁有這個鎖的線程擁有了它多少次。若是一個線程當前擁有一個遞歸鎖,而後它又在這個鎖上等待,那麼它再次持有該鎖,整型變量的值加一。當它釋放鎖時,整型變量的值減一,只有整型變量的值爲0時,另外一個線程纔可以得到鎖。

令混合構造的鎖支持自旋,主要是爲了考慮關鍵代碼段耗時不多的情景。此時,因爲每次線程得到鎖以後,只會在關鍵代碼段工做很短的時間,這時其餘線程呼叫WaitOne,轉爲內核模式,就會形成較大的性能損失。咱們能夠令其餘線程自旋一段時間,嘗試得到鎖,經過犧牲一點CPU換來總體速度的提高。若是自旋時得到了鎖,則不須要轉爲內核模式。若是自旋事後仍然沒有得到鎖,才轉爲內核模式。理想的狀況若是老是能夠在自旋時得到鎖,整個混合鎖的性能將會大大提高。

public class SimpleMonitor : IDisposable
    {
        private int waiters = 0;
        private readonly AutoResetEvent are = new AutoResetEvent(false);

        private int currentThreadId;
        private int count;

        //自行指定的一個自旋時間
        private int spinningTime = 4000;

        public void Enter()
        {
            //若是調用線程已經擁有鎖則遞增遞歸次數
            if (currentThreadId == Thread.CurrentThread.ManagedThreadId)
            {
                count++;
                return;
            }

            var spinwait = new SpinWait();
            for(int spinCount = 0; spinCount < spinningTime; spinCount++)
            {
                //Interlocked.CompareExchange方法有三個參數,它比較第一個和第三個參數的值
                //若是它們相等,將第一個參數的值替換爲第二個參數的值
                //而且返回第一個參數的原始值
                //因此下面的代碼的意思是:若是waiters的初始值等於0,則將waiters替換成1
                //若是if成立,意味着waiters是從0變爲1的
                //也就意味着當前沒有人持有鎖
                if (Interlocked.CompareExchange(ref waiters, 1, 0) == 0)
                {
                    currentThreadId = Thread.CurrentThread.ManagedThreadId;
                    count = 1;
                    return;
                }
                //自旋一段極短的時間
                spinwait.SpinOnce();
            }

            //自旋時間結束以後仍然沒有得到鎖,意味着假設「鎖只會被線程持有很短的時間」失敗
            //此時只能轉爲內核模式,不能再自旋下去了
            if (Interlocked.Increment(ref waiters) > 1)
                are.WaitOne();

            currentThreadId = Thread.CurrentThread.ManagedThreadId;
            count = 1;
        }

        public void Exit()
        {
            //調用的線程不擁有鎖,代表存在一個bug
            if (Thread.CurrentThread.ManagedThreadId != currentThreadId)
                throw new SynchronizationLockException("調用的線程必須擁有鎖");

            //減小遞歸鎖的遞歸次數,若是這個線程屢次擁有鎖,則返回
            if (--count > 0) return;

            //重置擁有鎖的線程id,如今沒有線程擁有鎖
            currentThreadId = 0;

            //理由同上,這裏就不重複了
            if (Interlocked.Decrement(ref waiters) == 0) return;
            are.Set();
        }

        public void Dispose()
        {
            are.Dispose();
        }
    }
View Code

這個鎖的性能大大好於AutoResetEvent,疊加十萬次的耗時僅須要50-100毫秒。當每次咱們疊加時,鎖只會被線程擁有極短的一段時間,此時,咱們改用自旋就基本規避了內核模式形成的性能損失。實際上,這差很少就是C#中Monitor的實現方式。

ManualResetEventSlim和SemaphoreSlim類

這兩個類和非Slim版本的功能是相同的,除了它們使用了自旋策略延遲初始化它們的內核對象以外。因此,一般使用Slim版原本得到更好的性能。

Monitor類

Monitor是最經常使用的混合鎖。lock是Monitor的語法糖。Monitor支持自旋和遞歸擁有鎖。Monitor在加鎖和解鎖時,須要一個引用類型的對象,這是由於它藉助了同步塊索引來實現鎖。

Monitor提供一個tryEnter方法,其能夠輸入一個Timeout時間。若是過了這個時間仍然沒有得到鎖,則將終止嘗試得到鎖。這是Lock所不具有的。因此Monitor相對於lock更加靈活。

Monitor的遞歸調用(Reentrancy)

Monitor支持遞歸調用。在這個例子中,程序不會出現任何問題,會運行下去,到最後,i等於10,咱們一層層的離開關鍵代碼段,終止程序。

static void Main(string[] args)
        {
            DeadLockTest(20);
        }

        public static void DeadLockTest(int i)
        {
            object o = new object();
            lock (o)   //或者lock一個靜態object變量
            {
                if (i > 10)
                {
                    Console.WriteLine(i--);
                    DeadLockTest(i);
                }
            }
        }
View Code

只有最外層的鎖也被釋放,另一個進程才能夠進入關鍵代碼段。

同步塊索引(Sync block index)

CLR初始化時,在堆上分配了一個同步塊數組,能夠認爲這個數組擁有無限個成員。這些成員(同步塊)儲存了使鎖支持遞歸的信息(持有次數,線程ID等)。Monitor經過將堆上的對象關聯到同步塊數組中的成員來實現同步和支持遞歸。

當在堆上新建一個對象時,分配空間給類型指針和同步塊索引。同步塊索引的值爲-1,表示它目前沒有和任何同步塊數組的成員發生關係。當對象的同步塊索引爲-1時,任何線程均可以對其任意操做。

調用Monitor.Enter時,CLR檢查同步塊數組,並取出下一個可用的同步塊(若沒有會建立一個),而後將堆上的對象和這個同步塊關聯起來,此時對象的同步塊索引就再也不是-1了。這時,其餘的線程就不能對它操做了。當Exit時,重置爲-1,此時其餘的線程才能使用對象。同步塊數組能夠被重複利用。

所以,鎖對象必須是引用類型。但若是你這樣寫:

Parallel.For(0, 100000, (i) =>
            {
                object o = new object();
                lock (o)
                {
                    number++;
                }               
            });
View Code

很差意思,這樣寫是有問題的。由於你每次用來加鎖的對象都是新的,其實最後的結果就如同本沒加鎖,程序會極快的運行完,也就耗費幾十毫秒,同步塊數組中會有不少的同步塊,程序每次的結果都不同。那麼正確的寫法固然是把初始化拿出來就對了:

object o = new object();
            Parallel.For(0, 100000, (i) =>
            {              
                lock (o)
                {
                    number++;
                }               
            });
View Code

此時同步塊數組中只會有一個同步塊。

鎖對象選擇 – 不要lock類型對象

下面的代碼選擇了使用方法所在的類型的類型對象SynchroThis做爲鎖對象。它的問題是,若是其餘地方咱們持有這個類型的一個實例,並且這個實例剛好和關鍵代碼段的實例相同,則其餘地方的實例有機會影響到正常使用者的使用。這是由於類型對象只有一個,它是全局的。

class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("開始使用");
            SynchroThis st = new SynchroThis();

            // 模擬惡意的使用者
            // 其持有一個SynchroThis的實例,而後永遠不放 
            // 去掉這行程序就能夠正常工做
            Monitor.Enter(st);

            // 正常的使用者會受到惡意使用者的影響
            // 下面的代碼徹底正確,但永遠沒法進入關鍵代碼段,由於其餘地方持有的實例和這個實例相同 
            Thread thread = new Thread(st.Work);
            thread.Start();

            // 主線程永遠阻塞
            thread.Join();

            // 程序不會執行到這裏
            Console.WriteLine("使用結束");

            Console.ReadKey();
        }
    }

    public class SynchroThis
    {
        private int number;

        public void Work(object state)
        {
            lock (this)
            {
                Console.WriteLine("number如今的值爲:{0}", number);
                number++;
                // 模擬作了其餘工做
                Thread.Sleep(200);
                Console.WriteLine("number自增後值爲:{0}", number);
            }
        }
    }
View Code

ReaderWriterLockSlim類

這個類比較適合讀取數據內容的情景。它的構造像下面這樣控制線程:

  • 一個線程向數據寫入時,請求訪問的其餘全部線程都被阻塞
  • 一個線程讀取數據時,請求讀取的線程能夠繼續執行,請求寫入的則被阻塞
  • 數據寫入的一個線程結束後,要麼解除另外一個請求寫入的線程阻塞,要麼解除全部請求讀取的線程的阻塞
  • 全部從數據讀取的線程結束後,解除一個請求寫入的線程阻塞,若是沒有,則鎖成爲自由狀態,無人持有

FCL中提供了一個ReaderWriterLock構造,早在1.0時就有了。而ReaderWriterLockSlim構造是對它的改進。

性能比較

若是關鍵代碼段有且僅有原子操做,使用Interlocked最快,最靈活。

若是關鍵代碼段有較多操做,首選Monitor,若是還要支持跨進程則首選SemaphoreSlim。在讀寫同步的狀況下能夠考慮ReaderWriterLockSlim。

CountDownEvent能夠處理一條線程等待多條線程的狀況。

永遠不要使用:AutoResetEvent,ManualResetEvent,Semaphore,ReaderWriterLock。

 

 

種類

遞歸的

自旋的

性能概要

累加十萬
次時間(毫秒)

Volatile

用戶模式

Interlocked相比易失構造更靈活
(有Interlocked anything模式)

小於10

Interlocked

用戶模式

原子操做優先考慮用戶模式

小於10

AutoResetEvent

內核模式

考慮使用Monitor

200-400

ManualResetEvent

內核模式

考慮使用它的Slim版本

200-400

Semaphore

內核模式

考慮使用它的Slim版本

200-400

Mutex

內核模式

支持遞歸致使性能不佳
若是但願本身的鎖支持遞歸,能夠本身實現一個
跨進程時,使用SemaphoreSlim

200-400

Monitor

混合模式

支持

最經常使用的混合鎖,除了原子操做,跨進程以外的首選

小於10

ReaderWriterLock

混合模式

N/A

使用它的Slim版本

不適用

ReaderWriterLockSlim

混合模式

N/A

N/A

爲了增強讀線程的運行速度
考慮使用(全部只讀線程能夠一塊兒進入關鍵代碼段)。若是全是寫線程,則沒有必要使用

不適用

CountDownEvent

混合模式

支持

內部使用了ManualResetEventSlim
適合一條線程等待多條線程的情形

不適用

ManualResetEventSlim

混合模式

支持

和普通混合鎖相比,只有在第一次掃描到競爭時,
纔會建立ManualResetEvent
會自旋很短的一段時間,規避內核模式的性能損失
適合多條線程等待一條線程的情形

20-50

SemaphoreSlim

混合模式

支持

跨進程時使用,模擬信號量時使用。

20-50

相關文章
相關標籤/搜索