環境:
- window 10
- netcore 3.1.1
- vs2019 16.4.3
目的:
- 探索c#中的臨界區、互斥量、信號量和事件的特點和使用方法
一、概念介紹
參照:
線程同步是一個非常大的話題,包括方方面面的內容。從大的方面講,線程的同步可分用戶模式的線程同步和內核對象的線程同步兩大類。用戶模式中線程的同步方法主要有原子訪問和臨界區等方法。其特點是同步速度特別快,適合於對線程運行速度有嚴格要求的場合。內核對象的線程同步則主要由事件、等待定時器、信號量以及信號燈等內核對象構成。由於這種同步機制使用了內核對象,使用時必須將線程從用戶模式切換到內核模式,而這種轉換一般要耗費近千個CPU週期,因此同步速度較慢,但在適用性上卻要遠優於用戶模式的線程同步方式。
1.1 臨界區(Critical Section)
保證在某一時刻只有一個線程能訪問數據的簡便辦法。在任意時刻只允許一個線程對共享資源進行訪問。如果有多個線程試圖同時訪問臨界區,那麼 在有一個線程進入後其他所有試圖訪問此臨界區的線程將被掛起,並一直持續到進入臨界區的線程離開。臨界區在被釋放後,其他線程可以繼續搶佔,並以此達到用原子方式操 作共享資源的目的。c#中的lock、Monitor、ReadWriterLock屬於臨界區。
1.2 互斥量(Mutex)
互斥量跟臨界區很相似,只有擁有互斥對象的線程才具有訪問資源的權限,由於互斥對象只有一個,因此就決定了任何情況下此共享資源都不會同時被多個線程所訪問。當前佔據資源的線程在任務處理完後應將擁有的互斥對象交出,以便其他線程在獲得後得以訪問資源。互斥量比臨界區複雜。因爲使用互斥不僅僅能夠在同一應用程序不同線程中實現資源的安全共享,而且可以在不同應用程序的線程之間實現對資源的安全共享。c#中的Mutex屬於互斥量。
1.3 信號量(Semaphores)
信號量對象對線程的同步方式與前面幾種方法不同,信號允許多個線程同時使用共享資源 ,這與操作系統中的PV操作相同。它指出了同時訪問共享 資源的線程 最大數目。它允許多個線程在同一時刻訪問同一資源,但是需要限制在同一時刻訪問此資源的最大線程數目。c#中的Semaphore屬於信號量。
1.4 通知事件(Event)
通知事件對象可以通過通知操作的方式來保持線程的同步。c#中的ManualResetEvent和AutoResetEvent屬於通知事件。
二、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是全部喚醒。這裏順便提一下:在多生產者、多消費者的情況下,我們更希望去喚醒消費者或者是生產者,而不是誰都喚醒,在java中我們可以使用lock的condition來解決這個問題,在c#中我們可以使用下面介紹的ManaualResetEvent或AutoResetEvent
下面把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而休眠中的線程。
下面看個實例:
四、ReadWriterLock
參照:5天不再懼怕多線程——第二天 鎖機制
先前也知道,Monitor實現的是在讀寫兩種情況的臨界區中只可以讓一個線程訪問,那麼如果業務中存在”讀取密集型“操作,就好比數據庫一樣,讀取的操作永遠比寫入的操作多。針對這種情況,我們使用Monitor的話很喫虧,不過沒關係,ReadWriterLock就很牛X,因爲實現了”寫入串行“,”讀取並行“。
ReaderWriteLock中主要用3組方法:
<1> AcquireWriterLock: 獲取寫入鎖。
ReleaseWriterLock:釋放寫入鎖。
<2> AcquireReaderLock: 獲取讀鎖。
ReleaseReaderLock:釋放讀鎖。
<3> UpgradeToWriterLock:將讀鎖轉爲寫鎖。
DowngradeFromWriterLock:將寫鎖還原爲讀鎖。
-
下面代碼測試一下讀時候的並行:
class Program { static ReaderWriterLock readerWriterLock = new ReaderWriterLock(); public static void Main(string[] args) { var thread = new Thread(() => { Console.WriteLine("thread1 start..."); readerWriterLock.AcquireReaderLock(3000); int index = 0; while (true) { index++; Console.WriteLine("du..."); Thread.Sleep(1000); if (index > 6) break; } readerWriterLock.ReleaseReaderLock(); }); thread.Start(); var thread2 = new Thread(() => { Console.WriteLine("thread2 start..."); readerWriterLock.AcquireReaderLock(3000); int index = 0; while (true) { index++; Console.WriteLine("讀..."); Thread.Sleep(1000); if (index > 6) break; } readerWriterLock.ReleaseReaderLock(); }); thread2.Start(); Console.ReadLine(); } }
-
測試寫的串行
class Program { static ReaderWriterLock readerWriterLock = new ReaderWriterLock(); public static void Main(string[] args) { var thread = new Thread(() => { Console.WriteLine("thread1 start..."); readerWriterLock.AcquireWriterLock(1000); Console.WriteLine("寫..."); Thread.Sleep(5000); Console.WriteLine("寫完了..."); readerWriterLock.ReleaseReaderLock(); }); thread.Start(); var thread2 = new Thread(() => { Console.WriteLine("thread2 start..."); try { readerWriterLock.AcquireReaderLock(2000); Console.WriteLine("du..."); readerWriterLock.ReleaseReaderLock(); Console.WriteLine("du wan..."); } catch (Exception ex) { Console.WriteLine(ex.Message); } }); Thread.Sleep(100); thread2.Start(); Console.ReadLine(); } }
class Program { static ReaderWriterLock readerWriterLock = new ReaderWriterLock(); public static void Main(string[] args) { var thread = new Thread(() => { Console.WriteLine("thread1 start..."); try { readerWriterLock.AcquireWriterLock(1000); Console.WriteLine("寫..."); readerWriterLock.ReleaseReaderLock(); Console.WriteLine("寫完了..."); } catch (Exception ex) { Console.WriteLine(ex.Message); } }); var thread2 = new Thread(() => { Console.WriteLine("thread2 start..."); readerWriterLock.AcquireReaderLock(2000); Console.WriteLine("du..."); Thread.Sleep(5000); readerWriterLock.ReleaseReaderLock(); Console.WriteLine("du wan..."); }); thread2.Start(); Thread.Sleep(100); thread.Start(); Console.ReadLine(); } }
從上面的試驗可以看出,“讀“和“寫”鎖是不能並行的,他們之間相互競爭,同一時間,裏面可以有一批“讀”鎖或一個“寫”鎖 ,其他的則不允許。
五、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億次鎖操作出來的,可見實際中這種影響微乎其微,真正耗費時間的是線程等待時間。
九、總結
它們的特點及應用場景如下表所示:
互斥量、信號量和通知事件的類繼承關係: