前面我們學習了很多用於線程管理的 類型,也學習了多種線程同步的使用方法,這一篇主要講述線程等待相關的內容。
在筆者認真探究多線程前,只會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 結構
微軟文檔定義:為基於自旋的等待提供支持。
如果你想了解 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 毫秒。