1. 程式人生 > >C#多線程編程(5)--線程安全1

C#多線程編程(5)--線程安全1

拆分 將不 編譯器 pre orm amd frame tile 有效

當你需要2個線程讀寫同一個數據時,就需要數據同步。線程同步的辦法有:(1)原子操作;(2)鎖。原子操作能夠保證該操作在CPU內核中不會被“拆分”,鎖能夠保證只有一個線程訪問該數據,其他線程在嘗試獲得有鎖的數據時,會被拒絕,直到當前獲得數據的線程將鎖釋放,其他線程才能夠獲得數據。

  • 為什麽要線程同步?

  我們先看一個需要數據同步的例子,

static void Main(string[] args){
    bool flag = false;
    var t1 = new Thread(() => { if (flag) Console.WriteLine("Flag"); });
    var t2 = 
new Thread(() => { flag = true; });
  t1.Start();
  t2.Start(); Console.ReadLine(); }

  上述例子中,t2線程將flag置為true,有可能發生:當t2打算執行flag = true時,t1執行了if(flag)語句,這造成了不可知的情況。此時就需要在t2執行時,若t1想要獲取flag的值,要等到flag=true執行完成後,再執行,這就是所謂的“線程同步”,一個線程要等待另一個線程執行到某段代碼後,再執行。線程同步能保證程序的執行符合“預想”--若t2沒有執行,則flag為false,t2若已執行,則flag=true。線程同步是為了防止t2正在執行flag=true的時候,t1開始執行,此時flag應該是true,因為t2已經開始執行了,但是實際上flag=false,因為t2的flag=true沒有執行完。解決的辦法就是當t2執行flag=true時,將任何嘗試讀取flag的線程都阻塞,直到flag=true執行結束後,其他線程再執行。類似下面的代碼。

var m_lock = GetSomeLock();
pulick void Go(){
  var t1 = new Thread(()=>Go1());
  var t2 = new Thread(()=>Go2());
  t1.Star();
  t2.Start();
}
public void Go1(){ m_lock.lock(); if (flag) //dosomething;   Console.WriteLine(flag);
   m_lock.Unlock(); }
public void Go2(){ m_lock.lock
(); flag = true; m_lock.Unlock(); }

在flag=true和if(flag)外面添加m_lock.lock()和m_lock.Unlock()就是為了保證線程同步。但是這樣的同步帶來的問題就是性能的下降,還有可能造成死鎖。摘要中說過,線程同步有2個手段,上面介紹了鎖,還有原子操作我沒有介紹。在介紹原子操作之前,我介紹下關鍵字volatile。

  • 關鍵字volatile

  該關鍵字能夠作用在變量前,其意義是對該變量的讀寫操作都是原子操作,這種特性被稱作“易變性”。

  編譯器在編譯過程中,會根據代碼的具體情況進行適當“優化”,例如:

public void Go(){
    int value = 100 * 1 - 50 * 2;
    for (int i = 0; i < value; i++)
        Console.WriteLine(i);
}

編譯器在看到有地方調用該方法,會跳過其中的語句,因為這段語句毫無意義,這當然是好的,編譯器彌補了我們的錯誤。但是有的時候這種優化會造成我們不想要的效果。

private static bool s_stopWorker = false;
static void Main(string[] args){
    Console.WriteLine("Main:letting worker run for 5s");
    var t = new Thread(Worker);
    t.Start();
    Thread.Sleep(5000);
    s_stopWorker = true;
    Console.WriteLine("Main: waiting for worker to stop.");
    t.Join();
}
private static void Worker(object o){
    int x = 0;
    while (s_stopWorker) x++;
    Console.WriteLine("Worker: stopped when x = {0}", x);
}

該段代碼中,主線程阻塞5秒,然後s_stopWorker=true,本意是要中斷t線程,讓其顯示數到的數後返回。但實際上編譯器在看到while(s_stopWorker)時,又看到s_stopWorker在Worker方法中沒有任何改變,因此該方法中對s_stopWorker的判斷只會在最開始判斷一次,若s_stopWorker=true,則進入死循環,若是false,則顯示Worker stopped when x = 0之後該線程就返回了。若想實際看到運行效果,需要將改短代碼放在.cs文件中,利用命令行編譯該段代碼。利用命令行編譯代碼要添加環境變量,變量的路徑是C:\Windows\Microsoft.NET\Framework\v4.0.30319。然後就可以在命令行中編譯該文件,註意要打開/platform:x86,其意義在《CLR via C#》29章中有解釋,x86編譯器比x64編譯器更成熟,優化也更大膽。在命令行中輸入 csc /platform:x86 你的cs文件的路徑,之後在輸入Program.exe(假設你的文件名字叫Program.cs),之後你會看到程序一直卡死在Main: waiting for worker to stop.之後一直沒有出現數到的數字。

  下面來討論如何解決這個問題。在System.Threading.Volatile中提供了2個靜態方法,

public static class Volatile{
    public static bool Read(ref bool location);
    public static bool Write(ref bool location,  bool value);
}

這兩個方法能夠阻止編譯器對讀和寫進行優化,修改後的代碼如下:

private static bool s_stopWorker = false;
static void Main(string[] args){
    Console.WriteLine("Main:letting worker run for 5s");
    var t = new Thread(Worker);
    t.Start();
    Thread.Sleep(5000);
    //防止優化
    Volatile.Write(ref s_stopWorker, true);
    Console.WriteLine("Main: waiting for worker to stop.");
    t.Join();
    Console.Read();
}
private static void Worker(object o){
    int x = 0;
    //防止優化
    while (Volatile.Read(ref s_stopWorker)) x++;
    Console.WriteLine("Worker: stopped when x = {0}", x);
}

在s_stopWorker的讀寫處,都改用了Volatile類中的Read和Write方法。再次利用命令行編譯該代碼,會發現運行正常。很多時候我們搞不清到底該什麽時候調用Volatile中的讀寫,什麽時候該正常讀寫,於是C#提供了volatile關鍵字,該關鍵字能夠保證對該變量的讀寫都是原子的,並且能夠阻止對該方法進行優化。由於為了提高CPU的運行效率,現在的程序都是亂序執行,但是volatile能夠保證該關鍵字之前的代碼會在該關鍵字的變量讀寫時已經執行完成,該關鍵字修飾的變量以後的代碼一定會在之後執行,而不會因亂序優化而在之前執行。我們去掉Volatile.Write和Read,然後將s_stopWorker前加上volatile關鍵字,運行上述代碼,會發現結果正確。

  volatile關鍵字能夠保證變量的線程安全,但是其缺點也是很明顯的,將變量的每次讀寫都變成易變的讀寫,是對性能的浪費,因為這種情況極少發生。

volatile int m = 5;
m=m+m;//volatile會阻止優化

通常,將一個變量增大一倍,只需要將該變量左移一位,就可以,但是volatile會阻止該優化。CPU會將m讀入一個寄存器,然後讀入另一個寄存器,然後在執行add,再將結果寫入m。如果m不是int類型,而是更大的類型,則造成更大的浪費,如果在循環中,那真是杯具。

另外C#不支持將有volatile修飾的變量以引用的形式傳入方法,如Int32.TryParse("123", m);會得到一個警告,對volatile字段的引用將不被視為volatile。

  • 變量捕獲(閉包)

  第一段代碼中,flag變量被lamda表達式包含。程序並沒有在主線程中執行,而是在t1和t2中執行,該變量已經脫離了它的作用域,為了保證flag變量能夠生效,編譯器負責延長flag的生命周期,以保證在t1和t2線程執行時,該變量能夠被訪問,這就是變量捕獲,也叫“閉包”,可以利用IL反編譯器查看上述代碼的IL指令來驗證。

技術分享圖片

  上圖可以看到為了保證flag的生命周期編譯器將2個lamda表達式(b_0和b_1)和flag用一個類包了起來,這樣這3個的生命周期就一致了。這很好,因為不需要我們去關心在t1和t2獲取flag值時,flag是否有效,編譯器已經幫我們全做了。

  本文講了線程安全的必要性以及線程安全的手段之一:volatile(易變性),還簡單介紹了變量捕獲。線程安全的內容還沒講完,預計分3-4篇博客來講線程安全。歡迎小夥伴在評論區與我交流。

C#多線程編程(5)--線程安全1