并發編程 - 線程同步(二)
經過前面對線程同步初步了解,相信大家對線程同步已經有了整體概念,今天我們就來一起看看線程同步的具體方案。

01、ThreadStatic
嚴格意義上來說這兩個并不是實現線程同步方案,而是解決多線程資源安全問題,而我們研究線程同步最終也是為了解決多線程資源安全問題,因此就先說下這兩個用法。
ThreadStatic特性可以實現線程本地存儲,使得每個線程都有一個獨立的字段副本。從而避免不同線程間共享資源。
使用ThreadStatic時需要注意以下幾點:
1、ThreadStatic僅能作用于靜態字段;。
2、ThreadStatic字段不應使用內聯初始化。
3、每個線程都會有獨立的_threadLocalVariable實例,當線程退出時,相關的線程本地存儲會被清除。
4、由于 ThreadStatic 是線程局部存儲,它并不是跨線程共享數據的解決方案。
使用起來也很簡單,我們來著重說說上面注意點的第二點,雖然語法上可以寫出內聯初始化,但是這樣會導致一個問題:僅有訪問其的首個線程上可以獲取其初始化變量值,而其他所有線程都只能獲取到變量類型的默認值。比如下面這段代碼:
[ThreadStatic]
public static int _threadStaticValue = 1;
public static void ThreadStaticRun()
{
var thread1 = new Thread(ThreadStatic1);
var thread2 = new Thread(ThreadStatic2);
var thread3 = new Thread(ThreadStatic3);
thread1.Start();
thread2.Start();
thread3.Start();
}
static void ThreadStatic1()
{
Console.WriteLine($"線程 Id : {Environment.CurrentManagedThreadId},變量值:{_threadStaticValue}");
}
static void ThreadStatic2()
{
Console.WriteLine($"線程 Id : {Environment.CurrentManagedThreadId},變量值:{_threadStaticValue}");
}
static void ThreadStatic3()
{
Console.WriteLine($"線程 Id : {Environment.CurrentManagedThreadId},變量值:{_threadStaticValue}");
}
也就是上面代碼只有一個線程能打印出1,其他線程都只能打印出0,我們看看實際打印結果:

因此注意項第二點提出ThreadStatic字段不應使用內聯初始化,因為這樣并不能保證每個線程都能獲取到相同的初始值。
也因為ThreadStatic有這個缺陷所以引出了ThreadLocal。
02、ThreadLocal
可以說ThreadLocal功能和ThreadStatic完全一樣,并且還解決了其缺陷,因此更推薦使用ThreadLocal。
可以使用 System.Threading.ThreadLocal
private static ThreadLocal<int> _threadLocalValue = new ThreadLocal<int>(() => 1);
public static void ThreadLocalRun()
{
var thread1 = new Thread(ThreadLocal1);
var thread2 = new Thread(ThreadLocal2);
var thread3 = new Thread(ThreadLocal3);
thread1.Start();
thread2.Start();
thread3.Start();
}
static void ThreadLocal1()
{
Console.WriteLine($"線程 Id : {Environment.CurrentManagedThreadId},變量值:{_threadLocalValue.Value}");
}
static void ThreadLocal2()
{
Console.WriteLine($"線程 Id : {Environment.CurrentManagedThreadId},變量值:{_threadLocalValue.Value}");
}
static void ThreadLocal3()
{
Console.WriteLine($"線程 Id : {Environment.CurrentManagedThreadId},變量值:{_threadLocalValue.Value}");
}
執行結果如下:

并且可以通過ThreadLocal
03、volatile關鍵字
首先volatile關鍵字同樣不是一個完整的線程同步機制,其主要作用是防止緩存和防止編譯器優化。
在C#語言開發中,由于編譯器優化、JIT 編譯、硬件緩存以及內存重排序等行為,很容易使得程序出現并發錯誤,尤其在多線程環境下這些情況會更為明顯。雖然這些優化是在不影響程序邏輯的情況下進行的,但是因為重新排序對內存的讀取和寫入,進而可能導致數據競爭和同步問題。
volatile關鍵字就是為了告訴編譯器和運行時:該字段的值可能會被多個線程同時修改,因此每次訪問該字段時,都應該直接從主內存中讀取,而不是使用寄存器或緩存中的值。這樣可以防止 CPU 的優化行為導致某些線程讀取到過時的值。
我們一起看看如下代碼:
//控制線程的標志
private static bool _flag = false;
//計數器
private static int _counter = 0;
public static void VolatileRun()
{
var thread1 = new Thread(Volatile1);
var thread2 = new Thread(Volatile2);
thread1.Start();
thread2.Start();
thread1.Join();
thread2.Join();
//Console.WriteLine($"計數器最后的值: {counter}");
}
static void Volatile1()
{
//注意:以下兩行代碼可能按相反的順序執行
//設置計數器
_counter = 88;
//線程1:設置標志位,并且增加計數器
_flag = true;
}
static void Volatile2()
{
//注意:_counter可能優先于_flag讀取
//線程2:等待標志位變為 true,然后讀取計數器
//等待 _flag 被設置為 true
while (!_flag) ;
//打印計數器值
Console.WriteLine($"當前計數器的值: {_counter}");
}
上面的代碼很難在復現下面要說的問題,因此下面僅以此代碼作為示例講解。
上面代碼的問題在于,經過編譯器優化和內存重排序后, Volatile1線程中的兩行賦值代碼可能被顛倒了順序,如果從單線程角度來說這個順序顛倒無關緊要,最總結果都是_counter被賦值了88,_flag被賦值了true。但是在多線程環境下,對于Volatile2線程來說就完全不一樣了,此時卻先讀取到_flag為true,然后打印_counter為0,和預期完全不一樣。
我們再從另一個角度來說,假定Volatile1線程中的代碼安裝編碼順序執行了,沒有被優化。在編譯Volatile2線程中的代碼時,編譯器必須生成代碼將_flag和_counter從RAM(主存)中讀入CPU寄存器,此時RAM可能先讀入_counter的值,為0。與此同時Volatile1線程可能執行,將_counter修改為88,想_flag修改為true。此時Volatile2線程的CPU寄存器還沒有看到_counter已被Volatile1線程修改為88,然后繼續將_flag的值從RAM中讀入CPU寄存器,但是由于此時_flag已經被Volatile1線程修改為true,所以最后Volatile2線程同樣會打印_counter為0。
開發時很容易忽略這些細微之處,并且由于開發調試環境不會進行代碼優化,就導致問題往往到了生產環境下才顯現出來。
為了解決這個問題我們就可以使用volatile關鍵字了。對于被聲明為volatile的字段將從編譯器優化、JIT 編譯、硬件緩存以及內存重排序等優化中排除,使用也很簡單,可以如下使用:
private static volatile bool _flag = false;
另外volatile關鍵字不能引用于double,long,數組等類型,可以使用Volatile.Read和Volatile.Write靜態方法來完成。
同時volatile關鍵字雖然可以解決許多并發問題,但是因為其不是原子操作,因此它并不能算是一個完整的線程同步機制,因此在多線程環境下還是需要借助一些其他同步機制來保證線程安全。
因此volatile最大的應用場景就是在需要保證多個線程訪問同一個共享變量時,大家都可以立刻看到最新的值,尤其是不涉及復雜操作如遞增遞減等。
注:測試方法代碼以及示例源碼都已經上傳至代碼庫,有興趣的可以看看。https://gitee.com/hugogoos/Planner

浙公網安備 33010602011771號