兩年前我曾經寫過一篇講理解實值型別,參考型別的文章,主要是講常見的實值型別,參考型別的區別。但是這兩種類型的淵源不止那些,今天就說說實值型別線上程方面的原子性問題。
目錄
- 0. 概念闡述
- 1. 使用Interlocked類型
- 2. 使用參考型別來聲明
- 3. 通過裝箱和拆箱
- 4. 使用lock
返回目錄
0. 概念闡述
首先我想專門強調一下,“原子性”和“安全執行緒”屬於容易混淆的概念,解決了“原子性”不等於解決了“安全執行緒”,但是如果連“原子性”都沒有保證,那麼肯定不是“安全執行緒”的。“原子性”屬於“安全執行緒”考慮的一個範疇,本文也僅僅是討論“原子性”問題。
根據C#語言標準的描述,只有如下類型的讀寫是保證具有原子性的:
bool, char, byte, sbyte, short, ushort, uint, int, float,參考型別,和以上述實值型別做為底層類型的枚舉類型。
但同時注意這些類型的運算(包含遞增++和遞減--)不是原子性的。
但是根據CLI標準。
I.12.6.6(Atomic reads and writes):CLI應該保證如下類型的讀寫是原子性的:
在記憶體中正確對其的位置,且大小不大於CPU一次性處理的字大小(也就是所謂的WORD大小,等於本地int大小)。
那麼可以推理出在64位CPU的環境下,64位的內建類型(如long, ulong, double)的讀寫也是具有原子性的。這一點在Eric Lippert的部落格中敘述過。不過注意,Eric Lippert也在其文章中提到由於這是CLI的規定,而不是C#語言的規定,因此某些非標準CLI執行環境可能會打破這個推斷。因此對於這些特殊類型,我個人覺得最好還是用Interlocked類型提供的操作,或者使用lock。下文會詳細講這些內容。
下面開始闡述嘗試解決實值型別原子性的各種方法,越是上面的方法越推薦使用。
返回目錄
1. 使用Interlocked類型
對於解決實值型別的原子性問題,首先考慮的類型就是System.Threading.Interlocked類型。如,Interlocked類型的方法成員:
可以看到,Interlocked類型提供多種形式的原子性操作:讀取,寫入,遞增,遞減等。同時還支援多種類型。
使用Interlocked類型提供的方法可以減少lock的使用從而增加效能,比如實現一個計數器的添加原子性操作,其實是沒有必要像這樣使用lock的:
readonly object locker = new object();
int myVar;
//執行原子性增值操作的方法
public void AtomicIncrement(int increment)
{
lock (locker)
{
myVar += increment;
}
}
完全可以使用Interlocked類型來執行這個原子性操作,使用如下更推薦的代碼:
int myVar;
//執行原子性增值操作的方法
public void AtomicIncrement(int increment)
{
Interlocked.Add(ref myVar, increment);
}
這不僅會增加效能,還減少了代碼,不需要聲明用於lock的對象。
因此,如果能用到Interlocked的話,盡量就用Interlocked類型去解決問題,當然也肯定會有Interlocked類型不支援的方法,比如Read方法只支援long參數,沒有double。(這個問題之後會被解決的)
返回目錄
2. 使用參考型別來聲明
比如這樣一個實值型別:
struct MyStruct
{
public long Data1;
public double Data2;
}
顯然,該類型的對象佔用空間肯定是大於等於128位的,無論在32位還是64位CPU環境下,該類型的讀寫操作都不具備原子性的。而且Interlocked類型不支援使用者定義的結構體,那麼怎樣使其具備原子性呢?
其實如果可以的話,這裡最好的方法就是把它改成class,是的,參考型別的讀寫始終是具備原子性的。這樣的話我們仍然可以不用lock。
//把MyStruct改成MyClass,該類型的讀寫具備了原子性
class MyClass
{
public long Data1;
public double Data2;
}
返回目錄
3. 通過裝箱和拆箱
假設我們無法把上面的MyStruct結構體定義成參考型別,怎樣解決原子性讀寫的問題?還有一個小詭計的,先來描述一下問題,我們先聲明一個MyStruct結構體的屬性:
public MyStruct MyStructObject { get; set; }
顯然,MyStruct結構體的讀寫是不具備原子性的,如果一個線程在寫入MyStructObject屬性,而另一個線程在讀取MyStructObject變數,那麼可能會輸出意想不到的值,因為有可能在一個線程寫入了一半的時候,另一個線程開始了讀取操作。於是另一個線程通過MyStructObject屬性讀取的值可能既不是寫入前,也不是寫入後的值,而是部分寫入部分原始的不完整狀態。
//一個線程在寫入
MyStructObject = new MyStruct() { Data1 = 2, Data2 = 3 };
//另一個線程在讀取
//輸出既不是寫入前,也不是寫入後的值,而是部分寫入部分原始的不完整狀態!
Console.WriteLine("{0} {1}", MyStructObject.Data1, MyStructObject.Data2);
詭計就是可以使用裝箱和拆箱,把屬性聲明成object,如下:
//宣告類型為object
//對於MyStruct對象的讀寫需要裝箱和拆箱
public static object MyStructObject { get; set; }
這樣如果再次遇到上面的多線程讀寫代碼,不會有問題發生(注意讀取MyStructObject屬性時需要一次強制轉換,也就是裝箱操作),因為此時讀寫操作會進行裝箱拆箱操作,最終操作的是object引用對象,它的讀寫操作是具備原子性的。
返回目錄
4. 使用lock
上面提到過的方法都有自己的優勢,但是全部不是萬能的,而如果使用lock的話,雖然有額外的效能損耗,但它確是萬能的。所以如果有任何非原子性操作,可以把他嵌套到一個lock中,這樣其他線程就不會打斷這些操作,整個過程具備原子性了!
比如在32位CPU環境下,進行原子的double加法操作,可以這樣:
readonly object locker = new object();
double myDouble;
//執行原子性增值操作的方法
public void AtomicIncrement(double increment)
{
//Interlocked.Add方法不支援double類型!!!
lock (locker)
{
myDouble += increment;
}
}