環境:
- window 10
- netcore 3.1.1
- vs2019 16.4.3
目的:
- 探索c#中的鎖、同步信號原理及使用方法
一、lock關鍵字
如果說c#中的鎖,那麼首當其衝的就是lock
關鍵字了。給lock關鍵字指定一個引用對象,然後上鎖,保證同一時間只能有一個線程在鎖裏。這應該是最我們最常用的場景了。注意:我們說的是一把鎖裏同時只能有一個線程,至於這把鎖用在了幾個地方,那就不確定了。比如:object lockobj=new object()
,這把鎖可以鎖一個代碼塊,也可以鎖多個代碼塊,但無論鎖多少個代碼塊,同一時間只能有一個線程打開這把鎖進去,所以會有人建議,不要用lock(typeof(Program))
或lock(this)
這種鎖,因爲這把鎖是所有人能看到的,別人可以用這把鎖鎖住自己的代碼,這樣就會出現一把鎖鎖住多個代碼塊的情況了,但現實使用中,一般沒人會這麼幹,所以即使我們在閱讀開源工程的源碼時也能常常見到lock(typeof(Program))
這種寫法,不過還是建議用私有字段做鎖,下面給出鎖的幾中應用場景:
- 當需要初始化對象時:
注意:下面的代碼使用的是一把鎖+內外判斷
,外層的判斷是防止多餘的鎖競爭,內層的判斷是爲了防止重複初始化class Program { private readonly object lockObj = new object(); private object obj = null; public void TryInit() { if (obj == null) { lock (lockObj) { if (obj == null) { obj = new object(); } } } } }
- 自動編號生成
下面的代碼只是一個實例,你也可以使用原子操作或直接id++
class DemoService { private static int id; private static readonly object lockObj = new object(); public void Action() { //do something int newid; lock (lockObj) { newid = id + 1; id = newid; } //use newid... } }
最後: 需要說明的是,lock關鍵字只不過是Monitor
的語法糖,也就是說下面的代碼:
lock (typeof(Program))
{
int i = 0;
//do something
}
被編譯成IL後就變成了:
try
{
Monitor.Enter(typeof(Program));
int i = 0;
//do something
}
finally
{
Monitor.Exit(typeof(Program));
}
我們反編譯看一下:
二、Monitor
參照:
Monitor.Pulse()的意義?如果不調用它會造成怎樣的後果?
C# Monitor的Wait和Pulse方法使用詳解
上面說了lock
關鍵字是Monitor的語法糖,那麼肯定Monitor
功能是lock的超集,所以這裏講講Monitor除了lock的功能外還有什麼:
- Monitor.Wait(lockObj):讓自己休眠並讓出鎖給其他線程用(其實就是發生了阻塞),直到其他在鎖內的線程發出脈衝(Pulse/PulseAll)後纔可從休眠中醒來開始競爭鎖。Monitor.Wait(lockObj,2000)則可以指定最大的休眠時間,如果時間到還沒有被喚醒那麼就自己醒。注意: Monitor.Wait有返回值,當自己醒的時候返回false,當其他線程喚醒的時候返回true,這主要是用來防止線程鎖死,返回值可以用來判斷是否向後執行或者是重新發起Monitor.Wait(lockObj)
- Monitor.Pulse或Monitor.PulseAll:喚醒由於Monitor.Wait休眠的線程,讓他們醒來參與競爭鎖。不同的是:Pulse只能喚醒一個,PulseAll是全部喚醒
下面把Monitor工作的流程做一個比喻:
一批人在醫院裏看病,診室裏面只能一個人進去看醫生,診室裏面有個鈴鐺按鈕,診室的門口站了幾個人等待進去,其中有A和B,診室外面板凳上有幾個人在睡覺(現在輪不到他們,所以他們在睡覺,還可能自帶鬧鐘哦)其中有C。好了現在說下他們分別代表的對象:
醫生: cpu
病人們: 線程
診室: 上了鎖的代碼塊
站在診室門口的病人: 正在競爭鎖的線程(處在就緒區)
坐在旁邊睡覺的病人: 執行了Monitor.Wait(lovkObj)的線程(處在等待區),對於那些帶了鬧鐘的病人,就是執行了Monitor.Wait(lockObj,2000)的
診室裏病人按鈴鐺: 正在鎖內的線程執行了Monitor.Pulse或Monitor.PulseAll
現在,開始看病:
首先A和B競爭,最後A競爭進去,B就在外面等着,A進去之後開始檢查看病,檢查中A發現自己拍的血常規化驗結果還沒出來於是自己就只能中斷看病去外面睡覺等一會了,不過A在出去前按了一下診室裏的鈴鐺,鈴鐺一響,坐在板凳上睡覺的C被喚醒了,然後C發現自己是被鈴鐺吵醒的說明診室通知自己去做準備了,於是C就走到診室門口和B一起焦急的等待了,這時候A開始退出診室,不過A走出去的時候給自己定了一個鬧鐘(最多睡三分鐘),然後醫生就讓A走出診室了並且把A的的檢查資料和進度放在了一遍以等A下次進來後接着檢查看病,A出來後B和C開始競爭,最後C贏了,於是C進去看病了,C進去之後就看了一會就出來了,此時B還等在門外而A還在睡覺,於是B毫無競爭壓力的就進去了,這個B看的時間有點長,所以B看病的期間A就醒了一次(鬧鐘定了3分鐘),A醒了之後發現診室的鈴鐺並沒有響,是自己的鬧鐘把自己喚醒了,然後A就開始自己思考了“我是再睡一會還是趕緊去診室門口等着呢”,最終A決定再睡一會並再定一個鬧鐘,等了一會B看完沒事了,B想“外面板凳上可能還有人”,於是B就按了下鈴鐺然後就走出診室了,此時A被鈴鐺吵醒了,於是就趕緊走到診室門口,然後毫無競爭壓力的就進去了,醫生把剛纔檢查到一半的進度和材料拿過來繼續給A看病,最後A也看完出去了,劇終!
說明:
從上面的場景模擬中我們可以看出Monitor的特點:
- 首先Monitor是根據一把鎖讓所有等待的線程同一時間只能有一個線程執行。
- Monitor可以允許進入鎖的線程中途退出線程去休眠,然後保留這個線程執行的進度信息。
- Monitor可以允許進入鎖內的線程發送脈衝(Pulse)去喚醒因爲Wait而休眠中的線程。
下面看個實例:
三、Mutex
本篇參照:c# mutex
Mutex的實現是調用操作系統層的功能,所以Mutex的性能要略慢一些,而它所能鎖住的範圍更大(它能跨進程上鎖),但是它的功能也就相當於lock關鍵字(因爲沒有類似Monitor.Wait和Monitor.Pulse的方法)。
Mutex分爲命名的Mutex和未命名的Mutex,命名的Mutex可用來跨進程加鎖,未命名的相當於lock。
所以說:在一個進程中使用它的場景真的不多。它的比較常用場景如:限制一個程序在一個計算機上只能允許運行一次:
class Program
{
private static Mutex mutex = null;
static void Main()
{
bool firstInstance;
mutex = new Mutex(true, @"Global\MutexSampleApp", out firstInstance);
try
{
if (!firstInstance)
{
Console.WriteLine("已有實例運行,輸入回車退出……");
Console.ReadLine();
return;
}
else
{
Console.WriteLine("我們是第一個實例!");
for (int i = 60; i > 0; --i)
{
Console.WriteLine(i);
Thread.Sleep(1000);
}
}
}
finally
{
if (firstInstance)
{
mutex.ReleaseMutex();
}
mutex.Close();
mutex = null;
}
}
}
需要注意的地方:
new Mutex(true, @"Global\MutexSampleApp", out firstInstance)
代碼不會阻塞當前線程(即使第一個參數爲true),在多進程協作的時候最後一個參數firstInstance
很重要,要善於運用。mutex.WaitOne(30*1000)
代碼,當前進程正在等待獲取鎖的時候,已佔用了這個命名鎖的進程意外退出了,此時當前線程並不會直接獲得鎖然後向後執行,而是拋出異常AbandonedMutexException
,所以在等待獲取鎖的時候要記得加上try catch。可以參照下面的代碼:class Program { private static Mutex mutex = null; static void Main() { mutex = new Mutex(false, @"Global\MutexSampleApp"); while (true) { try { Console.WriteLine("start wating..."); mutex.WaitOne(20 * 1000); Console.WriteLine("enter success"); Thread.Sleep(20 * 1000); break; } catch (AbandonedMutexException ex) { Console.WriteLine(ex.Message); continue; } } //do something mutex.ReleaseMutex(); Console.WriteLine("Released"); Console.WriteLine("ok"); Console.ReadKey(); } }
四、Semaphore
參照:C#多線程–信號量(Semaphore)
這個稱之爲信號量,它和Mutex都是繼承WaitHandle
的,所以他們本質都是調用操作系統,速度和Monitor相比略慢。
Semaphore的主要功能是限制併發的數量,即可以限制同一時間進入代碼塊的線程數量,不過Semaphore也可以有命名,命名後就可以進程間加鎖了(也可以用來限制一個程序只能運行一次),並且new Semaphore(1, 1)
相當於Mutex,所以說Semaphore的功能是Mutex的功能的超集,但是有一個不同:
Semaphore在執行WaitOne()時,如果正在佔用鎖的進程沒有Release()而是意外退出(比如我們強制關閉),那麼Semaphore是不會直接進入鎖的,而是一直死等了,而Mutex遇到這種情況的時候是拋出AbandonedMutexException
異常。
注意:
Semaphore的構造函數中允許我們指定初始可用的信號量和總共的信號量,一般我們都是讓初始可用的信號量等於總共的信號量。但是我們也可以指定初始可用的信號量小於總共的信號量,那麼我們在使用的時候可以一次Release多個信號量以達到全部信號量都被充分利用情況。
還有:我們可以在當前線程不進入鎖內的時候執行Release方法,所以從Semaphore開始、ManualResetEvent和AutoResetEvent這三個更像是線程間的信號了,而不再是鎖。
下面看個實例:
private static void Test4()
{
Semaphore sema = new Semaphore(5, 5);
int count = 0;
for (int i = 0; i < 10; i++)
{
new Thread(() =>
{
sema.WaitOne();
count++;
Thread.Sleep(1000);
Console.WriteLine($"time:{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")}\tcount={count}");
Thread.Sleep(1000 * 10);
sema.Release();
}).Start();
}
}
從上面的輸出可以看到,允許有5個併發。
五、ManualResetEvent和AutoResetEvent
參照:ManualResetEvent 與 AutoResetEvent 區別
這兩個對象都繼承自EventHandle
最終繼承自WaitHandle
,所以他們很像。
其實通過名字也可以看出,一個是手動的,一個是自動的,它們都沒有命名的功能,所以不能跨進程使用。
這兩個的對象的功能是通過WaitOne
、Set
和ReSet
方法提現的,原理就是他們封裝了同一個bool值,當bool值爲true的時候就允許waitone同行,否則就阻塞waitone的線程直到有其他線程將bool值設爲true。
WaitOne
:阻塞當前線程直到bool值變爲true
Set
:將bool值設爲true,對於AutoResetEvent 來說是將bool值設爲true後等待一個線程通過,一旦通過後就立馬設爲flase,也就是一次只放行一個線程;對於ManualResetEvent來說是將bool值設爲true,讓所有線程通過,然後bool值就保持爲true了直到下次執行Reset方法再變回false
ReSet
:將bool值設爲false以阻止線程通過。
注意:
雖說AutoResetEvent的Set方法相當於ManualResetEvent的Set+Reset方法,但是AutoResetEvent的Set方法能保證一次只有一個線程通過,但是ManualResetEvent的Set+Reset方法
卻不能保證只有一個線程通過。
下面給出幾個測試代碼:
/// <summary>
/// 測試AutoResetEvent執行set後必須要有一個線程被釋放纔會自動切換到無信號狀態
/// </summary>
private static void Test11()
{
var resetevent = new AutoResetEvent(false);
resetevent.Set();
new Thread(() =>
{
resetevent.WaitOne();
Console.WriteLine("進來了...");
resetevent.WaitOne();
Console.WriteLine("又進來了....");
}).Start();
}
/// <summary>
/// 測試AutoResetEvent一次只能通知一個線程,而ManualResetEvent是批量通知
/// </summary>
private static void Test10()
{
//var testevent = new AutoResetEvent(false);
var testevent = new ManualResetEvent(false);
Console.WriteLine("main coming...");
int count = 0;
for (int i = 0; i < 5; i++)
{
new Thread(() =>
{
testevent.WaitOne();
count++;
}).Start();
}
testevent.Set();
Thread.Sleep(1000);
Console.WriteLine("count=" + count);
}
/// <summary>
/// 測試AutoResetEvent,注意:AutoResetEvent不獲取鎖也可以通知其他線程,一次只通知一個
/// </summary>
private static void Test9()
{
AutoResetEvent autoResetEvent = new AutoResetEvent(false);
int count = 0;
var thread = new Thread(() =>
{
Thread.Sleep(3000);
count++;
autoResetEvent.Set();
});
thread.Start();
Console.WriteLine("main ...");
autoResetEvent.WaitOne();
Console.WriteLine("count=" + count);
}
六、測試鎖的性能消耗
測試鎖對程序性能的影響(10000*10000次的簡單運算,運算量比較大。。。)
對比不使用鎖、使用Lock、使用Monitor、使用原子操作對程序性能的影響
class Program
{
public static void Main(string[] args)
{
new Thread(Run).Start();
new Thread(Run_Interlocked).Start();
new Thread(Run_Lock).Start();
new Thread(Run_Monitor).Start();
Console.WriteLine("ok");
Console.ReadLine();
}
private static void Run_Monitor()
{
int count = 0;
var stopWatch = new Stopwatch();
var type = typeof(Program);
stopWatch.Start();
for (int i = 0; i < 10000 * 10000; i++)
{
Monitor.Enter(type);
count++;
Monitor.Exit(type);
}
stopWatch.Stop();
Console.WriteLine("Monitor:" + stopWatch.ElapsedMilliseconds);
}
private static void Run_Interlocked()
{
int count = 0;
var stopWatch = new Stopwatch();
var type = typeof(Program);
stopWatch.Start();
for (int i = 0; i < 10000 * 10000; i++)
{
Interlocked.Increment(ref count);
}
stopWatch.Stop();
Console.WriteLine("Interlocked:" + stopWatch.ElapsedMilliseconds);
}
private static void Run_Lock()
{
int count = 0;
var stopWatch = new Stopwatch();
var type = typeof(Program);
stopWatch.Start();
for (int i = 0; i < 10000 * 10000; i++)
{
lock (type)
{
count++;
}
}
stopWatch.Stop();
Console.WriteLine("Lock:" + stopWatch.ElapsedMilliseconds);
}
private static void Run()
{
int count = 0;
var stopWatch = new Stopwatch();
var type = typeof(Program);
stopWatch.Start();
for (int i = 0; i < 10000 * 10000; i++)
{
count++;
}
stopWatch.Stop();
Console.WriteLine("Normal:" + stopWatch.ElapsedMilliseconds);
}
}
看運行結果:
可以看出:lock鎖對是正常運行速度的25倍,原子操作要小一些。。。
注意: 這個25倍是我們進行1億次鎖操作出來的,可見實際中這種影響微乎其微,真正耗費時間的是線程等待時間。
七、總結
它們的特點及應用場景如下表所示: