0 概述 所謂同步,就是給多個線程規定一個執行的順序(或稱為時序),要求某個線程先執行完一段代碼後,另一個線程才能開始執行。 第一種情況:多個線程訪問同一個變數: 1. 一個線程寫,其它線程讀:這種情況不存在同步問題,因為只有一個線程在改變記憶體中的變數,記憶體中的變數在任意時刻都有一個確定的值; 2. ...
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),則情況就更為複雜了。
在多線程環境下,當幾個線程同時對number進行賦值操作時(假設number初始值為0),就有可能發生衝突:
當某個線程對number進行++操作並執行到步驟2(讀取)時(0保存在CPU寄存器中),發生線程切換,該線程的所有寄存器狀態被保存到記憶體後後,由另一個線程對number進行賦值操作。當另一個線程對number賦值完畢(假設將number賦值為10),切換回第一個線程,進行現場恢復,則在寄存器中保存的number值依然為0,該線程從步驟3繼續執行指令,最終將1寫入到number所在記憶體地址,number值最終為1,另一個線程對number賦值為10的操作表現為無效操作。
看一個例子:
[csharp] view plaincopy
- using System;
- using System.Threading;
- namespace Edu.Study.Multithreading.WriteValue {
- class Program {
- /// <summary>
- /// 多個線程要訪問的變數
- /// </summary>
- private static int number = 0;
- /// <summary>
- /// 令線程隨機休眠的隨機數對象
- /// </summary>
- private static Random random = new Random();
- /// <summary>
- /// 線程入口方法, 這裡為了簡化編程, 使用了靜態方法
- /// </summary>
- private static void ThreadWork(object arg) {
- // 迴圈1000次, 每次將number欄位的值加1
- for (int i = 0; i < 1000; ++i) {
- // += 1操作比++操作需要更多的CPU指令, 以增加出現錯誤的幾率
- number += 1;
- // 線程在10毫秒內隨機休眠, 以增加出現錯誤的幾率
- Thread.Sleep(random.Next(10));
- }
- }
- /// <summary>
- /// 主方法
- /// </summary>
- static void Main(string[] args) {
- do {
- // 令number為0, 重新給其賦值
- number = 0;
- Thread t1 = new Thread(new ParameterizedThreadStart(ThreadWork));
- Thread t2 = new Thread(new ParameterizedThreadStart(ThreadWork));
- // 啟動兩個線程訪問number變數
- t1.Start();
- t2.Start();
- // 等待線程退出, Timeout.Infinite表示無限等待
- while (t1.Join(Timeout.Infinite) && t2.Join(Timeout.Infinite)) {
- Console.WriteLine(number);
- break;
- }
- Console.WriteLine("請按按回車鍵重新測試,任意鍵退出程式......");
- } while (Console.ReadKey(false).Key == ConsoleKey.Enter);
- }
- }
- }
例子中,兩個線程(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- int n = 0;
- // 將n加1
- // 執行完畢後n的值變為1, 和返回值相同
- int x = Interlocked.Add(ref n, 1);
- // 將n減1
- x = Interlocked.Add(ref n, -1);
- Interlocked.Increment/Interlocked.Decrement方法演示
- int n = 0;
- // 對n進行自加操作
- // 執行完畢後n的值變為1, 和返回值相同
- int x = Interlocked.Increment(ref n);
- // 對n進行自減操作
- x = Interlocked.Decrement(ref n);
- Interlocked.Exchange方法演示
- string s = "Hello";
- // 用另一個字元串對象"OK"為s賦值
- // 操作完畢後s變數改變為引用到"OK"對象, 返回"Hello"對象的引用
- string old = Interlocked.Exchange(ref s, "OK");
- Interloceked.CompareExchange方法演示
- string s = "Hello";
- string ss = s;
- // 首先用變數ss和s比較, 如果相同, 則用另一個字元串對象"OK"為s賦值
- // 操作完畢後s變數改變為引用到"OK"對象, 返回"Hello"對象的引用
- string old = Interlocked.CompareExchange(ref s, ss, "OK");
註意,原子操作中,要賦值的變數都是以引用方式傳遞參數的,這樣才能在原子操作方法內部直接改變變數的值,才能完全避免非安全的賦值操作。
下麵我們將前一節中出問題的代碼做一些修改,修改其ThreadWork方法,在多線程下能夠安全的操作同一個變數:
[csharp] view plaincopy
- private static void ThreadWork(object arg) {
- for (int i = 0; i < 1000; ++i) {
- // 使用原子方式操作變數, 避免多個線程為同一變數賦值出現錯誤
- Interlocked.Add(ref number, 1);
- Thread.Sleep(random.Next(10));
- }
- }
上述代碼解決了一個重要的問題:同一個變數同時只能被一個線程賦值。
2 迴圈鎖、關鍵代碼段和令牌對象
使用變數的原子操作可以解決整數變數的加減計算和各類變數的賦值操作(或比較後賦值操作)的問題,但對於更複雜的同步操作,原子操作並不能解決問題。
有時候我們需要讓同一段代碼同時只能被一個線程執行,而不僅僅是同一個變數同時只能被一個線程訪問,例如如下操作:
[csharp] view plaincopy
- double a = 10;
- double b = 20;
- c = Math.Pow(a, 2);
- c += Math.Pow(b, 2);
- c = Math.Sqrt(c);
- c /= Math.PI;
假設變數c是一個類欄位,同時被若幹線程賦值,顯然僅通過原子操作,無法解決c變數被不同線程同時訪問的問題,因為計算c需要若幹步才能完成計算,需要比較多的指令,原子操作只能在對變數一次賦值時產生同步,面對多次賦值,顯然無能為力。無論c=Math.Pow(a, 2)這步如何原子操作後,這步結束後下步開始前,c的值都有可能其它線程改變,從而最終計算出錯誤的結果。
所以鎖定必須要施加到一段代碼上才能解決上述問題,這就是關鍵代碼段:
關鍵代碼段需要兩個前提條件:
- 一個作為令牌的對象;
- 一個鎖操作。
令牌對象有個狀態屬性:具備兩個屬性值:掛起和釋放。可以通過原子操作改變這個屬性的屬性值。規定:所有線程都可以訪問同一個令牌對象,但只有訪問時令牌對象狀態屬性為釋放狀態的那個線程,才能執行被鎖定的代碼,同時將令牌對象的狀態屬性更改為掛起。其餘線程自動進入迴圈檢測代碼(在一個迴圈中不斷檢測令牌對象的狀態),直到第一個對象訪問完鎖定代碼,將令牌對象狀態屬性重新設置為釋放狀態,其餘線程中的某一個才能檢測到令牌對象已經釋放並接著執行被鎖定的代碼,同時將令牌對象狀態屬性設置為掛起。
語法如下:
[csharp] view plaincopy
- lock (對象引用) {
- // 關鍵代碼段
- }
其中lock稱為迴圈鎖,訪問的引用變數所引用的對象稱為令牌對象,一對大括弧中的代碼稱為關鍵代碼段。如果同時有多個線程訪問同一關鍵代碼段,則可以保證每次同時只有一個線程可以執行這段代碼,一個線程執行完畢後另一個線程才能解開鎖並執行這段代碼。
所以前面的那段代碼可以改為:
[csharp] view plaincopy
- double a = 10;
- double b = 20;
- lock (某對象引用) {
- c = Math.Pow(a, 2);
- c += Math.Pow(b, 2);
- c = Math.Sqrt(c);
- c /= Math.PI;
- }
在.net Framework中,任意引用類型對象都可以作為令牌對象。
鎖定使用起來很簡單,關鍵在使用前要考慮鎖定的顆粒度,也就是鎖定多少行代碼才能真正的安全。鎖定的代碼過少,可能無法保證完全同步,鎖定的代碼過多,有可能會降低系統執行效率(導致線程無法真正意義上的同時執行),我們舉個例子,解釋一下鎖定的顆粒度:
程式界面設計如下:
程式運行效果圖如下:
源代碼摘錄如下:
FormMain.cs
[csharp] view plaincopy
- using System;
- using System.Drawing;
- using System.Threading;
- using System.Windows.Forms;
- namespace Edu.Study.Multithreading.Lock {
- /// <summary>
- /// 更新PictureBox背景色的委托
- /// </summary>
- /// <param name="index">要更改背景色的PictureBox對象在數組中的索引</param>
- /// <param name="color">背景色</param>
- public delegate void ChangeRadioButtonHandler(int index, Color color);
- /// <summary>
- /// 主窗體
- /// </summary>
- public partial class FormMain : Form {
- /// <summary>
- /// RadioButton的數組
- /// </summary>
- private PictureBox[] picboxes = new PictureBox[10];
- /// <summary>
- /// 線程1, 將picboxes數組中的PictureBox對象背景色逐個設置為紅色
- /// </summary>
- private Thread thread1 = null;
- /// <summary>
- /// 線程1, 將picboxes數組中的PictureBox對象背景色逐個設置為綠色
- /// </summary>
- private Thread thread2 = null;
- /// <summary>
- /// 主窗體構造器
- /// </summary>
- public FormMain() {
- InitializeComponent();
- // 初始化picboxes數組, 向其中存放PictureBox對象引用
- for (int i = 0; i < this.picboxes.Length; ++i) {
- PictureBox rb = new PictureBox();
- // 設置PictureBox對象大小
- rb.Size = new Size(50, 50);
- // 設置PictureBox邊框樣式
- rb.BorderStyle = BorderStyle.Fixed3D;
- // 設置PictureBox背景色初始為白色
- rb.BackColor = Color.White;
- this.picboxes[i] = rb;
- // 將PictureBox控制項對象放置在流式佈局面板上
- this.mainFlowLayoutPanel.Controls.Add(rb);
- }
- // 根據控制項的數量重新計算窗體寬度
- this.Width =
- this.mainFlowLayoutPanel.Padding.Left +
- this.mainFlowLayoutPanel.Padding.Right +
- this.picboxes.Length * (50 + this.picboxes[0].Margin.Left + this.picboxes[0].Margin.Right);
- }
- /// <summary>
- /// 實現ChangeRadioButtonHandler委托, 轉換設置某個PictureBox控制項背景色
- /// </summary>
- /// <param name="index">要更改背景色的PictureBox在數組中的索引</param>
- /// <param name="color">背景色</param>
- private void ChangeRadioButton(int index, Color color) {
- // 操作如下: 從this.picboxes數組中, 每次將index參數指定的PictureBox對象設置為參數color指定的顏色
- // 並將前一個PictureBox對象背景色設置為白色
- if (index == 0) { // 如果index參數為零, 表示數組中第一個PictureBox對象
- // 將數組最後一個PictureBox對象背景色設置為白色
- this.picboxes[this.picboxes.Length - 1].BackColor = Color.White;
- } else { // 如果index參數不為零
- // 將 index-1指定的PictureBox背景色設置為白色
- this.picboxes[index - 1].BackColor = Color.White;
- }
- // 將index指定的PictureBox對象背景色設置為color參數指定的顏色
- this.picboxes[index].BackColor = color;
- }
- /// <summary>
- /// 線程方法1, 展示顆粒度較小的鎖定
- /// </summary>
- /// <param name="arg">傳入的參數對象, 這裡為一個Color類的對象, 表示背景色</param>
- private void ThreadWorkTest1(object arg) {
- try { // 用於退出線程的異常捕獲結構
- while (true) {
- // 遍歷this.picboxes數組
- for (int i = 0; i < this.picboxes.Length; ++i) {
- // 以當前Form類對象為令牌對象, 這次鎖定發生在迴圈內
- lock (this) {
- // 執行ChangeRadioButton方法, 更改PictureBox的背景色
- this.BeginInvoke(new ChangeRadioButtonHandler(this.ChangeRadioButton), i, arg);
- Thread.Sleep(500);
- }
- }
- }
- } catch (ThreadAbortException) {
- }
- }
- /// <summary>
- /// 線程方法2, 展示顆粒度較大的鎖定
- /// </summary>
- /// <param name="arg">傳入的參數對象, 這裡為一個Color類的對象, 表示背景色</param>
- private void ThreadWorkTest2(object arg) {
- try { // 用於退出線程的異常捕獲結構
- while (true) {
- // 以當前Form類對象為令牌對象, 這次鎖定鎖定整個迴圈
- lock (this) {
- // 遍歷this.picboxes數組
- for (int i = 0; i < this.picboxes.Length; ++i) {
- // 執行ChangeRadioButton方法, 更改PictureBox的背景色
- this.BeginInvoke(new ChangeRadioButtonHandler(this.ChangeRadioButton), i, arg);
- Thread.Sleep(500);
- }
- }
- }
- &nb