C# 線程併發鎖

本文目錄:html

  • 線程的簡單使用
  • 併發和異步的區別
  • 併發控制 - 鎖
  • 線程的信號機制
  • 線程池中的線程
  • 案例:支持併發的異步日誌組件

線程的簡單使用

常見的併發和異步大可能是基於線程來實現的,因此本文先講線程的簡單使用方法。安全

使用線程,咱們須要引用System.Threading命名空間。建立一個線程最簡單的方法就是在 new 一個 Thread,並傳遞一個ThreadStart委託(無參數)或ParameterizedThreadStart委託(帶參數),以下:併發

複製代碼
class Program {
    static void Main(string[] args) {

        // 使用無參數委託ThreadStart
        Thread t = new Thread(Go);
        t.Start();

        // 使用帶參數委託ParameterizedThreadStart
        Thread t2 = new Thread(GoWithParam);
        t2.Start("Message from main.");

        t2.Join();// 等待線程t2完成。

        Console.WriteLine("Thread t2 has ended!");
        Console.ReadKey();
    }

    static void Go() {
        Console.WriteLine("Go!");
    }

    static void GoWithParam(object msg) {
        Console.WriteLine("Go With Param! Message: " + msg);
        Thread.Sleep(1000);// 模擬耗時操做
    }
}
複製代碼

運行結果:框架

線程的用法,咱們只須要了解這麼多。下面咱們再來經過一段代碼來說講併發和異步。異步

併發和異步的區別

關於併發和異步,咱們先來寫一段代碼,模擬多個線程同時寫1000條日誌:函數

複製代碼
class Program {
    static void Main(string[] args) {

        Thread t1 = new Thread(Working);
        t1.Name = "Thread1";
        Thread t2 = new Thread(Working);
        t2.Name = "Thread2";
        Thread t3 = new Thread(Working);
        t3.Name = "Thread3";

        // 依次啓動3個線程。
        t1.Start();
        t2.Start();
        t3.Start();

        Console.ReadKey();
    }

    // 每一個線程都同時在工做
    static void Working() {
        // 模擬1000次寫日誌操做
        for (int i = 0; i < 1000; i++) {
            //  異步寫文件
            Logger.Write(Thread.CurrentThread.Name + " writes a log: " + i + ", on " + DateTime.Now.ToString() + ".\n");
        }// 作一些其它的事件
        for (int i = 0; i < 1000; i++) { }
    }
}
複製代碼

代碼很簡單,相信你們都能看得懂。Logger 你們能夠把它看作是一個寫日誌的組件,先不關心它的具體實現,只要知道它是一個提供了寫日誌功能的組件就行。性能

那麼,這段代碼跟併發和異步有什麼關係呢?測試

咱們先用一張圖來描述這段代碼:字體

觀察上圖,3個線程同時調用Logger寫日誌,對於Logger來講,3個線程同時交給了它任務,這種狀況就是併發。對於其中一個線程來講,它在工做過程當中,在某個時間請求Logger幫它寫日誌,同時又繼續在本身的其它工做,這種狀況就是異步spa

(經讀者反饋,爲不「誤導」讀者(儘管我我的不以爲是誤導。以前個人定義和解釋不全 面,沒有從操做系統和CPU層次去區分這兩個概念。個人文章不喜歡搬教科書,只是想用通俗易讀的白話讓你們理解),爲了知識的專業性和嚴謹,現已把我理解 的對併發和異步的定義刪除,感謝園友們的熱心討論)。

 

接下來,咱們繼續講幾個頗有用的有關線程和併發的知識 - 鎖、信號機制和線程池。

併發控制 - 鎖

CLR 會爲每一個線程分配本身的內存堆空間,以使他們的本地變量保持分離互不干擾。

線程之間也能夠共享通用的數據,好比同一對象的某個屬性或全局靜態變量。但線程間共享數據是存在安全問題的。舉個例子,下面的主線程和新線程共享了變量done,done用來標識某件事已經作過了(告訴其它線程不要再重複作了):

複製代碼
class Program {
    static bool done;
    static void Main(string[] args) {

        new Thread(Go).Start(); // 在新的線程上調用Go
        Go(); // 在主線程上調用Go

        Console.ReadKey();
    }

    static void Go() {
        if (!done) {
            Thread.Sleep(500); // 模擬耗時操做
            Console.WriteLine("Done"); 
            done = true;
        }
    }
}
複製代碼

輸出結果:

輸出了兩個「Done」,事件被作了兩次。因爲沒有控制好併發,這就出現了線程的安全問題,沒法保證數據的狀態。

要解決這個問題,就須要用到鎖(Lock,也叫排它鎖或互斥鎖)。使用lock語句,能夠保證共享數據只能同時被一個線程訪問。lock的數據對象 要求是不能null的引用類型的對象,因此lock的對象需保證不能爲空。爲此須要建立一個不爲空的對象來使用鎖,修改一下上面的代碼以下:

複製代碼
class Program {

    static bool done;
    static object locker = new object(); // !!

    static void Main(string[] args) {

        new Thread(Go).Start(); // 在新的線程上調用Go
        Go(); // 在主線程上調用Go

        Console.ReadKey();
    }

    static void Go() {
        lock (locker) {
            if (!done) {
                Thread.Sleep(500); // Doing something.
                Console.WriteLine("Done");
                done = true;
            }
        }
    }
}
複製代碼

再看結果:

使用鎖,咱們解決了問題。但使用鎖也會有另一個線程安全問題,那就是「死鎖」,死鎖的機率很小,但也要避免。保證「上鎖」這個操做在一個線程上執行是避免死鎖的方法之一,這種方法在下文案例中會用到。

這裏咱們就不去深刻研究「死鎖」了,感興趣的朋友能夠去查詢相關資料。

線程的信號機制

有時候你須要一個線程在接收到某個信號時,纔開始執行,不然處於等待狀態,這是一種基於信號的事件機制。.NET框架提供一個 ManualResetEvent類來處理這類事件,它的 WaiOne 實例方法可以使當前線程一直處於等待狀態,直到接收到某個信號。它的Set方法用於打開發送信號。下面是一個信號機制的使用示例:

複製代碼
static void Main(string[] args) {

    var signal = new ManualResetEvent(false);

    new Thread(() => {
        Console.WriteLine("Waiting for signal...");
        signal.WaitOne();
        signal.Dispose();
        Console.WriteLine("Got signal!");
    }).Start();
    Thread.Sleep(2000);

    signal.Set();// 打開「信號」

    Console.ReadKey();
}
複製代碼

運行結果:

當執行Set方法後,信號保持打開狀態,可經過Reset方法將其關閉,若再也不須要,經過Dispose將其釋放。若是預期的等待時間很短,能夠用 ManualResetEventSlim代替ManualResetEvent,前者在等待時間較短時性能更好。信號機制很是有用,後面的日誌案例會用 到它。

線程池中的線程

線程池中的線程是由CLR來管理的。在下面兩種條件下,線程池能起到最好的效用:

  • 任務運行的時候比較短(<250ms),這樣CLR能夠充分調配現有的空閒線程來處理該任務;
  • 大量時間處於等待(或阻塞)的任務不去支配線程池的線程。

要使用線程中的線程,主要有下面兩種方式:

// 方式1:Task.Run,.NET Framework 4.5 纔有
Task.Run (() => Console.WriteLine ("Hello from the thread pool"));

// 方式2:ThreadPool.QueueUserWorkItem
ThreadPool.QueueUserWorkItem (t => Console.WriteLine ("Hello from the thread pool"));

線程池使得線程能夠充分有效地被使用,減小了任務啓動的延遲。可是不是全部的狀況都適合使用線程池中的線程,好比下面要講的日誌案例 - 異步寫文件。

這裏講線程池,是爲了讓你們大體瞭解何時用線程池中的線程,何時不用。即,耗時長或有阻塞狀況的不用線程池中的線程。

建立不走線程池中的線程,能夠直接經過new Thread來建立,也能夠經過下面的代碼來建立:

Task task = Task.Factory.StartNew (() => ...,TaskCreationOptions.LongRunning);// 注意必須帶TaskCreationOptions.LongRunning參數

這裏用到了Task,你們不用關心它,後續博文會詳細講。

關於線程的知識不少,這裏再也不深刻了,由於這些已經足夠讓咱們應付Web開發了。

案例:支持併發的異步日誌組件

上文的「併發和異步的區別」的代碼中咱們用到了一個Logger類,如今咱們就來作一個這樣的Logger。

基於上面的知識,咱們能夠實現應用程序的併發寫日誌日誌功能。在應用程序中,寫日誌是常見的功能,簡單分析一下該功能的需求:

  1. 在後臺異步執行,和其它線程互不影響。
    根據上文線程池的兩個最優使用條件,由寫日誌線程會長時間處於阻塞(或運行等待)狀態,因此它不適合使用線程池。即不能使用Task.Run,而最好使用new Thread。

  2. 支持併發,即多個任務(分佈在不一樣線程上)可同時調用寫日誌功能,但需保證線程安全。
    支持併發,必然要用到鎖,但要徹底保證線程安全,那就要想辦法避免「死鎖」。只要咱們把「上鎖」的操做始終由同一個線程來作便可避免「死鎖」問題,但這樣的話,併發請求的任務只能放在隊列中由該線程依次執行(由於是後臺執行,無需即時響應用戶,因此能夠這麼作)。

  3. 單個實例,單個線程。
    任何地方調用寫日誌功能都調用的是同一個Logger實例(顯然不能每次寫日誌都新建一個實例),即需使用單例模式。無論有多少任務調用寫日誌功能,都必須始終使用同一個線程來處理這些寫日誌操做,以保證不佔用過多的線程資源和避免新建線程帶來的延遲。

運用上面的知識,咱們來寫一個這樣的類。簡單理一下思路:

  1. 須要一個用來存放寫日誌任務的隊列。
  2. 須要有一個信號機制來標識是否有新的任務要執行。
  3. 當有新的寫日誌任務時,將該任務加入到隊列中,併發出信號。
  4. 用一個方法來處理隊列中的任務,當接收新任務信號時,就依次調用隊列中的任務。

開發一個功能前須要有個簡單的思路,保證內心面有底。具體開發的時候會發現問題,而後再去補充擴展和完善等。剛開始很難想得太周全,先有個簡單的思路,而後代碼寫起來!

下面是這樣一個Logger類初步實現:

複製代碼
public class Logger {

    // 用於存放寫日誌任務的隊列
    private Queue<Action> _queue;

    // 用於寫日誌的線程
    private Thread _loggingThread;

    // 用於通知是否有新日誌要寫的「信號器」
    private ManualResetEvent _hasNew;

    // 構造函數,初始化。
    private Logger() {
        _queue = new Queue<Action>();
        _hasNew = new ManualResetEvent(false);

        _loggingThread = new Thread(Process);
        _loggingThread.IsBackground = true;
        _loggingThread.Start();
    }

    // 使用單例模式,保持一個Logger對象
    private static readonly Logger _logger = new Logger();
    private static Logger GetInstance() {
        /* 不安全代碼
        lock (locker) {
            if (_logger == null) {
                _logger = new Logger();
            }
        }*/
        return _logger;
    }

    // 處理隊列中的任務
    private void Process() {
        while (true) {
            // 等待接收信號,阻塞線程。
            _hasNew.WaitOne();

            // 接收到信號後,重置「信號器」,信號關閉。
            _hasNew.Reset(); 

            // 因爲隊列中的任務可能在極速地增長,這裏等待是爲了一次能處理更多的任務,減小對隊列的頻繁「進出」操做。
            Thread.Sleep(100);

            // 開始執行隊列中的任務。
            // 因爲執行過程當中還可能會有新的任務,因此不能直接對原來的 _queue 進行操做,
            // 先將_queue中的任務複製一份後將其清空,而後對這份拷貝進行操做。

            Queue<Action> queueCopy;
            lock (_queue) {
                queueCopy = new Queue<Action>(_queue);
                _queue.Clear();
            }

            foreach (var action in queueCopy) {
                action();
            }
        }
    }

    private void WriteLog(string content) {
        lock (_queue) { // todo: 這裏存在線程安全問題,可能會發生阻塞。
            // 將任務加到隊列
            _queue.Enqueue(() => File.AppendAllText("log.txt", content));
        }

        // 打開「信號」
        _hasNew.Set();
    }

    // 公開一個Write方法供外部調用
    public static void Write(string content) {
        // WriteLog 方法只是向隊列中添加任務,執行時間極短,因此使用Task.Run。
        Task.Run(() => GetInstance().WriteLog(content));
    }
}
複製代碼

類寫好了,用上文「併發和異步的區別」中的代碼測試一下這個Logger類,在個人電腦上運行的一次結果:

 共3000條日誌,結果沒有問題。

上面的Logger類註釋寫得很詳細,我就再也不解析了。

經過這個示例,目的是讓你們掌握線程和併發在開發中的基本應用和要注意的問題。

遺憾的是這個Logger類並不完美,並且存在線程安全問題(代碼中用紅色字體標出),雖然實際環境機率很小。可能上面代碼屢次運行都很難看到有異常發生(我屢次運行未發生異常),但同時再添加幾個線程可能就會有問題了。

那麼,如何解決這個線程安全問題呢?

 

引用地址:http://www.cnblogs.com/kesimin/p/5085460.html

相關文章
相關標籤/搜索