[C#.NET 拾遺補漏]11:最基礎的執行緒知識

精緻碼農發表於2020-11-10

執行緒的知識太多,知識點有深有淺,往深的研究會涉及作業系統、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。

相關文章