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),則情況就更為復雜了。
圖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
例子中,兩個線程(t1和t2)同時訪問number變量(初始值為0),對其進行1000次+1操作,在兩個線程都結束後,在主線程顯式number變量的最終值。可以看到,很經常的,最終顯示的結果不是2000,而是1999或者更少。究其原因,就是發生了我們上面講的問題:兩個線程在進行賦值操作時,時序重疊了。
可以做實驗,在CPU核心數越多的計算機上,上述代碼出現問題的幾率越小。這是因為多核心CPU可能會在每一個獨立核心上各自運行一個線程,而CPU設計者針對這種多核心訪問一個內存地址的情況,本身就設計了防范措施。
第二種情況:多個線程組成了生產者和消費者:
我們前面已經講過,多線程並不能加快算法速度(多核心處理器除外),所以多線程的主要作用還是為了提高用戶的響應,一般有兩種方式:
所以,線程之間很容易就形成了生產者/消費者模式,即一個線程的某部分代碼必須要等待另一個線程計算出結果後才能繼續運行。目前存在兩種情況需要線程間同步執行:
1 變量的原子操作
CPU有一套指令,可以在訪問內存中的變量前,並將一段內存地址標記為“只讀”,此時除過標志內存的那個線程外,其余線程來訪問這塊內存,都將發生阻塞,即必須等待前一個線程訪問完畢後其它線程才能繼續訪問這塊內存。
這種鎖定的結果是:所有線程只能依次訪問某個變量,而無法同時訪問某個變量,從而解決了多線程訪問變量的問題。
原子操作封裝在Interlocked類中,以一系列靜態方法提供:
例如:
Interlocked.Add方法演示
[csharp] view plaincopy注意,原子操作中,要賦值的變量都是以引用方式傳遞參數的,這樣才能在原子操作方法內部直接改變變量的值,才能完全避免非安全的賦值操作。
下面我們將前一節中出問題的代碼做一些修改,修改其ThreadWork方法,在多線程下能夠安全的操作同一個變量:
[csharp] view plaincopy
上述代碼解決了一個重要的問題:同一個變量同時只能被一個線程賦值。
2 循環鎖、關鍵代碼段和令牌對象
使用變量的原子操作可以解決整數變量的加減計算和各類變量的賦值操作(或比較後賦值操作)的問題,但對於更復雜的同步操作,原子操作並不能解決問題。
有時候我們需要讓同一段代碼同時只能被一個線程執行,而不僅僅是同一個變量同時只能被一個線程訪問,例如如下操作:
[csharp] view plaincopy
假設變量c是一個類字段,同時被若干線程賦值,顯然僅通過原子操作,無法解決c變量被不同線程同時訪問的問題,因為計算c需要若干步才能完成計算,需要比較多的指令,原子操作只能在對變量一次賦值時產生同步,面對多次賦值,顯然無能為力。無論c=Math.Pow(a, 2)這步如何原子操作後,這步結束後下步開始前,c的值都有可能其它線程改變,從而最終計算出錯誤的結果。
所以鎖定必須要施加到一段代碼上才能解決上述問題,這就是關鍵代碼段:
關鍵代碼段需要兩個前提條件:
令牌對象有個狀態屬性:具備兩個屬性值:掛起和釋放。可以通過原子操作改變這個屬性的屬性值。規定:所有線程都可以訪問同一個令牌對象,但只有訪問時令牌對象狀態屬性為釋放狀態的那個線程,才能執行被鎖定的代碼,同時將令牌對象的狀態屬性更改為掛起。其余線程自動進入循環檢測代碼(在一個循環中不斷檢測令牌對象的狀態),直到第一個對象訪問完鎖定代碼,將令牌對象狀態屬性重新設置為釋放狀態,其余線程中的某一個才能檢測到令牌對象已經釋放並接著執行被鎖定的代碼,同時將令牌對象狀態屬性設置為掛起。
語法如下:
[csharp] view plaincopy
其中lock稱為循環鎖,訪問的引用變量所引用的對象稱為令牌對象,一對大括號中的代碼稱為關鍵代碼段。如果同時有多個線程訪問同一關鍵代碼段,則可以保證每次同時只有一個線程可以執行這段代碼,一個線程執行完畢後另一個線程才能解開鎖並執行這段代碼。
所以前面的那段代碼可以改為:
[csharp] view plaincopy
在.net Framework中,任意引用類型對象都可以作為令牌對象。
鎖定使用起來很簡單,關鍵在使用前要考慮鎖定的顆粒度,也就是鎖定多少行代碼才能真正的安全。鎖定的代碼過少,可能無法保證完全同步,鎖定的代碼過多,有可能會降低系統執行效率(導致線程無法真正意義上的同時執行),我們舉個例子,解釋一下鎖定的顆粒度:
程序界面設計如下:
圖2 循環鎖程序設計界面