線程安全

線程安全

多個線程試圖同時訪問同一個數據時,數據不會遭到破壞緩存

線程同步構造

構造模式分別有用戶模式和內核模式兩種,其中用戶模式構造使用了特殊的CPU指令協調線程(協調是在硬件中發生的事情),因此其構造速度要顯著快於內核模式構造,同時用戶模式中阻塞的線程池線程永遠不會被認爲阻塞,因此線程池不會建立新線程替換阻塞線程。在用戶模式中運行的線程可能被系統搶佔,但線程會以最快的速度再次調度,因此想要獲取某一資源又暫時沒法取得時,線程會用戶模式中一直運行,這並非一個良好的現象。而內核模式的構造是由Windows操做系統自身提供的,要求在應用程序的線程中調用在操做系統內核中實現的函數,將線程從用戶模式切換爲內核模式會形成巨大的性能損失。可是也有一個優勢:一個線程使用內核模式構造獲取一個由其它線程正在訪問的資源時,Windows會阻塞線程,使之再也不浪費CPU時間,等到資源可用時會恢復線程,容許它訪問資源。安全

用戶模式構造
  • 易失構造:在包含一個簡單數據類型的變量上執行原子性的讀或寫操做
  • 互鎖構造:在包含一個簡單數據類型的變量上執行原子性的讀和寫操做
原子性

指事務的不可分割性,意味着一個變量的值的讀取都是一次性的,如如下代碼多線程

class SomeType
{
    public static int x;
}

SomeType.x = 0x01234567;

變量x會一次性從0x00000000變成0x01234567,另外一個線程不可能看到一個處於中間值的狀態,如0x01234000,這即是原子性。函數

易失構造

編寫好的代碼須要被編譯器編譯成IL代碼,再通過JIT編譯器轉換成本地CPU指令才能被計算機執行。而在這些轉換過程當中,編譯器、JIT編譯器、CPU自己可能都會對原先編寫好的代碼進行優化。以下面這段代碼通過編譯後將會消失性能

private static void SomeMethod()
{
    //常量表達式在編譯時計算爲0
    int value = 100 - (50 * 2);
    //value爲0循環永不執行
    for (int i = 0; i < value; i++)
    {
        //永遠執行不到,不須要編譯循環中的代碼
        Console.WriteLine(i);
    }
}

上述代碼中,編譯器發現value爲0,循環永遠不會執行,沒有必要編譯循環中的代碼,所以這個方法編譯後會被優化掉。若是有一個方法中調用了SomeMethod方法,在對這個方法進行JIT編譯的時候,JIT編譯器會嘗試內聯SomeMethod方法的代碼,因爲沒有代碼,因此JIT編譯器會刪除調用SomeMethod方法的代碼。優化

編譯器、JIT編譯器和CPU對代碼進行優化的時候,從單線程的角度看,代碼會作咱們但願它作的事情,而從多線程來看,代碼的意圖不必定會獲得保留,如下的代碼進行了演示:this

class SomeType
{
    private int m_Flag = 0;
    private int m_Value = 0;

    public void Thread1()
    {
        this.m_Value = 10;
        this.m_Flag = 1;
    }

    public void Thread2()
    {
        //可能會輸出0,與預期不一致
        if(this.m_Flag == 1)
            Console.WriteLine("value = {0}", this.m_Value);
    }
}

static void Main()
{
    ThreadPool.QueueUserWorkItem((o) =>
    {
        someType.Thread1();
    });
    ThreadPool.QueueUserWorkItem((o) =>
    {
        someType.Thread2();
    });
}

上述代碼的問題在於假定Thread1方法中的代碼按照順序執行,編譯Thread2方法中的代碼時,編譯器必須生成代碼將m_Flag和m_Value 從RAM讀入CPU寄存器。RAM可能先傳遞m_Value的值(此時爲0),而後Thread1可能執行,將Thread1改成10,m_Flag改成1。可是Thread2的CPU寄存器沒有看到m_Value的值已經被另外一個線程修改成10,出現輸出結果爲0的狀況。除此以外Thread1方法中的兩行代碼在CUP/編譯器在解釋代碼時可能會出現反轉,畢竟這樣作也不會改變代碼的意圖,一樣可能出如今Thread2中m_Value輸出0的狀況。spa

修改代碼以修復問題,修改後的代碼以下:
class SomeType
{
    private int m_Flag = 0;
    private int m_Value = 0;

    public void Thread1()
    {
        this.m_Value = 10;
        Thread.VolatileWrite(ref this.m_Flag, 1);
    }

    public void Thread2()
    {
        if (Thread.VolatileRead(ref this.m_Flag) == 1)
            Console.WriteLine("value = {0}", this.m_Value);
    }
}

修改後的代碼能夠看到分別使用了VolatileWrite和VolatileRead來讀寫數據,Thread1方法調用VolatileWrite能夠確保前面的全部數據都寫入完成纔會將1寫入m_Flag;Thread2方法調用VolatileRead能夠確保必須先讀取m_Flag的值才能讀取m_Value的值。操作系統

VolatileWrite和VolatileRead
  • VolatileWrite:強迫address中的值在調用時寫入,除此以外還必須按照順序,即全部發生在VolatileWrite以前的加載和存儲操做必須先於調用VolatileWrite方法完成
  • VolatileRead:強迫address中的值在調用時讀取,除此以外還必須按照順序,即全部發生在VolatileRead以後的加載和存儲操做必須晚於調用VolatileRead方法完成
volatile關鍵字
class SomeType
{
    private volatile int m_Flag = 0;
    private int m_Value = 0;

    public void Thread1()
    {
        this.m_Value = 10;
        this.m_Flag = 1;
    }

    public void Thread2()
    {
        if (this.m_Flag == 1)
            Console.WriteLine("value = {0}", this.m_Value);
    }
}

使用volatile關鍵字能夠達到和調用VolatileWrite和VolatileRead相同的效果,除此以外volatile關鍵字告訴C#和JIT編譯器不將字段緩存到CPU寄存器中,確保字段的全部讀寫都在RAM中進行。pwa

調用VolatileWrite方法或VolatileRead方法、使用volatile關鍵字將會禁用C#編譯器、JIT編譯器和CPU自己所執行的一些代碼優化,若是使用不當反而會損害性能。而且C#不支持以傳引用的方式將volatile修飾的字段傳遞給方法。

自旋鎖
struct SpinLock
{
    private int m_ResourceInUse;

    public void Enter()
    {
        //將資源設置爲正在使用,並返回m_ResourceInUse的原始值
        while (Interlocked.Exchange(ref this.m_ResourceInUse, 1) != 0) { }
    }

    public void Leave()
    {
        //釋放資源
        Thread.VolatileWrite(ref this.m_ResourceInUse, 0);
    }
}

private static SpinLock s_SpinLock = new SpinLock();
private static void DoSomething()
{
    s_SpinLock.Enter();
    //一次只有一個線程才能進入這裏執行代碼
    s_SpinLock.Leave();
}

如今若是兩個線程同時調用Enter,Interlocked.Exchange會確保其中一個線程將m_ResourceInUse從0變到1,並返回m_ResourceInUse的原始值0,而後線程從Enter返回,繼續執行後面的代碼。另外一個線程會將m_ResourceInUse從1變到1,並返回原始值1,發現不是將m_ResourceInUse從0變成1的,因此會一直調用Interlocked.Exchange開始自旋,直到第一個線程調用Leave。第一個線程調用Leave後,會將m_ResourceInUse從新變成0,這時正在自旋的線程調用Interlocked.Exchange可以將m_ResourceInUse從0變成1,因而從Enter返回繼續執行後續的代碼。

自旋鎖的缺點在於處於自旋的線程沒法作其它的工做,浪費CPU時間,建議只將自旋鎖用於保護執行得很是快的代碼塊。

內核構造構造
內核模式構造的缺點

因爲須要Windows操做系統的自身協做以及內核對象上調用的每一個方法都會形成調用線程從託管代碼轉換成本地用戶代碼,再轉換爲本地內核模式代碼,這些轉換須要大量的CPU時間,若是常常執行可能會對應用程序的性能形成負面影響。

內核模式構造的優勢
  • 在資源競爭時,Windows會阻塞輸掉的線程,讓它不佔用CPU從而浪費處理器資源
  • 在內核模式構造上阻塞的線程能夠指定超時值,若是指定時間內訪問不到但願獲得的資源,線程能夠解除阻塞執行其它任務
  • 一個線程能夠一直阻塞,直到一個集合中的全部內核模式的構造皆可以使用或者一個集合中的任何內核模式的構造可用
經過內核構造實現一個單實例應用程序
static void Main()
{
    bool createdNew;
    //建立一個具備指定名稱的內核對象
    using (new Semaphore(0, 1, "MyObject", out createdNew))
    {
        if (createdNew)
        {
            //線程建立了內核對象,因此確定沒有這個應用程序的其它實例正在運行
        }
        else
        {
            //線程打開了一個現有的內核對象,說明實例正在被使用,當即退出
        }
    }
}
代碼解析

假設進程的兩個實例同時啓動。每一個進程都有本身的線程,兩個線程都嘗試建立具備相同字符串名稱「MyObject」的一個Semaphore。Windows內核確保只有一個線程建立具備指定名稱的內核對象。建立對象的線程會將它的createdNew設置爲true。

第二個線程,Windows發現具備指定名稱的內核對象已經存在了,所以不容許第二個線程建立另外一個同名的內核對象,可是卻能夠訪問和第一個進程的線程所訪問的同樣的內核對象。不一樣進程的線程即是這樣經過一個內核對象互相通訊的。在上述代碼中第二個線程發現createdNew變量爲false,因此知道這個進程的另外一個實例正在運行,因此進程的第二個實例當即退出。

Event構造

事件是由內核維護的Boolean變量,若是事件爲false,在事件上等待的線程就阻塞,反之解除阻塞。事件分爲自動重置事件和手動重置事件,當自動重置事件爲true時,只喚醒一個阻塞的線程,由於在解除第一個線程的阻塞後,內核將事件重置回false。當手動重置事件爲true時,會解除正在等待的全部線程的阻塞,由於內核不將事件自動重置爲false,代碼必須將事件手動重置回false。

使用自動同步事件建立線程同步鎖
class WaitLock : IDisposable
{ 
    private AutoResetEvent m_Resources = new AutoResetEvent(true);

    public void Enter()
    {
        //在內核中阻塞,等待資源可用而後返回
        this.m_Resources.WaitOne();
    }

    public void Leave()
    {
        //釋放資源
        this.m_Resources.Set();
    }

    public void Dispose()
    {
        this.m_Resources.Dispose();
    }
}
SpinLock與WaitLock性能對比
static void Method() { }

static void Main()
{
    var x = 0;
    var iteration = 10000000;

    //x遞增1000萬須要花費時間
    Stopwatch sw = Stopwatch.StartNew();
    for (int i = 0; i < iteration; i++)
        x++;
    Console.WriteLine("x遞增1000萬次花費時間: {0}", sw.ElapsedMilliseconds);

    //x遞增1000萬次加上調用一個空方法須要花費的時間
    sw.Restart();
    for (int i = 0; i < iteration; i++)
    {
        Method();
        x++;
    }
    Console.WriteLine("x遞增1000萬次加上調用一個空方法須要花費的時間: {0}", sw.ElapsedMilliseconds);

    //x遞增1000萬次加上一個無競爭的SpinLock須要花費的時間
    SpinLock spinLock = new SpinLock();
    sw.Restart();
    for (int i = 0; i < iteration; i++)
    {
        spinLock.Enter();
        x++;
        spinLock.Leave();
    }
    Console.WriteLine("x遞增1000萬次加上一個無競爭的SpinLock須要花費的時間: {0}", sw.ElapsedMilliseconds);

    //x遞增1000萬次加上一個無競爭的WaitLock須要花費的時間
    using (var waitLock = new WaitLock())
    {
        sw.Restart();
        for (int i = 0; i < iteration; i++)
        {
            waitLock.Enter();
            x++;
            waitLock.Leave();
        }
        Console.WriteLine("x遞增1000萬次加上一個無競爭的WaitLock須要花費的時間: {0}", sw.ElapsedMilliseconds);
    }

    Console.ReadKey();
}
運行結果

image.png

能夠看出SpinLock和WaitLock的行爲徹底相同,可是兩個鎖的性能徹底不一樣。鎖上面沒有競爭的時候WaitLock比SpinLock慢得多,由於上面說到的WaitLock的Enter和Leave方法的每一次調用都強迫調用線程從託管代碼轉換成內核代碼。但在存在競爭的時候,輸掉的線程會被內核阻塞,不會形成自旋,這是好的地方。

經過例子能夠看出內核構造速度慢得可怕,因此須要進行線程同步的時候儘可能使用用戶模式的構造。

Semaphore構造

信號量(Semaphore)是由內核維護的Int32變量,信號量爲0時,在信號量上等待的線程會阻塞。信號量大於0時,就會解除阻塞。在一個信號量上等待的一個線程解除阻塞時,內核自動從信號量的計數中減1。當前信號量計數不能超過信號量關聯的最大計數值。

Event構造與Semaphore構造對比
  • 自動重置事件:多個線程在一個自動重置事件上等待時,設置事件只致使一個線程被解除阻塞
  • 手動重置事件:多個線程在一個手動重置事件上等待時,設置事件會致使全部線程被解除阻塞
  • Semaphore構造:多個線程在一個信號量上等待時,釋放信號量致使致使releaseCount(釋放信號量個數)個線程被解除阻塞(releaseCount是傳給Semaphore的Release方法的實參)

一個自動重置事件在行爲上和最大計數爲1的信號量很是類似,二者的區別就在,能夠在一個自動重置事件上連續屢次調用Set,同時仍然只有一個線程被解除阻塞。而在一個信號量上連續屢次調用Release,會使它內部的計數一直遞增,這可能形成解除大量線程的阻塞。而當計數超過最大計數時,Release會拋出SemaphoreFullException。

示例代碼
class SemaphoreLock : IDisposable
{
    private Semaphore m_Resources;

    public SemaphoreLock(int coumaximumConcurThreads)
    {
        this.m_Resources = new Semaphore(coumaximumConcurThreads, coumaximumConcurThreads);
    }

    public void Enter()
    {
        //在內核中阻塞,等待資源可用而後返回
        this.m_Resources.WaitOne();
    }

    public void Leave()
    {
        //釋放資源
        this.m_Resources.Release();
    }

    public void Dispose()
    {
        this.m_Resources.Close();
    }
}
Mutex(互斥鎖)構造

互斥鎖的邏輯
首先Mutex對象會查詢調用線程的int ID,記錄是哪個線程得到了鎖。一個線程調用ReleaseMutex時,Mutex確保調用線程就是獲取Mutex的那個線程。若是不是,Mutex對象的狀態就不會改變,同時ReleaseMutex也會拋出異常ApplicationException。

其次若是擁有Mutex的線程終止,那麼Mutex上等待的一些線程會由於拋出一個AbandonedMutexException異常而被喚醒,一般該異常也會成爲未處理異常。

Mutex對象還維護着一個遞歸計數,它指明擁有該Mutex的線程擁有了它多少次。若是一個線程當前擁有一個Mutex,而後該線程再次在Mutex上等待,遞歸計數將遞增,且不會阻塞線程,容許這個線程繼續執行。線程調用ReleaseMutex時,遞歸計數遞減。只有在遞歸計數變成0時,另外一個線程才能獲取該Mutex。

Mutex的缺點

須要更多的內存容納額外的線程ID和遞歸計數信息,Mutex代碼還得維護這些信息,這些都會讓鎖變得更慢。

遞歸鎖
class SomeType : IDisposable
{
    private readonly Mutex m_Lock = new Mutex();

    public void M1()
    {
        this.m_Lock.WaitOne();
        //do something...
        M2(); //遞歸獲取鎖
        this.m_Lock.ReleaseMutex();
    }

    public void M2()
    {
        this.m_Lock.WaitOne();
        //do something...
        this.m_Lock.ReleaseMutex();
    }

    public void Dispose()
    {
        this.m_Lock.Dispose();
    }
}

SomeType對象調用M1獲取一個Mutex,而後調用M2,因爲Mutex對象支持遞歸,因此線程會獲取兩次鎖,而後釋放兩次,以後另外一個線程才能擁有它。

內核構造可用時回調方法

讓一個線程不肯定地等待一個內核對象進入可用狀態,這對線程的內存資源來講是一種浪費,所以線程池提供了一種方式,在一個內核對象變得可用時回調一個方法。

示例代碼
class RegisterdWaitHandleClass
{
    public static void Main()
    {
        //構造自動重置事件
        AutoResetEvent autoResetEvent = new AutoResetEvent(false);

        //告訴線程池在AutoResetEvent上等待
        RegisteredWaitHandle rwh = ThreadPool.RegisterWaitForSingleObject(
            autoResetEvent, //在此事件上等待
            EventOperation, //回調EventOperation方法
            null, //向EventOperation傳遞null
            5000, //等5s事件變爲True
            false //每次事件變爲True時都調用EventOperation
        );

        var operation = (char)0;
        while(operation != 'Q')
        {
            operation = char.ToUpper(Console.ReadKey(true).KeyChar);
            if (operation == 'S')
                autoResetEvent.Set();
        }

        //取消註冊
        rwh.Unregister(null);
    }

    //任什麼時候候事件爲True,或者自從上一次回調超過5s,就調用這個方法
    private static void EventOperation(object state, bool timedOut)
    {
        Console.WriteLine(timedOut ? "超時" : "事件爲True");
    }
}
運行結果(每隔5s輸出超時,鍵盤按下S輸出事件爲True)

image.png

相關文章
相關標籤/搜索