程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> .NET網頁編程 >> C# >> C#入門知識 >> C#基本線程同步,

C#基本線程同步,

編輯:C#入門知識

C#基本線程同步,


0 概述

  所謂同步,就是給多個線程規定一個執行的順序(或稱為時序),要求某個線程先執行完一段代碼後,另一個線程才能開始執行。

  

第一種情況:多個線程訪問同一個變量

  1. 一個線程寫,其它線程讀:這種情況不存在同步問題,因為只有一個線程在改變內存中的變量,內存中的變量在任意時刻都有一個確定的值;

  2. 一個線程讀,其它線程寫:這種情況會存在同步問題,主要是多個線程在同時寫入一個變量的時候,可能會發生一些難以察覺的錯誤,導致某些線程實際上並沒有真正的寫入變量;

  3. 幾個線程寫,其它線程讀:情況同2。

  多個線程同時向一個變量賦值,就會出現問題,這是為什麼呢?

  我們編程采用的是高級語言,這種語言是不能被計算機直接執行的,一條高級語言代碼往往要編譯為若干條機器代碼,而一條機器代碼,CPU也不一定是在一個CPU周期內就能完成的。計算機代碼必須要按照一個“時序”,逐條執行。

  舉個例子,在內存中有一個整型變量number(4字節),那麼計算++number(運算後賦值)就至少要分為如下幾個步驟:

  1. 尋址:由CPU的控制器找尋到number變量所在的地址;

  2. 讀取:將number變量所在的值從內存中讀取到CPU寄存器中;

  3. 運算:由CPU的算術邏輯運算器(ALU)對number值進行計算,將結果存儲在寄存器中;

  4. 保存:由CPU的控制器將寄存器中保存的結果重新存入number在內存中的地址。

  這是最簡單的時序,如果牽扯到CPU的高速緩存(CACHE),則情況就更為復雜了。

CPU結構簡圖圖1 CPU結構簡圖

  在多線程環境下,當幾個線程同時對number進行賦值操作時(假設number初始值為0),就有可能發生沖突: 

  當某個線程對number進行++操作並執行到步驟2(讀取)時(0保存在CPU寄存器中),發生線程切換,該線程的所有寄存器狀態被保存到內存後後,由另一個線程對number進行賦值操作。當另一個線程對number賦值完畢(假設將number賦值為10),切換回第一個線程,進行現場恢復,則在寄存器中保存的number值依然為0,該線程從步驟3繼續執行指令,最終將1寫入到number所在內存地址,number值最終為1,另一個線程對number賦值為10的操作表現為無效操作。

  看一個例子:

 

[csharp] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. using System;  
  2. using System.Threading;  
  3.    
  4. namespace Edu.Study.Multithreading.WriteValue {  
  5.    
  6.     class Program {  
  7.    
  8.         /// <summary>  
  9.         /// 多個線程要訪問的變量  
  10.         /// </summary>  
  11.         private static int number = 0;  
  12.    
  13.         /// <summary>  
  14.         /// 令線程隨機休眠的隨機數對象  
  15.         /// </summary>  
  16.         private static Random random = new Random();  
  17.    
  18.         /// <summary>  
  19.         /// 線程入口方法, 這裡為了簡化編程, 使用了靜態方法  
  20.         /// </summary>  
  21.         private static void ThreadWork(object arg) {  
  22.    
  23.             // 循環1000次, 每次將number字段的值加1  
  24.             for (int i = 0; i < 1000; ++i) {  
  25.                 // += 1操作比++操作需要更多的CPU指令, 以增加出現錯誤的幾率  
  26.                 number += 1;  
  27.                 // 線程在10毫秒內隨機休眠, 以增加出現錯誤的幾率  
  28.                 Thread.Sleep(random.Next(10));  
  29.             }  
  30.         }  
  31.    
  32.    
  33.         /// <summary>  
  34.         /// 主方法  
  35.         /// </summary>  
  36.         static void Main(string[] args) {  
  37.             do {  
  38.                 // 令number為0, 重新給其賦值  
  39.                 number = 0;  
  40.                 Thread t1 = new Thread(new ParameterizedThreadStart(ThreadWork));  
  41.                 Thread t2 = new Thread(new ParameterizedThreadStart(ThreadWork));  
  42.    
  43.                 // 啟動兩個線程訪問number變量  
  44.                 t1.Start();  
  45.                 t2.Start();  
  46.    
  47.                 // 等待線程退出, Timeout.Infinite表示無限等待  
  48.                 while (t1.Join(Timeout.Infinite) && t2.Join(Timeout.Infinite)) {  
  49.                     Console.WriteLine(number);  
  50.                     break;  
  51.                 }  
  52.                 Console.WriteLine("請按按回車鍵重新測試,任意鍵退出程序......");  
  53.             } while (Console.ReadKey(false).Key == ConsoleKey.Enter);  
  54.         }  
  55.     }  
  56. }  

  例子中,兩個線程(t1和t2)同時訪問number變量(初始值為0),對其進行1000次+1操作,在兩個線程都結束後,在主線程顯式number變量的最終值。可以看到,很經常的,最終顯示的結果不是2000,而是1999或者更少。究其原因,就是發生了我們上面講的問題:兩個線程在進行賦值操作時,時序重疊了

  可以做實驗,在CPU核心數越多的計算機上,上述代碼出現問題的幾率越小。這是因為多核心CPU可能會在每一個獨立核心上各自運行一個線程,而CPU設計者針對這種多核心訪問一個內存地址的情況,本身就設計了防范措施。

 

第二種情況:多個線程組成了生產者和消費者:

  我們前面已經講過,多線程並不能加快算法速度(多核心處理器除外),所以多線程的主要作用還是為了提高用戶的響應,一般有兩種方式:

  • 將響應窗體事件操作和復雜的計算操作分別放在不同的線程中,這樣當程序在進行復雜計算時不會阻塞到窗體事件的處理,從而提高用戶操作響應;
  • 對於為多用戶服務的應用程序,可以一個獨立線程為一個用戶提供服務,這樣用戶之間不會相互影響,從而提高了用戶操作的響應。

  所以,線程之間很容易就形成了生產者/消費者模式,即一個線程的某部分代碼必須要等待另一個線程計算出結果後才能繼續運行。目前存在兩種情況需要線程間同步執行:

  • 多個線程向一個變量賦值或多線程改變同一對象屬性;
  • 某些線程等待另一些線程執行某些操作後才能繼續執行。

1 變量的原子操作

  CPU有一套指令,可以在訪問內存中的變量前,並將一段內存地址標記為“只讀”,此時除過標志內存的那個線程外,其余線程來訪問這塊內存,都將發生阻塞,即必須等待前一個線程訪問完畢後其它線程才能繼續訪問這塊內存。

  這種鎖定的結果是:所有線程只能依次訪問某個變量,而無法同時訪問某個變量,從而解決了多線程訪問變量的問題。

  原子操作封裝在Interlocked類中,以一系列靜態方法提供:

  • Add方法,對整型變量(4位、8位)進行原子的加法/減法操作,相當於n+=x或n-=x表達式的原子操作版本;
  • Increment方法,對整形變量(4位、8位)進行原子的自加操作,相當於++n的原子操作版本;
  • Decrement方法,對整型變量(4位、8位)進行原子的自減操作,相當於--n的原子操作版本;
  • Exchange方法,對變量或對象引用進行原子的賦值操作;
  • CompareExchange方法,對兩個變量或對象引用進行比較,如果相同,則為其賦值。

  例如:

Interlocked.Add方法演示

[csharp] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. int n = 0;  
  2.    
  3. // 將n加1  
  4. // 執行完畢後n的值變為1, 和返回值相同  
  5. int x = Interlocked.Add(ref n, 1);  
  6. // 將n減1  
  7. x = Interlocked.Add(ref n, -1);  
  8. Interlocked.Increment/Interlocked.Decrement方法演示  
  9. int n = 0;  
  10.    
  11. // 對n進行自加操作  
  12. // 執行完畢後n的值變為1, 和返回值相同  
  13. int x = Interlocked.Increment(ref n);  
  14. // 對n進行自減操作  
  15. x = Interlocked.Decrement(ref n);  
  16. Interlocked.Exchange方法演示  
  17. string s = "Hello";  
  18.    
  19. // 用另一個字符串對象"OK"為s賦值  
  20. // 操作完畢後s變量改變為引用到"OK"對象, 返回"Hello"對象的引用  
  21. string old = Interlocked.Exchange(ref s, "OK");  
  22. Interloceked.CompareExchange方法演示  
  23. string s = "Hello";  
  24. string ss = s;  
  25.    
  26. // 首先用變量ss和s比較, 如果相同, 則用另一個字符串對象"OK"為s賦值  
  27. // 操作完畢後s變量改變為引用到"OK"對象, 返回"Hello"對象的引用  
  28. string old = Interlocked.CompareExchange(ref s, ss, "OK");  

  注意,原子操作中,要賦值的變量都是以引用方式傳遞參數的,這樣才能在原子操作方法內部直接改變變量的值,才能完全避免非安全的賦值操作。

下面我們將前一節中出問題的代碼做一些修改,修改其ThreadWork方法,在多線程下能夠安全的操作同一個變量:

 

[csharp] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. private static void ThreadWork(object arg) {  
  2.     for (int i = 0; i < 1000; ++i) {  
  3.         // 使用原子方式操作變量, 避免多個線程為同一變量賦值出現錯誤  
  4.         Interlocked.Add(ref number, 1);  
  5.         Thread.Sleep(random.Next(10));  
  6.     }  
  7. }  

 

  上述代碼解決了一個重要的問題:同一個變量同時只能被一個線程賦值

 

2 循環鎖、關鍵代碼段和令牌對象

  使用變量的原子操作可以解決整數變量的加減計算和各類變量的賦值操作(或比較後賦值操作)的問題,但對於更復雜的同步操作,原子操作並不能解決問題。

  有時候我們需要讓同一段代碼同時只能被一個線程執行,而不僅僅是同一個變量同時只能被一個線程訪問,例如如下操作:

 

[csharp] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. double a = 10;  
  2. double b = 20;  
  3.    
  4. c = Math.Pow(a, 2);  
  5. c += Math.Pow(b, 2);  
  6. c = Math.Sqrt(c);  
  7. c /= Math.PI;  

 

  假設變量c是一個類字段,同時被若干線程賦值,顯然僅通過原子操作,無法解決c變量被不同線程同時訪問的問題,因為計算c需要若干步才能完成計算,需要比較多的指令,原子操作只能在對變量一次賦值時產生同步,面對多次賦值,顯然無能為力。無論c=Math.Pow(a, 2)這步如何原子操作後,這步結束後下步開始前,c的值都有可能其它線程改變,從而最終計算出錯誤的結果。

  所以鎖定必須要施加到一段代碼上才能解決上述問題,這就是關鍵代碼段

  關鍵代碼段需要兩個前提條件:

  • 一個作為令牌的對象;
  • 一個鎖操作。

  令牌對象有個狀態屬性:具備兩個屬性值:掛起和釋放。可以通過原子操作改變這個屬性的屬性值。規定:所有線程都可以訪問同一個令牌對象,但只有訪問時令牌對象狀態屬性為釋放狀態的那個線程,才能執行被鎖定的代碼,同時將令牌對象的狀態屬性更改為掛起。其余線程自動進入循環檢測代碼(在一個循環中不斷檢測令牌對象的狀態),直到第一個對象訪問完鎖定代碼,將令牌對象狀態屬性重新設置為釋放狀態,其余線程中的某一個才能檢測到令牌對象已經釋放並接著執行被鎖定的代碼,同時將令牌對象狀態屬性設置為掛起

  語法如下:

 

[csharp] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. lock (對象引用) {  
  2.     // 關鍵代碼段  
  3. }  

 

  其中lock稱為循環鎖,訪問的引用變量所引用的對象稱為令牌對象,一對大括號中的代碼稱為關鍵代碼段。如果同時有多個線程訪問同一關鍵代碼段,則可以保證每次同時只有一個線程可以執行這段代碼,一個線程執行完畢後另一個線程才能解開鎖並執行這段代碼。

  所以前面的那段代碼可以改為:

 

[csharp] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. double a = 10;    
  2. double b = 20;    
  3.     
  4. lock (某對象引用) {   
  5.     c = Math.Pow(a, 2);    
  6.     c += Math.Pow(b, 2);    
  7.     c = Math.Sqrt(c);    
  8.     c /= Math.PI;    
  9. }   

 

  在.net Framework中,任意引用類型對象都可以作為令牌對象。

  鎖定使用起來很簡單,關鍵在使用前要考慮鎖定的顆粒度,也就是鎖定多少行代碼才能真正的安全。鎖定的代碼過少,可能無法保證完全同步,鎖定的代碼過多,有可能會降低系統執行效率(導致線程無法真正意義上的同時執行),我們舉個例子,解釋一下鎖定的顆粒度:

  程序界面設計如下:

循環鎖程序設計界面  圖2 循環鎖程序設計界面

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved