[C#.NET 拾遺補漏]11:最基礎的線程知識

線程的知識太多,知識點有深有淺,往深的研究會涉及操作系統、CUP、內存,往淺了說就是一些語法。沒有一定的知識積累,很難把線程的知識寫得全面,當然我也沒有這個能力。所以想到一個點寫一個點,盡量總結一些有用的知識點。線程是個大話題,這個系列可能會有好幾遍關於線程的,先從基礎的開始,熱熱身。

一些基礎概念

線程(Thread)是操作系統能夠進行運算調度的最小單位。它是進程中的實際運作單位,一個進程中可以並發多個線程,每條線程並行執行不同的任務。嚴格意義上來說,同一時間可以並發運行的線程數取決於 CPU 的核數。

根據線程運行模式,可以把線程分為前台線程、後台線程和守護(Deamon)線程:

  • 前台線程:主程序必須等待線程執行完畢後才可退出程序。C# 中的 Thread 默認為前台線程,也可以設置為後台線程。

  • 後台線程:主程序執行完畢立即跟隨退出,不管線程是否執行完畢。C# 的 ThreadPool 管理的線程默認為後台線程。

  • 守護線程:守護線程擁有自動結束自己生命周期的特點,它通常被用來執行一些後台任務。

每次開啟一個新的線程都要消耗一定的內存,即使線程什麼也不做,也會至少消耗 1M 左右的內存。

多線程並行(Parallelism)和並發(Concurrency)的區別:

  • 並行:同一時刻有多條指令在多個處理器上同時執行,無論從宏觀還是微觀上都是同時發生的。
  • 並發:是指在同一時間段內,宏觀上看多個指令看起來是同時執行,微觀上看是多個指令進程在快速的切換執行,同一時刻可能只有一條指令被執行。

PS:以上概念來源 Google 的多個搜索結果,稍加整理。

Thread、ThreadPool 和 Task

對 C# 開發者來說,不可不理解清楚 Thread、ThreadPool 和 Task 這三個概念。這也是面試頻率很高的話題,在 StackOverflow 可以找到有很多不錯的回答,我總結整理了一下。

Thread

Thread 是一個實際的操作系統級別的線程(OS 線程),有自己的棧和內核資源。Thread 允許最高程度的控制,你可以 Abort、Suspend 或 Resume 一個線程,你還可以監聽它的狀態,設置它的堆棧大小和 Culture 等屬性。Thread 的開銷成本很高,你的每一個線程都會為它的堆棧消耗相對較多的內存,並且在線程之間的處理器上下文切換時會增加額外的 CPU 開銷。

ThreadPool

ThreadPool(線程池)是一堆線程的包裝器,由 CLR 維護。你對線程池中的線程沒有任何控制權,你甚至無法知道線程池什麼時候開始執行你提交的任務,你只能控制線程池的大小。簡單來說,線程池調用線程的機制是,它首先調用已創建的空閑線程來執行你的任務,如果當前沒有空閑線程,可能會創建新線程,也可能會等待。

使用 ThreadPool 可以避免創建太多線程的開銷。但是,如果你向 ThreadPool 提交了太多長時間運行的任務,它可能會被填滿,這時你提交的後面的任務可能最終會等待前面的長時間運行的任務執行完成。此外,線程池沒有提供任何方法來檢測一個工作任務何時完成(不像 Thread.Join()),也沒有方法來獲取結果。因此,ThreadPool 最好用於調用者不需要結果的短時操作。

Task

Task 是 TPL(Task Parallel Library)提供一個類,它在 Thread 和 TheadPool 之間提供了兩全其美的解決方案。和 ThreadPool 一樣,Task 並不創建自己的OS 線程。相反,Task 是由 TaskScheduler 調度器執行的,默認的調度器只是在 ThreadPool 上運行。

與 ThreadPool 不同的是,Task 還允許你知道它完成的時間,並獲取返回一個結果。你可以在現有的 Task 上調用 ContinueWith(),使它在任務完成後運行更多的代碼(如果它已經完成,就會立即運行回調)。

你也可以通過調用 Wait() 來同步等待一個任務的完成(或者,通過獲取它的 Result 屬性)。與 Thread.Join() 一樣,這將阻塞調用線程,直到任務完成。通常不建議同步等待任務執行完成,它使調用線程無法進行任何其他工作。如果當前線程要等待其它線程任務執行完成,建議使用 async/await 異步等待,這樣當前線程可以空閑出來去處理其它任務,比如在 await Task.Delay() 時,並不佔用線程資源。

由於任務仍然在 ThreadPool 上運行,因此不應該將其用於長時任務的執行,因為它們會填滿線程池並阻塞新的工作任務。相反,Task 提供了一個 LongRunning 選項,它將告訴 TaskScheduler 啟用一個新的線程,而不是在 ThreadPool 上運行。

所有較新的上層多線程 API,包括 Parallel.ForEach()、PLINQ、async/await 等,都是建立在 Task 上的。

Thread 和 Task 簡單示例

下面通過一個簡單示例演示 Thread 和 Task 的使用,注意他們是如何創建、傳參、執行和等待執行完成的。

static void Main(string[] args)
{
    // 創建兩個新的 Thread
    var thread1 = new Thread(new ThreadStart(() => PerformAction("Thread", 1)));
    var thread2 = new Thread(new ThreadStart(() => PerformAction("Thread", 2)));

    // 開始執行線程任務
    thread1.Start();
    thread2.Start();

    // 等待兩個線程執行完成
    thread1.Join();
    thread1.Join();

    Console.WriteLine("Theads done!");

    Console.WriteLine("===我是分隔線===");

    // 創建兩個新的 Task
    var task1 = Task.Run(() => PerformAction("Task", 1));
    var task2 = Task.Run(() => PerformAction("Task", 2));

    // 執行並等待兩個 Task 執行完成
    Task.WaitAll(new[] { task1, task2 });

    Console.WriteLine("Tasks done!");

    Console.ReadKey();
}

static void PerformAction(string threadOrTask, int id)
{
    var rnd = new Random(id);
    for (int i = 0; i < 5; i++)
    {
        Console.WriteLine($"{threadOrTask}: {id}: {i}", id, i);
        Thread.Sleep(rnd.Next(0, 1000));
    }
}

運行效果:

注意到,相比之下 Task 比 Thread 好用得多,加上前文 Task 和 Thread 的對比,對我們編碼的指導意義是:大多數情況我們應該使用 Task,而不要直接使用 Thread,除非你明確知道你需要一個獨立的線程來執行一個長耗時的任務。

小結

本篇內容很基礎,整理了 C# 線程編程有關的重要概念,簡單演示了 Thread 和 Task 的使用。Thread 和 Task 是高頻面試話題,尤其是 Thread 和 Task 的區別,Thread 更底層,Task 更抽象,回答好這類面試題的關鍵點在 ThreadPool。