關於C#多線程的文章,大部分都在討論線程的起停或者是多線程同步問題。多線程同步就是在不同線程中訪問同一個變量(一般是線程工作函數外部的變量),眾所周知在不使用線程同步的機制下,由於竟態的存在會使某些線程產生臟讀或者是覆蓋其它線程已寫入的值(各種混亂)。而另外一種情況就是我們想讓線程所訪問的變量屬於線程自身所有,這就是所謂的線程本地變量。
下文我們將逐漸擴展一個最簡單的示例代碼,來展示上面所說的變量並發訪問以及線程本地變量的區別和各自解決方案。
這里要展示的例子很簡單。所訪問的變量是一個“袋子內蘋果的數量”,而工作函數就是“往袋子里放蘋果”。
public class Bag
{
public int AppleNum { get; set; }
}
public class Test
{
public void TryTwoThread()
{
var b = new Bag();
Action localAct = () =>
{
for (int i = 0; i < 10; i++)
{
++b.AppleNum;
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} - {b.AppleNum}");
Thread.Sleep(100);
}
};
Parallel.Invoke(localAct, localAct);
}
}
// Program.cs
var tester = new Test();
tester.TryTwoThread();
如代碼所示,這是一段經典的多線程變量並發訪問錯誤的代碼。由於沒有任何並發訪問控制的代碼,所以執行結果是不確定的。我們期望的結果是有20個蘋果在袋子種,實際情況下很難達到這個結果。
由於執行結果不確定,所以上面只是展示了其中一種隨機出現的情況。
解決這個問題的方法就是使用並發控制,最容易的方法就是給共享變量的訪問加個鎖。
public class Test
{
private object _locker = new object();
public void TryTwoThread()
{
var b = new Bag();
Action localAct = () =>
{
for (int i = 0; i < 10; i++)
{
lock(_locker)
{
++b.AppleNum;
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} - {b.AppleNum}");
}
Thread.Sleep(100);
}
};
Parallel.Invoke(localAct, localAct);
}
}
這樣執行結果就能得到保障,最終袋子里就會有20個蘋果。當然還有其它並發控制方法,但那不是本文重點忽略不說。
在某些場景下我們會有另一種需求,我們關心的是每個線程往袋子里放了多少個蘋果。這時我們就需要讓Bag對象與線程相關(有多個袋子,每個袋子為線程所有)。這就需要用到本文重點要介紹的內容 - 線程本地變量。
在不使用線程本地變量的情況下,實現上述目的的一個簡單方法是把變量放入工作函數內部,作為函數內部變量。
public class Test
{
public void TryTwoThread()
{
Action localAct = () =>
{
var b = new Bag(); //把變量訪問工作函數當中
for (int i = 0; i < 10; i++)
{
++b.AppleNum;
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} - {b.AppleNum}");
Thread.Sleep(100);
}
};
Parallel.Invoke(localAct, localAct);
}
}
可以看到結果如我們所願。
如果我們的工作函數是獨立於一個類中,且要並發的訪問的變量是這個類的成員,上面這種方法就不適用了。
前面的例子種的Action換成如下的工作類:
public class Worker
{
private Bag _bag = new Bag();
public void PutTenApple()
{
for (int i = 0; i < 10; i++)
{
PutApple();
Show();
Thread.Sleep(100);
}
}
private void PutApple()
{
++_bag.AppleNum;
}
private void Show()
{
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} - {_bag.AppleNum}");
}
}
測試方法改為:
public void TryTwoThread()
{
var worker = new Worker();
Parallel.Invoke(worker.PutTenApple, worker.PutTenApple);
}
注意上面的Worker類也是一個不滿足我們每個線程獨立操作自己關聯變量要求的例子。而且由於沒有並發控制,程序的執行結果不可控。
我們也可以將
_bag
變量聲明於PutTenApple
中來實現與線程本地變量一樣的效果,但那樣在調用PutApple
和Show
方法時就免不了傳參數。
下面開始介紹幾種實現線程本地變量的方法。
線程相關的靜態字段
第一種方法線程相關的靜態字段是使用ThreadStatic
Attribute。這也是微軟推薦的性能更好的方法。
其做法是將成員變量聲明為static
並打上[ThreadStatic]
這個標記。我們在之前代碼的基礎上做如下修改:
[ThreadStatic] private static Bag _bag = new Bag();
注意這個實現是有問題的。下面會詳細介紹。
如果你的VS上也安裝有Resharper這個宇宙級插件,你會看到在初始化這個靜態變量的代碼下會有這樣的提示:
關於這個提示,ReSharper官網也有解釋。
簡單來說,就是上面的初始化器只會被調用一次,導致的結果就是只有第一個執行此方法的線程能正確獲取到_bag
成員的值,之后的進程再訪問_bag
時,會發現_bag
仍是未初始化狀態 - 為null。
對於這個問題我選擇的解決方式是在工作方法中去初始化_bag
變量。
public class Worker
{
[ThreadStatic] private static Bag _bag;
public void PutTenApple()
{
_bag = new Bag(); //調用前初始化
for (int i = 0; i < 10; i++)
{
PutApple();
Show();
Thread.Sleep(100);
}
}
private void PutApple()
{
++_bag.AppleNum;
}
private void Show()
{
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} - {_bag.AppleNum}");
}
}
ReSharper網站給出的方法是通過一個屬性去包裝這個靜態字段,並將對靜態字段的訪問都換成對靜態屬性的訪問。
public class Worker
{
[ThreadStatic] private static Bag _bag;
public static Bag Bag => _bag ?? (_bag = new Bag());
public void PutTenApple()
{
for (int i = 0; i < 10; i++)
{
PutApple();
Show();
Thread.Sleep(100);
}
}
private void PutApple()
{
++Bag.AppleNum;
}
private void Show()
{
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} - {Bag.AppleNum}");
}
}
對於線程本地變量,如果在線程外訪問,會發現它並沒有受到線程操作的影響。
public void TryTwoThread()
{
var worker = new Worker();
Parallel.Invoke(worker.PutTenApple, worker.PutTenApple);
Console.WriteLine($"Main Thread : {Thread.CurrentThread.ManagedThreadId} - {Worker.Bag.AppleNum}");
}
主線程中訪問情況:
數據槽
另一種等價的方法是使用LocalDataStoreSlot
,但是性能不如上面介紹的ThreadStatic
方法。
public class Worker
{
private LocalDataStoreSlot _localSlot = Thread.AllocateDataSlot();
public void PutTenApple()
{
Thread.SetData(_localSlot, new Bag());
for (int i = 0; i < 10; i++)
{
PutApple();
Show();
Thread.Sleep(100);
}
}
private void PutApple()
{
var bag = Thread.GetData(_localSlot) as Bag;
++bag.AppleNum;
}
private void Show()
{
var bag = Thread.GetData(_localSlot) as Bag;
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} - {bag.AppleNum}");
}
}
把線程相關的數據存儲在LocalDataStoreSlot
對象中,並通過Thread
的GetData
和SetData
進行存取。
數據槽還有一種命名的分配方式:
private LocalDataStoreSlot _localSlot = Thread.AllocateNamedDataSlot("Apple");
public void PutTenApple()
{
_localSlot = Thread.GetNamedDataSlot("Apple");//演示用
Thread.SetData(_localSlot, new Bag());
for (int i = 0; i < 10; i++)
{
PutApple();
Show();
Thread.Sleep(100);
}
}
在多組件的情況下,用不同名稱區分數據槽很有用。但如果不小心給不同組件起了相同的名字,則會導致數據污染。
數據槽的性能較低,微軟也不推薦使用,而且不是強類型的,用起來也不太方便。
.NET 4 - ThreadLocal
在.NET Framework 4以后新增了一種泛型化的本地變量存儲機制 - ThreadLocal<T>
。下面的例子也是在之前例子基礎上修改的。對比之前代碼就很好理解ThreadLocal<T>
的使用,ThreadLocal<T>
的構造函數接收一個lambda用於線程本地變量的延遲初始化,通過Value屬性可以訪問本地變量的值。IsValueCreated可以判斷本地變量是否已經創建。
public class Worker
{
private ThreadLocal<Bag> _bagLocal = new ThreadLocal<Bag>(()=> new Bag(), true);
public ThreadLocal<Bag> BagLocal => _bagLocal;
public void PutTenApple()
{
if (_bagLocal.IsValueCreated) //在第一次訪問后,線程本地變量才會被創建
{
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} - 已初始化");
}
for (int i = 0; i < 10; i++)
{
PutApple();
Show();
Thread.Sleep(100);
}
if (_bagLocal.IsValueCreated)
{
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} - 已初始化");
}
}
private void PutApple()
{
var bag = _bagLocal.Value; //通過Value屬性訪問
++bag.AppleNum;
}
private void Show()
{
var bag = _bagLocal.Value; //通過Value屬性訪問
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} - {bag.AppleNum}");
}
}
另外如果在初始化ThreadLocal<T>
時,將其trackAllValues設置為true,則可以在使用ThreadLocal<T>
的線程外部訪問線程本地變量中所存儲的值。如在測試代碼中:
public void TryTwoThread()
{
var worker = new Worker();
Parallel.Invoke(worker.PutTenApple, worker.PutTenApple);
// 可以使用Values在線程外訪問所有線程本地變量(需要ThreadLocal初始化時將trackAllValues設為true)
foreach (var tval in worker.BagLocal.Values)
{
Console.WriteLine(tval.AppleNum);
}
}
關於線程本地變量就寫到這吧。歡迎大家指正補充。