C#多線程(11):線程等待



前面我們學習了很多用於線程管理的 類型,也學習了多種線程同步的使用方法,這一篇主要講述線程等待相關的內容。

在筆者認真探究多線程前,只會new Thread;鎖?Lock;線程等待?Thread.Sleep()

前面已經探究了創建線程的創建姿勢和各種鎖的使用,也學習了很多類型,也使用到了很多種等待方法,例如 Thread.Sleep()Thread.SpinWait();{某種鎖}.WaitOne() 等。

這些等待會影響代碼的算法邏輯和程序的性能,也有可能會造成死鎖,在本篇我們將會慢慢探究線程中等待。

前言

volatile 關鍵字

volatile 關鍵字指示一個字段可以由多個同時執行的線程修改。

我們繼續使用《C#多線程(3):原子操作》中的示例:

        static void Main(string[] args)
        {
            for (int i = 0; i < 5; i++)
            {
                new Thread(AddOne).Start();
            }
            Thread.Sleep(TimeSpan.FromSeconds(5));
            Console.WriteLine("sum = " + sum);
            Console.ReadKey();
        }
        private static int sum = 0;
        public static void AddOne()
        {
            for (int i = 0; i < 100_0000; i++)
            {
                sum += 1;
            }
        }

運行后你會發現,結果不為 500_0000,而使用 Interlocked.Increment(ref sum);后,可以獲得准確可靠的結果。

你試試再運行下面的示例:

        static void Main(string[] args)
        {
            for (int i = 0; i < 5; i++)
            {
                new Thread(AddOne).Start();
            }
            Thread.Sleep(TimeSpan.FromSeconds(5));
            Console.WriteLine("sum = " + sum);
            Console.ReadKey();
        }
        private static volatile int sum = 0;
        public static void AddOne()
        {
            for (int i = 0; i < 100_0000; i++)
            {
                sum += 1;
            }
        }

你以為正常了?哈哈哈,並沒有。

volatile 的作用在於讀,保證了觀察的順序和寫入的順序一致,每次讀取的都是最新的一個值;不會干擾寫操作。

詳情請點擊:https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/keywords/volatile

其原理解釋:https://theburningmonk.com/2010/03/threading-understanding-the-volatile-modifier-in-csharp/

三種常用等待

這三種等待分別是:

Thread.Sleep();
Thread.SpinWait();
Task.Delay();

Thread.Sleep(); 會阻塞線程,使得線程交出時間片,然后處於休眠狀態,直至被重新喚醒;適合用於長時間的等待;


Thread.SpinWait(); 使用了自旋等待,等待過程中會進行一些的運算,線程不會休眠,用於微小的時間等待;長時間等待會影響性能;


Task.Delay(); 用於異步中的等待,異步的文章后面才寫,這里先不理會;


這里我們還需要繼續 SpinWait 和 SpinLock 這兩個類型,最后再進行總結對照。

再說自旋和阻塞

前面我們學習過自旋和阻塞的區別,這里再來擼清楚一下。

線程等待有內核模式(Kernel Mode)和用戶模式(User Model)。

因為只有操作系統才能控制線程的生命周期,因此使用 Thread.Sleep() 等方式阻塞線程,發生上下文切換,此種等待稱為內核模式。

用戶模式使線程等待,並不需要線程切換上下文,而是讓線程通過執行一些無意義的運算,實現等待。也稱為自旋。

SpinWait 結構

微軟文檔定義:為基於自旋的等待提供支持。

SpinWait 是結構體;Thread.SpinWait() 的原理就是 SpinWait 。
如果你想了解 Thread.SpinWait() 是怎么實現的,可以參考 https://www.tabsoverspaces.com/233735-how-is-thread-spinwait-actually-implemented

線程阻塞是會耗費上下文切換的,對於過短的線程等待,這種切換的代價會比較昂貴的。在我們前面的示例中,大量使用了 Thread.Sleep() 和各種類型的等待方法,這其實是不合理的。

SpinWait 則提供了更好的選擇。

屬性和方法

老規矩,先來看一下 SpinWait 常用的屬性和方法。

屬性:

屬性 說明
Count 獲取已對此實例調用 SpinOnce() 的次數。
NextSpinWillYield 獲取對 SpinOnce() 的下一次調用是否將產生處理器,同時觸發強制上下文切換。

方法:

方法 說明
Reset() 重置自旋計數器。
SpinOnce() 執行單一自旋。
SpinOnce(Int32) 執行單一自旋,並在達到最小旋轉計數后調用 Sleep(Int32) 。
SpinUntil(Func) 在指定條件得到滿足之前自旋。
SpinUntil(Func, Int32) 在指定條件得到滿足或指定超時過期之前自旋。
SpinUntil(Func, TimeSpan) 在指定條件得到滿足或指定超時過期之前自旋。

自旋示例

下面來實現一個讓當前線程等待其它線程完成任務的功能。

其功能是開辟一個線程對 sum 進行 +1,當新的線程完成運算后,主線程才能繼續運行。

    class Program
    {
        static void Main(string[] args)
        {
            new Thread(DoWork).Start();

            // 等待上面的線程完成工作
            MySleep();

            Console.WriteLine("sum = " + sum);
            Console.ReadKey();
        }

        private static int sum = 0;
        private static void DoWork()
        {
            for (int i = 0; i < 1000_0000; i++)
            {
                sum++;
            }
            isCompleted = true;
        }

        // 自定義等待等待
        private static bool isCompleted = false;
        private static void MySleep()
        {
            int i = 0;
            while (!isCompleted)
            {
                i++;
            }
        }
    }

新的實現

我們改進上面的示例,修改 MySleep 方法,改成:

        private static bool isCompleted = false;        
        private static void MySleep()
        {
            SpinWait wait = new SpinWait();
            while (!isCompleted)
            {
                wait.SpinOnce();
            }
        }

或者改成

        private static bool isCompleted = false;        
        private static void MySleep()
        {
            SpinWait.SpinUntil(() => isCompleted);
        }

SpinLock 結構

微軟文檔:提供一個相互排斥鎖基元,在該基元中,嘗試獲取鎖的線程將在重復檢查的循環中等待,直至該鎖變為可用為止。

SpinLock 稱為自旋鎖,適合用在頻繁爭用而且等待時間較短的場景。主要特征是避免了阻塞,不出現昂貴的上下文切換。

筆者水平有限,關於 SpinLock ,可以參考 https://www.c-sharpcorner.com/UploadFile/1d42da/spinlock-class-in-threading-C-Sharp/

另外,還記得 Monitor 嘛?SpinLock 跟 Monitor 比較像噢~https://www.cnblogs.com/whuanle/p/12722853.html#2monitor

在《C#多線程(10:讀寫鎖)》中,我們介紹了 ReaderWriterLock 和 ReaderWriterLockSlim ,而 ReaderWriterLockSlim 內部依賴於 SpinLock,並且比 ReaderWriterLock 快了三倍。

屬性和方法

SpinLock 常用屬性和方法如下:

屬性:

屬性 說明
IsHeld 獲取鎖當前是否已由任何線程占用。
IsHeldByCurrentThread 獲取鎖是否已由當前線程占用。
IsThreadOwnerTrackingEnabled 獲取是否已為此實例啟用了線程所有權跟蹤。

方法:

方法 說明
Enter(Boolean) 采用可靠的方式獲取鎖,這樣,即使在方法調用中發生異常的情況下,都能采用可靠的方式檢查 lockTaken 以確定是否已獲取鎖。
Exit() 釋放鎖。
Exit(Boolean) 釋放鎖。
TryEnter(Boolean) 嘗試采用可靠的方式獲取鎖,這樣,即使在方法調用中發生異常的情況下,都能采用可靠的方式檢查 lockTaken 以確定是否已獲取鎖。
TryEnter(Int32, Boolean) 嘗試采用可靠的方式獲取鎖,這樣,即使在方法調用中發生異常的情況下,都能采用可靠的方式檢查 lockTaken 以確定是否已獲取鎖。
TryEnter(TimeSpan, Boolean) 嘗試采用可靠的方式獲取鎖,這樣,即使在方法調用中發生異常的情況下,都能采用可靠的方式檢查 lockTaken 以確定是否已獲取鎖。

示例

SpinLock 的模板如下:

        private static void DoWork()
        {
            SpinLock spinLock = new SpinLock();
            bool isGetLock = false;     // 是否已獲得了鎖
            try
            {
                spinLock.Enter(ref isGetLock);
                // 運算
            }
            finally
            {
                if (isGetLock)
                    spinLock.Exit();
            }
        }

這里就不寫場景示例了。

需要注意的是, SpinLock 實例不能共享,也不能重復使用。

等待性能對比

大佬的文章,.NET 中的多種鎖性能測試數據:http://kejser.org/synchronisation-in-net-part-3-spinlocks-and-interlocks/

這里我們簡單測試一下阻塞和自旋的性能測試對比。

我們經常說,Thread.Sleep() 會發生上下文切換,出現比較大的性能損失。具體有多大呢?我們來測試一下。(以下運算都是在 Debug 下測試)

測試 Thread.Sleep(1)

        private static void DoWork()
        {
            Stopwatch watch = new Stopwatch();
            watch.Start();
            for (int i = 0; i < 1_0000; i++)
            {
                Thread.Sleep(1);
            }
            watch.Stop();
            Console.WriteLine(watch.ElapsedMilliseconds);
        }

筆者機器測試,結果大約 20018。Thread.Sleep(1) 減去等待的時間 10000 毫秒,那么進行 10000 次上下文切換需要花費 10000 毫秒,約每次 1 毫秒。

上面示例改成:

            for (int i = 0; i < 1_0000; i++)
            {
                Thread.Sleep(2);
            }

運算,發現結果為 30013,也說明了上下文切換,大約需要一毫秒。

改成 Thread.SpinWait(1000)

            for (int i = 0; i < 100_0000; i++)
            {
                Thread.SpinWait(1000);
            }

結果為 28876,說明自旋 1000 次,大約需要 0.03 毫秒。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM