C# 溫故而知新: 線程篇(三)
線程同步篇 (上)
- 線程同步中的一些重要概念
- 臨界區(共享區)的概念
- 基元用戶模式
- 基元內核模式
- 原子性操作
- 非阻止同步
- 阻止同步
- 詳解Thread類 中的VolatileRead和VolatileWrite方法和Volatile關鍵字的作用
- Volatile關鍵字的作用
- 介紹下Interlocked
- 介紹下Lock關鍵字
- 詳解ReaderWriterLock 類
- 本章總結
- 參考文獻
在多線程的環境中,可能需要共同使用一些公共資源,這些資源可能是變量,方法邏輯段等等,這些被多個線程
共用的區域統稱為臨界區(共享區),聰明的你肯定會想到,臨界區的資源不是很安全,因為線程的狀態是不定的,所以
可能帶來的結果是臨界區的資源遭到其他線程的破壞,我們必須采取策略或者措施讓共享區數據在多線程的環境下保持
完成性不讓其受到多線程訪問的破壞
可能大家覺得這個很難理解,的確如果光看概念解釋的話,會讓人抓狂的,因為這個模式牽涉到了深奧的底層cup
內核和windows的一些底層機制,所以我用最簡單的理解相信大家一定能理解,因為這對於理解同步也很重要
回到正題,基元用戶模式是指使用cpu的特殊指令來調度線程,所以這種協調調度線程是在硬件中進行的所以得出
了它第一些優點:
速度特別快
線程阻塞時間特別短
但是由於該模式中的線程可能被系統搶占,導致該模式中的線程為了獲取某個資源,而浪費許多cpu時間,同時如果一直處
於等待的話會導致”活鎖”,也就是既浪費了內存,又浪費了cpu時間,這比下文中的死鎖更可怕,那么如何利用強大的
cpu時間做更多的事呢?那就引出了下面的一個模式
該模式和用戶模式不同,它是windows系統自身提供的,使用了操作系統中內核函數,所以它能夠阻塞線程提高了cpu的利
用率,同時也帶來了一個很可怕的bug,死鎖,可能線程會一直阻塞導致程序的奔潰,常用的內核模式的技術例如Monitor,Mutex,
等等會在下一章節介紹。本章將詳細討論鎖的概念,使用方法和注意事項
如果一個語句執行一個單獨不可分割的指令,那么它是原子的。嚴格的原子操作排除了任何搶占的可能性(這也是實現同步的一
個重要條件,也就是說沒有一個線程可以把這個美女占為己有,更方便的理解是這個值永遠是最新的),在c#中原子操作如下圖所示:
其實要符合原子操作必須滿足以下條件
- c#中如果是32位cpu的話,為一個少於等於32位字段賦值是原子操作,其他(自增,讀,寫操作)的則不是
- 對於64位cpu而言,操作32或64位的字段賦值都屬於原子操作
- 其他讀寫操作都不能屬於原子操作
相信大家能夠理解原子的特點,下文中的Volatil和interlocked會詳細模擬原子操作來實現線程同步,所以在使用原子操
作時也需要注意當前操作系統是32位或是64位cpu或者兩者皆要考慮
非阻止同步說到底,就是利用原子性操作實現線程間的同步,不刻意阻塞線程,減少相應線程的開銷,下文中的VolatileRead,V
olatileWrite,Volatile關鍵字,interlocked類便是c#中非阻止同步的理念所產生的線程同步技術
阻止同步正好相反,其實阻止同步也是基元內核模式的特點之一,例如c# 中的鎖機制,及其下幾章介紹的mutex,monitor等都屬
於阻止同步,他們的根本目的是,以互斥的效果讓同一時間只有一個線程能夠訪問共享區,其他線程必須阻止等待,直到該線程離開共享
區后,在讓其他一個線程訪問共享區,阻止同步缺點也是容易產生死鎖,但是阻止同步提高了cpu時間的利用率
2.詳解Thread類中的VolatileRead和VolatileWrite方法和Volatile關鍵字
前文中,我們已經對原子操作和非阻止同步的概念已經有了大概的認識,接着讓我們從新回到Thread類來看下其中比較經典的VolatileRead
和VolatileWrite方法
VolatileWrite: 該方法作用是,當線程在共享區(臨界區)傳遞信息時,通過此方法來原子性的寫入最后一個值 VolatileRead: 該方法作用是,當線程在共享區(臨界區)傳遞信息時,通過此方法來原子性的讀取第一個值。 |
可能這樣的解釋會讓大家困惑,老規矩,直接上例子讓大家能夠理解:
/// <summary> /// 本例利用VolatileWrite和VolatileRead來實現同步,來實現一個計算 /// 的例子,每個線程負責運算1000萬個數據,共開啟10個線程計算至1億, /// 而且每個線程都無法干擾其他線程工作 /// </summary> class Program { static Int32 count;//計數值,用於線程同步 (注意原子性,所以本例中使用int32) static Int32 value;//實際運算值,用於顯示計算結果 static void Main(string[] args) { //開辟一個線程專門負責讀value的值,這樣就能看見一個計算的過程 Thread thread2 = new Thread(new ThreadStart(Read)); thread2.Start(); //開辟10個線程來負責計算,每個線程負責1000萬條數據 for (int i = 0; i < 10; i++) { Thread.Sleep(20); Thread thread = new Thread(new ThreadStart(Write)); thread.Start(); } Console.ReadKey(); } /// <summary> /// 實際運算寫操作 /// </summary> private static void Write() { Int32 temp = 0; for (int i = 0; i < 10000000; i++) { temp += 1; } value += temp; //注意VolatileWrite 在每個線程計算完畢時會寫入同步計數值為1,告訴程序該線程已經執行完畢 //所以VolatileWrite方法類似與一個按鈴,往往在原子性的最后寫入告訴程序我完成了 Thread.VolatileWrite(ref count, 1); } /// <summary> /// 顯示計算后的數據,使用該方法的線程會死循環等待寫 /// 操作的線程發出完畢信號后顯示當前計算結果 /// </summary> private static void Read() { while (true) { //一旦監聽到一個寫操作線執行完畢后立刻顯示操作結果 //和VolatileWrite相反,VolatileRead類似一個門禁,只有原子性的最先讀取他,才能達到同步效果 //同時count值保持最新 if (Thread.VolatileRead(ref count) > 0) { Console.WriteLine("累計計數:{1}", Thread.CurrentThread.ManagedThreadId, value); //將count設置成0,等待另一個線程執行完畢 count = 0; } } } }
顯示結果:
例子中我們可以看出當個線程調用Read方法時,代碼會先判斷Thread. VolatileRead先讀取計數值是否返回正確的計數值,如果正確則顯示
結果,不正確的話繼續循環等待,而這個返回值是通過其他線程操作Write方法時最后寫入的,也就是說對於Thread. VolatileWrite
方法的作用便一目了然了,在實現Thread. VolatileWrite前寫入其他的數據或進行相應的邏輯處理,在我們示例代碼中我們會先去加運算到
10000000時,通過thread. VolatileWrite原子性的操作寫入計數值告訴那個操作Read方法的線程有一個計算任務已經完成,於是死循環中
的Thread. VolatileRead方法接受到了信號,你可以顯示計算結果了,於是結果便會被顯示,同時計數值歸零,這樣便起到了一個非阻塞功能
的同步效果,同樣對於臨界區(此例中的Write方法體和Read方法體)起到了保護的作用。當然由於使用上述兩個方法在復雜的項目中很容易
出錯,往往這種錯誤是很難被發現,所以微軟為了讓我們更好使用,便開發出了一個新的關鍵字Volatile:
Volatile關鍵字的本質含義是告訴編譯器,聲明為Volatile關鍵字的變量或字段都是提供給多個線程使用的,當然不是每個類型都
可以聲明為Volatile類型字段,msdn中詳細說明了那些類型可以聲明為Volatile 所以不再陳述,但是有一點必須注意,Volatile
無法聲明為局部變量。作為原子性的操作,Volatile關鍵字具有原子特性,所以線程間無法對其占有,它的值永遠是最新的。那我
們就對上文的那個例子簡化如下:

/// <summary> /// 本例利用volatile關鍵字來實現同步,來實現一個計算 /// 的例子,每個線程負責運算1000萬個數據,共開啟10個線程計算至1億, /// 而且每個線程都無法干擾其他線程工作 /// </summary> class Program { static volatile Int32 count;//計數值,用於線程同步 (注意原子性,所以本例中使用int32) static Int32 value;//實際運算值,用於顯示計算結果 static void Main(string[] args) { //開辟一個線程專門負責讀value的值,這樣就能看見一個計算的過程 Thread thread2 = new Thread(new ThreadStart(Read)); thread2.Start(); //開辟10個線程來負責計算,每個線程負責1000萬條數據 for (int i = 0; i < 10; i++) { Thread.Sleep(20); Thread thread = new Thread(new ThreadStart(Write)); thread.Start(); } Console.ReadKey(); } /// <summary> /// 實際運算寫操作 /// </summary> private static void Write() { Int32 temp = 0; for (int i = 0; i < 10000000; i++) { temp += 1; } value += temp; //注意VolatileWrite 在每個線程計算完畢時會寫入同步計數值為1,告訴程序該線程已經執行完畢 //將count值設置成1,效果等同於Thread.VolatileWrite count = 1; } /// <summary> /// 顯示計算后的數據,使用該方法的線程會死循環等待寫 /// 操作的線程發出完畢信號后顯示當前計算結果 /// </summary> private static void Read() { while (true) { //一旦監聽到一個寫操作線執行完畢后立刻顯示操作結果,效果等同於Thread.VolatileRead if (count==1) { Console.WriteLine("累計計數:{1}", Thread.CurrentThread.ManagedThreadId, value); //將count設置成0,等待另一個線程執行完畢 count = 0; } } } }
從例子中大家可以看出Volatile關鍵字的出現替代了原先VolatileRead 和VolatileWrite方法的繁瑣,同時原子性的操作更加直觀透明
相信大家理解了Volatile后對於非阻止同步和原子操作有了更深的認識,接下來的Interlocked雖然也屬於非阻止同步但是而后Volatile相比也
有着很大的不同,interlocked 利用了一個計數值的概念來實現同步,當然這個計數值也是屬於原子性的操作,每個線程都有機會通過Interlocked
去遞增或遞減這個計數值來達到同步的效果,同時Interlocked比Volatile更加適應復雜的邏輯和並發的情況
首先讓我們了解下Interlocked類的一些重要方法
static long Read() 以原子操作形式讀取計數值,該方法能夠讀取當前計數值,但是如果是64位cpu的可以不需要使用該方法讀取. *但是如果是32位的cpu則必須使用interlocked類的方法對64位的變量進行操作來保持原子操作,否則就不是原子操作 static int or long Increment(Int32 Or Int64) 該方法已原子操作的形式遞增指定變量的值並存儲結果,也可以理解成以原子的操作對計數器加1 Increment有2個返回類型的版本,分別是int 和 long static int or long Decrement(Int32 Or Int64) 和Increment方法相反,該方法已原子操作的形式遞減指定變量的值並存儲結果,也可以理解成以原子的操作對計數器減1 同樣,Decrement也有2個返回類型的版本,分別是int 和 long static int Add(ref int location1,int value) 該方法是將Value的值和loation1中的值相加替換location1中原有值並且存儲在locaion1中,注意,該方法不會拋出溢出異常, 如果location中的值和Value之和大於int32.Max則,location1中的值會變成int32.Min和Value之和 Exchange(double location1,double value) Exchange方法有多個重載,但是使用方法是一致的,以原子操作的形式將Value的值賦值給location1 |
看完了概念性的介紹后,讓我們馬上進入很簡單的一個示例,來深刻理解下Interlocked的使用方法
/// <summary> /// 本示例通過Interlocked實現同步示例,通過Interlocked.Increment和 /// Interlocked.Decrement來實現同步,此例有2個共享區,一個必須滿足計數值為0,另 /// 一個滿足計數值為1時才能進入 /// </summary> class Program { //聲明計數變量 //(注意這里用的是long是64位的,所以在32位機子上一定要通過Interlocked來實現原子操作) static long _count = 0; static void Main(string[] args) { //開啟6個線程,3個執行Excution1,三個執行Excution2 for (int i = 0; i < 3; i++) { Thread thread = new Thread(new ThreadStart(Excution1)); Thread thread2 = new Thread(new ThreadStart(Excution2)); thread.Start(); Thread.Sleep(10); thread2.Start(); Thread.Sleep(10); } //這里和同步無關,只是簡單的對Interlocked方法進行示例 Interlocked.Add(ref _count, 2); Console.WriteLine("為當前計數值加上一個數量級:{0}后,當前計數值為:{1}", 2, _count); Interlocked.Exchange(ref _count, 1); Console.WriteLine("將當前計數值改變后,當前計數值為:{0}", _count); Console.Read(); } static void Excution1() { //進入共享區1的條件 if (Interlocked.Read(ref _count) == 0) { Console.WriteLine("Thread ID:{0} 進入了共享區1", Thread.CurrentThread.ManagedThreadId); //原子性增加計數值,讓其他線程進入共享區2 Interlocked.Increment(ref _count); Console.WriteLine("此時計數值Count為:{0}", Interlocked.Read(ref _count)); } } static void Excution2() { //進入共享區2的條件 if (Interlocked.Read(ref _count) == 1) { Console.WriteLine("Thread ID:{0} 進入了共享區2", Thread.CurrentThread.ManagedThreadId); //原子性減少計數值,讓其他線程進入共享區1 Interlocked.Decrement(ref _count); Console.WriteLine("此時計數值Count為:{0}", Interlocked.Read(ref _count)); } } }
在本例中,我們使用和上文一樣的思路,通過不同線程來原子性的操作計數值來達到同步效果,大家可以仔細觀察到,通過
Interlocked對計數值進行操作就能夠讓我們非常方便的使用非阻止的同步效果了,但是在復雜的項目或邏輯中,可能也會出
錯導致活鎖的可能,大家務必當心
Lock關鍵字是用來對於多線程中的共享區進行阻止同步的一種方案,當某一個線程進入臨界區時,lock關鍵字會鎖住共享區,
同樣可以理解為互斥段,互斥段在某一時刻內只允許一個線程進入,同時編譯器會把這個關鍵字編譯成Monitor.Entery和
Monitor.Exit 方法,關於Monitor類會在下章詳細闡述。既然有Lock關鍵字,那么它是如何工作的?到底鎖住了什么,怎么
高效和正確的使用lock關鍵字呢?
其實鎖的概念還是來自於現實生活,共享區就是多個人能夠共同擁有房間,當其中一個人進入房間后,他把鎖反鎖,直到他解鎖
出門后將鑰匙交給下個人,可能房間的門可能有問題,或者進入房間的人因為某種原因出不來了,導致全部的人都無法進去,這些
問題也是我們應該考慮到的,好,首先讓我們討論下我們應該Lock住什么,什么材料適合當鎖呢?
雖然說lock關鍵字可以鎖住任何object類型及其派生類,但是盡量不要用public 類型的,因為public類型難以控制
有可能大伙對上面的有點疑問,為什么不能用public類型的呢,為什么會難以控制呢?
好,以下3個例子是比較經典的例證
1.Lock(this):大伙肯定會知道this指的是當前類對象,Lock(this) 、意味着將當前類對象給鎖住了, 假設我需要同時使用這個類的別的方法,那么某一線程一旦進入臨界區后,那完蛋了,該類所有的 成員(方法)都無法訪問,這可能在某些時刻是致命的錯誤 2.同理Lock(typeof(XXX)) 更厲害,一方面對鎖的性能有很大影響,因為一個類型太大了,其次, 當某一線程進入臨界區后,包括所有該類型的type都可能會被鎖住而產生死鎖 3.最嚴重的某過於鎖住字符串對象,lock(“Test”),c#中的字符串對象很特殊,string test=”Test” 和 string test2=”Test” 其實是一個對象,假如你使用了lock(“Test”)那么,所有字符串值為"Test"的 字符串都有可能被鎖住,甚至造成死鎖,所以有些奇怪的bug都是因為一些簡單的細節導致 |
接着這個例子便是lock(this)的一個示例,既能讓大伙了解如何使用Lock關鍵字,更是讓大伙了解,lock(this)的危害性
/// <summary> /// 本例展示下如何使用lock關鍵字和lock(this)時產生死鎖的情況 /// </summary> class Program { static void Main(string[] args) { //創建b對象,演示lock B b = new B(); Console.ReadKey(); } } /// <summary> /// A類構造中初始化一個線程並且啟動, /// 線程調用的方法內放入死循環,並且在死循環中放入lock(this), /// </summary> public class A { public A() { Thread th = new Thread(new ThreadStart ( () => { while (true) { lock (this) { Console.WriteLine("進入a類共享區"); Thread.Sleep(3000); } } } )); th.Start(); } } /// <summary> /// B類在構造中創建A的對象,並且還是鎖住a對象,這樣就創建的死鎖的條件 /// 因為初始化A類對象時,A類的構造函數會鎖住自身對象,這樣在A類死循環間隔期,一旦出了 A類中的鎖時 /// 進入B的鎖住的區域內,A 對象永遠無法進入a類共享區,從而產生了死鎖 /// </summary> public class B { public B() { A a = new A(); lock (a) { Console.WriteLine(@"將a類對象鎖住的話,a類中的lock將進入死鎖, 直到3秒后B類中的將a類對象釋放鎖,如果我不釋放,那么 a類中將永遠無法進入a類共享區"); //計時器 Timer timer = new Timer(new TimerCallback ( (obj) => { Console.WriteLine(DateTime.Now); } ), this, 0, 1000); //如果這里運行很長時間, a類中將永遠無法進入a類共享區 Thread.Sleep(3000000); } } }
結果計時器會一直滾動,因為a對象被鎖住,除非完成Thread.Sleep(3000000)后才能進入到a共享區
由於以上的問題,微軟還是建議我們使用一個私有的變量來鎖定,由於私有變量外界無法訪問,所以鎖住話死鎖的可能性大大下降了。
這樣我們就能選擇正確的“門”來進行鎖住,但是可能還有一種可能也會造成死鎖,就是在lock內部出現了問題,由於死鎖非常復雜,我將在
今后的文章中專門寫一篇關於死鎖的文章來深入解釋下死鎖,所以這里就對死鎖不深究了,這里大伙了解下lock的使用方法和注意事項就行了。
由於lock關鍵字對臨界區(共享區)保護的非常周密,導致了一些功能可能會無法實現,假設我將某個查詢功能放置在臨界區中時,可能
當別的線程在查詢臨界區中的數據時,可能我的那個線程被阻塞了,所以我們期望鎖能夠達到以下功能
1 首先鎖能細分為讀鎖和寫鎖
2 能夠保證同時可以讓多個線程讀取數據
3 能保證同一時刻只有一個線程能進行寫操作,也就是說,對於寫操作,它必須擁有獨占鎖
4 能保證一個線程同一時刻只能擁有寫鎖或讀鎖中的一個
顯然lock關鍵字無法滿足我們的需求,還好微軟想到了這點,ReaderWriterLock便隆重登場了ReaderWriterLock能夠達到的效果是:
1. 同一時刻,它允許多個讀線程同時訪問臨界區,或者允許單個線程進行寫訪問
2. 在讀訪問率很高,而且寫訪問率很低的情況下,效率最高,
3.它也滿足了同一時刻只能獲取寫鎖或讀鎖的要求。
4. 最為關鍵的是,ReaderWriterLock能夠保證讀線程鎖和寫線程鎖在各自的讀寫隊列中,當某個線程釋放了寫鎖了,同時讀線程隊列中
的所有線程將被授予讀鎖,同樣,當所有的讀鎖被釋放時,寫線程隊列中的排隊的下一個線程將被授予寫鎖,更直觀的說,ReaderWriterLock
就是在這幾種狀態間來回切換
5 使用時注意每當你使用AcquireXXX方法獲取鎖時,必須使用ReleaseXXX方法來釋放鎖
6 ReaderWriterLock 支持遞歸鎖,關於遞歸鎖會在今后的章節詳細闡述
7 在性能方面ReaderWriterLock做的不夠理想,和lock比較差距明顯,而且該類庫中還隱藏些bug,有於這些原因,微軟又專門重新寫了個新
類ReaderWriterLockSilm來彌補這些缺陷。
8 處理死鎖方面ReaderWriterLock為我們提供了超時的參數這樣我們便可以有效的防止死鎖
9 對於一個個獲取了讀鎖的線程來說,在寫鎖空閑的情況下可以升級為寫鎖
接着讓我們了解下ReaderWriterLock的重要成員
上述4個方法分別是讓線程獲取寫鎖和讀鎖的方法,它利用的計數的概念,當一個線程中調用此方法后,該類會給該線程擁有的鎖計數加1
(每次加1,但是一個線程可以擁有多個讀鎖,所以計數值可能更多,但是對於寫鎖來說同時一個一個線程可以擁有)。后面的參數是超時
時間,我們可以自己設置來避免死鎖。同樣調用上述方法后我們必須使用ReleaseXXX 方法來讓計數值減1,直到該線程擁有鎖的計數為0,
釋放了鎖為止。
最后我們用一個簡單的例子來溫故下上述的知識點(請注意看注釋)
/// <summary> /// 該示例通過ReaderWriterLock同步來實現Student集合多線程下 /// 的寫操作和讀操作 /// </summary> class Program { static ReaderWriterLock _readAndWriteLock = new ReaderWriterLock(); static List<Student> demoList = new List<Student>(); static void Main(string[] args) { InitialStudentList(); Thread thread=null; for (int i = 0; i <5; i++) { //讓第前2個個線程試圖掌控寫鎖, if (i < 2) { thread = new Thread(new ParameterizedThreadStart(AddStudent)); Console.WriteLine("線程ID:{0}, 嘗試獲取寫鎖 ", thread.ManagedThreadId); thread.Start(new Student { Name = "Zhang" + i }); } else { //讓每個線程都能訪問DisplayStudent 方法去獲取讀鎖 thread = new Thread(new ThreadStart(DisplayStudent)); thread.Start(); } Thread.Sleep(20); } Console.ReadKey(); } static void InitialStudentList() { demoList = new List<Student> { new Student{ Name="Sun"}, new Student{Name="Zheng"} }; } /// <summary> /// 當多個線程試圖使用該方法時,只有一個線程能夠透過AcquireSWriterLock /// 獲取寫鎖,同時其他線程進入隊列中等待,直到該線程使用ReleaseWriterLock后 /// 下個線程才能進入擁有寫鎖 /// </summary> /// <param name="student"></param> static void AddStudent(object student) { if (student == null|| !(student is Student)) return; if (demoList.Contains(student)) return; try { //獲取寫鎖 _readAndWriteLock.AcquireWriterLock(Timeout.Infinite); demoList.Add(student as Student); Console.WriteLine("當前寫操作線程為{0}, 寫入的學生是:{1}", Thread.CurrentThread.ManagedThreadId,(student as Student).Name); } catch (Exception) { } finally { _readAndWriteLock.ReleaseWriterLock(); } } /// <summary> /// 對於讀鎖來所,允許多個線程共同擁有,所以這里同時 /// 可能會有多個線程訪問Student集合,使用try catch是為了 /// 一定要讓程序執行finally語句塊中的releaseXXX方法,從而保證 /// 能夠釋放鎖 /// </summary> static void DisplayStudent() { try { _readAndWriteLock.AcquireReaderLock(Timeout.Infinite); demoList.ForEach(student => { Console.WriteLine("當前集合中學生為:{0},當前讀操作線程為{1}", student.Name, Thread.CurrentThread.ManagedThreadId); }); } catch (Exception) { } finally { _readAndWriteLock.ReleaseReaderLock(); } } } internal class Student { public string Name { get; set; } }
運行結果:
從例子可以看出有2個線程試圖嘗試爭取寫鎖,但是同時只有一個線程可以獲取到寫鎖,同時對於讀取集合的線程可以同時獲取多個讀鎖
由於本人上個月工作突然忙了起來,快一個多月沒更新博客了,希望大家可以見諒^^
本章介紹了線程同步的概念和一些關於同步非常重要的基本概念,對於原子性的操作的認識也格外重要,同時對於Volatile,Interlocked,lock,ReaderWriterLock 知識點做了相關介紹,
相信大家對於線程同步有個初步的認識和理解,在寫本篇博客時,發現死鎖也是個很重要的知識點,關於死鎖我會單獨寫篇文章來闡述,謝謝大家的支持!
CLR via c#
msdn