C#語言 線程同步(1)

0 概述

上一章我們瞭解了多個線程可以“同時”運行代碼(我們稱爲“異步運行”),這一章我們來研究爲什麼異步運行的線程需要同步?

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

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

一個線程寫,其它線程讀:這種情況不存在同步問題,因爲只有一個線程在改變內存中的變量,內存中的變量在任意時刻都有一個確定的值;
一個線程讀,其它線程寫:這種情況會存在同步問題,主要是多個線程在同時寫入一個變量的時候,可能會發生一些難以察覺的錯誤,導致某些線程實際上並沒有真正的寫入變量;
幾個線程寫,其它線程讀:情況同2。
多個線程同時向一個變量賦值,就會出現問題,這是爲什麼呢?

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

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

尋址:由CPU的控制器找尋到number變量所在的地址;
讀取:將number變量所在的值從內存中讀取到CPU寄存器中;
運算:由CPU的算術邏輯運算器(ALU)對number值進行計算,將結果存儲在寄存器中;
保存:由CPU的控制器將寄存器中保存的結果重新存入number在內存中的地址。
這是最簡單的時序,如果牽扯到CPU的高速緩存(CACHE),則情況就更爲複雜了。

 圖1 CPU結構簡圖

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

看一個例子:

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方法演示

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方法,在多線程下能夠安全的操作同一個變量:

private static void ThreadWork(object arg) {
    for (int i = 0; i < 1000; ++i) {
        // 使用原子方式操作變量, 避免多個線程爲同一變量賦值出現錯誤
        Interlocked.Add(ref number, 1);
        Thread.Sleep(random.Next(10));
    }
}

本節代碼下載

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

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

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

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

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的值都有可能其它線程改變,從而最終計算出錯誤的結果。

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

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

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

語法如下:

lock (對象引用) {
    // 關鍵代碼段
}

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

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

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中,任意引用類型對象都可以作爲令牌對象。

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

程序界面設計如下:

 
圖2 循環鎖程序設計界面

程序運行效果圖如下:

循環鎖程序設計界面

 
圖3 程序運行效果圖

訊院所程序運行效果圖

源代碼摘錄如下:

FormMain.cs

1   using System;
2   using System.Drawing;
3   using System.Threading;
4   using System.Windows.Forms;
5    
6   namespace Edu.Study.Multithreading.Lock {
7    
8       /// <summary>
9       /// 更新PictureBox背景色的委託
10       /// </summary>
11       /// <param name="index">要更改背景色的PictureBox對象在數組中的索引</param>
12       /// <param name="color">背景色</param>
13       public delegate void ChangeRadioButtonHandler(int index, Color color);
14    
15    
16       /// <summary>
17       /// 主窗體
18       /// </summary>
19       public partial class FormMain : Form {
20    
21           /// <summary>
22           /// RadioButton的數組
23           /// </summary>
24           private PictureBox[] picboxes = new PictureBox[10];
25    
26           /// <summary>
27           /// 線程1, 將picboxes數組中的PictureBox對象背景色逐個設置爲紅色
28           /// </summary>
29           private Thread thread1 = null;
30    
31           /// <summary>
32           /// 線程1, 將picboxes數組中的PictureBox對象背景色逐個設置爲綠色
33           /// </summary>
34           private Thread thread2 = null;
35    
36           /// <summary>
37           /// 主窗體構造器
38           /// </summary>
39           public FormMain() {
40               InitializeComponent();
41    
42               // 初始化picboxes數組, 向其中存放PictureBox對象引用
43               for (int i = 0; i < this.picboxes.Length; ++i) {
44                   PictureBox rb = new PictureBox();
45    
46                   // 設置PictureBox對象大小
47                   rb.Size = new Size(50, 50);
48                   // 設置PictureBox邊框樣式
49                   rb.BorderStyle = BorderStyle.Fixed3D;
50                   // 設置PictureBox背景色初始爲白色
51                   rb.BackColor = Color.White;
52    
53                   this.picboxes[i] = rb;
54    
55                   // 將PictureBox控件對象放置在流式佈局面板上
56                   this.mainFlowLayoutPanel.Controls.Add(rb);
57               }
58    
59               // 根據控件的數量重新計算窗體寬度
60               this.Width = 
61                   this.mainFlowLayoutPanel.Padding.Left +
62                   this.mainFlowLayoutPanel.Padding.Right +
63                   this.picboxes.Length * (50 + this.picboxes[0].Margin.Left + this.picboxes[0].Margin.Right);
64           }
65    
66           /// <summary>
67           /// 實現ChangeRadioButtonHandler委託, 轉換設置某個PictureBox控件背景色
68           /// </summary>
69           /// <param name="index">要更改背景色的PictureBox在數組中的索引</param>
70           /// <param name="color">背景色</param>
71           private void ChangeRadioButton(int index, Color color) {
72    
73               // 操作如下: 從this.picboxes數組中, 每次將index參數指定的PictureBox對象設置爲參數color指定的顏色
74               // 並將前一個PictureBox對象背景色設置爲白色
75    
76               if (index == 0) { // 如果index參數爲零, 表示數組中第一個PictureBox對象
77    
78                   // 將數組最後一個PictureBox對象背景色設置爲白色
79                   this.picboxes[this.picboxes.Length - 1].BackColor = Color.White;
80    
81               } else { // 如果index參數不爲零
82    
83                   // 將 index-1指定的PictureBox背景色設置爲白色
84                   this.picboxes[index - 1].BackColor = Color.White;
85               }
86    
87               // 將index指定的PictureBox對象背景色設置爲color參數指定的顏色
88               this.picboxes[index].BackColor = color;
89           }
90    
91           /// <summary>
92           /// 線程方法1, 展示顆粒度較小的鎖定
93           /// </summary>
94           /// <param name="arg">傳入的參數對象, 這裏爲一個Color類的對象, 表示背景色</param>
95           private void ThreadWorkTest1(object arg) {
96               try { // 用於退出線程的異常捕獲結構
97                   while (true) {
98                       // 遍歷this.picboxes數組
99                       for (int i = 0; i < this.picboxes.Length; ++i) {
100                           // 以當前Form類對象爲令牌對象, 這次鎖定發生在循環內
101                           lock (this) {
102                               // 執行ChangeRadioButton方法, 更改PictureBox的背景色
103                               this.BeginInvoke(new ChangeRadioButtonHandler(this.ChangeRadioButton), i, arg);
104                               Thread.Sleep(500);
105                           }
106                       }
107                   }
108               } catch (ThreadAbortException) {
109               }
110           }
111    
112           /// <summary>
113           /// 線程方法2, 展示顆粒度較大的鎖定
114           /// </summary>
115           /// <param name="arg">傳入的參數對象, 這裏爲一個Color類的對象, 表示背景色</param>
116           private void ThreadWorkTest2(object arg) {
117               try { // 用於退出線程的異常捕獲結構
118                   while (true) {
119                       // 以當前Form類對象爲令牌對象, 這次鎖定鎖定整個循環
120                       lock (this) {
121                           // 遍歷this.picboxes數組
122                           for (int i = 0; i < this.picboxes.Length; ++i) {
123                               // 執行ChangeRadioButton方法, 更改PictureBox的背景色
124                               this.BeginInvoke(new ChangeRadioButtonHandler(this.ChangeRadioButton), i, arg);
125                               Thread.Sleep(500);
126                           }
127                       }
128                   }
129               } catch (ThreadAbortException) {
130               }
131           }
132    
133           /// <summary>
134           /// 退出線程
135           /// </summary>
136           private void AbortThreads() {
137               // 如果線程1對象存在, 終止線程1對象
138               if (this.thread1 != null) {
139                   // 取消線程運行
140                   this.thread1.Abort();
141                   // 等待線程結束
142                   this.thread1.Join();
143               }
144               // 如果線程2對象存在, 終止線程2對象
145               if (this.thread2 != null) {
146                   this.thread2.Abort();
147                   this.thread2.Join();
148               }
149           }
150    
151           /// <summary>
152           /// "測試1"按鈕事件
153           /// </summary>
154           private void test1StartButton_Click(object sender, EventArgs e) {
155               // 終止上一次啓動的線程
156               this.AbortThreads();
157    
158               // 初始化線程1, 使用ThreadWorkTest1方法作爲入口方法
159               this.thread1 = new Thread(new ParameterizedThreadStart(this.ThreadWorkTest1));
160               // 初始化線程2, 使用ThreadWorkTest1方法作爲入口方法
161               this.thread2 = new Thread(new ParameterizedThreadStart(this.ThreadWorkTest1));
162    
163               // 啓動線程1, 參數爲紅色, 表示線程1將picboxes數組中的對象改爲紅色
164               this.thread1.Start(Color.Red);
165               // 啓動線程2, 參數爲綠色, 表示線程1將picboxes數組中的對象改爲綠色
166               this.thread2.Start(Color.Green);
167           }
168    
169           /// <summary>
170           /// "測試2"按鈕事件
171           /// </summary>
172           private void test2StartButton_Click(object sender, EventArgs e) {
173               // 終止上一次啓動的線程
174               this.AbortThreads();
175    
176               // 初始化線程1, 使用ThreadWorkTest2方法作爲入口方法
177               this.thread1 = new Thread(new ParameterizedThreadStart(this.ThreadWorkTest2));
178               // 初始化線程2, 使用ThreadWorkTest2方法作爲入口方法
179               this.thread2 = new Thread(new ParameterizedThreadStart(this.ThreadWorkTest2));
180    
181               // 啓動線程1, 參數爲紅色, 表示線程1將picboxes數組中的對象改爲紅色
182               this.thread1.Start(Color.Red);
183               // 啓動線程2, 參數爲綠色, 表示線程1將picboxes數組中的對象改爲綠色
184               this.thread2.Start(Color.Green);
185           }
186    
187           /// <summary>
188           /// 窗體關閉事件
189           /// </summary>
190           private void FormMain_FormClosing(object sender, FormClosingEventArgs e) {
191               // 終止並等待所有的輔助線程
192               AbortThreads();
193           }
194       }
195   }

幾點說明:

ThreadWorkTest1方法(第95-110行)和ThreadWorkTest2方法(第116-131行)算法邏輯完全相同,只是鎖定顆粒度不同,前者是在循環內對代碼加鎖,後者是再循環外對循環加鎖。造成的效果就是:前者兩個線程可以同時進入循環,只是執行循環內兩句代碼時是同步執行的,所以點擊“測試1”按鈕,可以看到每個圖片框先變成紅色,然後變成綠色,接下來輪到下一個圖片框不斷重複;後者兩個線程同時只能有一個進入循環,所以點擊“測試2”按鈕,可以看到,所有的圖片框先逐次變成紅色,然後逐次變成綠色;
兩個按鈕點擊事件都要啓動新的線程,所以必須等待原有線程結束後才能啓動新線程,AbortThreads方法(第136-149行)作用就是結束線程並等待線程退出。由於線程中沒有必須要完成的操作,所以直接使用Thread類的Abort方法結束線程,並使用Join方法等待線程退出。
BeginInvoke方法(第103行、124行)和Invoke方法作用相同,區別就是Invoke方法會等待窗體線程(這裏爲主線程)將委託方法執行完畢後才返回,而BeginInvoke方法則不會等待,只是告訴窗體線程要執行這麼一個委託,並不關心委託方法執行結果。BeginInvoke方法可以避免窗體線程阻塞導致的Invoke方法被阻塞,即即便窗體線程被阻塞了(例如使用Join方法阻塞),輔助線程調用窗體的BeginInvoke方法也不會被阻塞。如此以來就可以省略前面介紹的啓動等待線程等待輔助線程結束的代碼。注意:BeginInvoke的使用時機爲:1、不關心委託方法的返回值;2、傳遞給委託方法的參數一般爲一個局部對象的引用,如果是全局對象,則無法保證委託方法運行時,這個對象是什麼狀態。
一旦多個線程要向同一個變量賦值,或者向同一個集合存入對象引用,就必須使用鎖來同步。

通過上述的例子,一方面思考循環鎖的作用;一方面考慮調整循環鎖的鎖定顆粒度對程序帶來的影響。將104行線程休眠代碼移出關鍵代碼段(即移動到lock結束大括號之後),運行代碼,查看運行結果,思考原因


本文來自CSDN博客,轉載請標明出處:http://blog.csdn.net/mousebaby808/archive/2010/04/12/5477733.aspx

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章