先前的 C# 筆記曾提到過幾次,這次(第 4 集)算是比較正式的來介紹 .NET 執行緒集區(thread pool)的基礎觀念和用法。同場加映 execution context 概念講解與範例。

溫馨提醒:本文新版本的內容已經整理自電子書《.NET 本事-非同步程式設計》。點我查看出版訊息


.NET CLR 實作了集區(pool)的概念,其基本運作方式為:當執行緒完成任務之後,並不立即摧毀執行緒,而是將完成任務的執行緒丟進集區裡面待命;等到有其他工作需要非同步執行,便可直接從集區取出執行緒,並分派工作給它執行。如此一來,不但省去了頻繁建立和釋放執行緒的時間與資源損耗,也因為執行緒集區是由系統來管理,系統便能夠依整體執行環境的狀況來調整相關參數(例如每條執行緒要分配多少執行時間);開發人員也能夠更專注於實現應用程式的邏輯,而不是埋首與底層細節奮戰。

執行緒集區的運作方式

Jeffrey Richter 在《CLR via C# 第四版》中說道:「每一個 CLR 有一個執行緒集區,且 CLR 中所有的 App Domain 都會共享這個執行緒集區。」意思是,.NET 4.0 (亦即 CLR 4)允許一個應用程式同時載入 CLR 2 和 CLR 4 的組件,稱為並肩(side-by-side)執行。若某應用程式同時使用了多個 CLR,那麼其中每一個 CLR 都各自擁有一個執行緒集區,而且 CLR 中的所有 App Domain 會共享這個執行緒集區。

當一個 CLR 初始化的時候,它的執行緒集區是空的,裡面沒有任何執行緒。執行緒集區本身有一個工作請求佇列,每當應用程式需要非同步操作時,便可呼叫一些方法來將工作請求送進這個佇列。CLR 會從佇列中逐一取出請求(先到先服務),並查看集區裡面有沒有閒置的執行緒。剛開始當然沒有,於是 CLR 會建立一條新的執行緒來負責執行任務。等到任務執行完畢,CLR 並不摧毀那個執行緒,而是將它放回執行緒集區,等待下一次任務指派。如此一來,就如前面所說,執行緒能夠重複使用,從而減少了反覆建立和摧毀執行緒所產生的效能損耗。

另一方面,放回執行緒集區中待命的執行緒在閒置一段時間之後若未再接到新任務,就會自動摧毀,以便將記憶體資源釋放出來。除了摧毀閒置的執行緒,CLR 還有一套演算法(註1),會根據系統目前擁有的運算資源(CPU 核心的數量)和應用程式的負載等狀況來決定是否要建立更多執行緒來處理應用程式提出的工作請求。
註1:CLR 採用的是所謂的爬山演算法(hill-climbing algorithm)。
現在我們知道,相較於先前介紹過的建立專屬執行緒的作法(亦即 new 一個 Thread 物件),透過執行緒集區來取得執行緒會來得更有效率。

使用執行緒集區

欲從集區中取用執行緒來執行特定工作--這裡指的是運算類型的工作(compute-bound tasks),可以用 ThreadPool 類別的靜態方法: QueueUserWorkItem。此方法有兩種版本:

static Boolean QueueUserWorkItem(WaitCallback callBack);
static Boolean QueueUserWorkItem(WaitCallback callBack, Object state);

呼叫 QueueUserWorkItem 時,它會將你指定的「工作項目」(work item)加入執行緒集區的工作請求佇列,然後立即返回呼叫端。所謂的工作項目,也就是輸入參數 callBack 所代表的回呼函式,此函式的宣告(回傳值與參數列)必須符合 System.Threading.WaitCallback 委派型別,如下:

delegate void WaitCallback(Object state);

當 CLR 從執行緒集區中取出一條執行緒來執行佇列中的任務時,就會呼叫那個預先指定的回呼函式。如需提供額外參數給回呼函式,在呼叫 QueueUserWorkItem 時可透過 state 參數來傳遞。

底下是個簡單範例,示範如何利用執行緒集區的執行緒來進行非同步呼叫。

using System;
using System.Threading;
 
class Program
{
    static void Main(string[] args)
    {
        ThreadPool.QueueUserWorkItem(DownloadFile, "xyz.iso");
        Console.ReadLine();
    }

    static void DownloadFile(object fileName)
    {
        Console.WriteLine("Downloading file {0}", fileName);
    }
}

正如稍早提過的,執行緒集區本身有個工作請求佇列,而上列程式碼當中的 QueueUserWorkItem 敘述就是用來將我們指定的 DownloadFile 工作送進執行緒集區的工作佇列。
複習一下,若不使用執行緒集區,而要自行建立新的專屬執行緒,則 ThreadPool.QueueUserWorkItem 那行程式碼可以改成:

new Thread(DownloadFile).Start();

這種用法已在前面的筆記中提過,這裡只是簡短複習一下,方便對照兩種寫法,順便提出一個小細節:透過 Thread 類別來建立專屬執行緒時,傳入的委派型別是 ParameterizedThreadStart(因為回呼函式有帶額外參數;若無額外參數則是 ThreadStart),它的長相(signature)跟 WaitCallback 委派完全一樣。也就是說,同一個回呼函式,既可用於建立專屬執行緒(new 一個 Thread 物件),亦可用於 ThreadPool.QueueUserWorkItem 方法。
從 .NET Framework 4 開始,我們還可以利用 Task.Run 方法來使用集區中的執行緒。這個部分留待後續進一步說明。

下一個學習課題是工作の取消。在此之前,先岔開來介紹一個觀念:execution context。

執行環境

執行緒在執行任務時會需要記住一些相關資訊,我們用一個專有名詞來統稱這些資訊,叫做執行環境(execution context)。鑒於 context 譯成中文之後讀起來有點彆扭,往後碰到時會直接使用英文。事實上,.NET Framework 裡面就有一個實作此概念的類別,叫做 ExecutionContext

Context,就是周遭的環境資訊,execution context 則是執行環境的資訊。「執行環境」這個詞所代表的含意,視討論的對象而定,範圍放大一點可能指的是 CLR;縮小一點可能指的是特定執行緒的執行環境,即 execution context。

在單一執行緒的應用程式中,可能 A 函式先呼叫 B,然後又呼叫 C,這沒什麼問題--A、B、C 三者都隸屬同一條執行緒,好像甲乙丙三個人在同一條船上一樣,要傳遞的東西都已經放在船艙裡,隨手可得。在這個譬喻中,船身如同執行緒的 execution context,而用來放置環境資訊的船艙,則是 TLS(Thread Local Storage)。

同樣的工作改用多執行緒來處理就比較複雜了。延續剛才的例子,假設 A 是主執行緒,呼叫 B 函式時會建立另一條執行緒來執行 B,而 B 又會建立另一條執行緒來執行 C 函式。現在等於是三條船了,A 船命令 B 船做一件事,B 船又命令 C 船做一件事,問題是 C 船所欲執行的工作會需要用到 B 船和 A 船上面的相關資訊,這可不是從自己的船艙(TLS)裡面就能拿到的環境資訊,怎麼辦呢?

這就要靠環境資訊的層層傳遞了,或者說,環境資訊的流動--從上層流動至下層。幸運的是,CLR 預設會幫我們傳遞環境資訊給下游船隻執行緒,故一般而言,我們很少機會用到 ExecutionContext 與其相關類別。(意思是看到這裡就差不多夠了,底下可安全跳過。)

如果你是那種對效能錙銖必較,連一丁點浪費都不允許的人,下面的內容或許有用。

如同剛才舉的 ABC 例子,當一個執行緒在執行任務時,可能會再調動其他執行緒來協助完成工作──姑且稱他們為「輔助執行緒」(Jeffrey Richter 用語)或「下游執行緒」(我偏好這個詞)。在呼叫下游執行緒的時候,為了確保他們能夠獲得上游執行緒(亦即發號施令的那個執行緒)的相關資訊,CLR 會自動把上游執行緒的 execution context 內容複製一份,疊加至下游執行緒的 execution context,如同把上游船隻的貨物傳遞一份給給下游船隻。此複製程序不僅需要額外花一些時間,而且當調用層次愈多(輔助執行緒亦可調用其他輔助執行緒),位於下游的執行緒的 execution context 內容便累積愈多,這表示需要複製的資訊量也愈大,甚至可能影響執行效能。用行船人的話來說就是:最下游的船由於堆積了所有上游船隻丟過來的貨物,壓力最沉重。

剛才說過,CLR 預設會幫你將環境資訊從上游層層傳遞至下游,但如果你發現下游根本不需要上游的那些環境資訊,此時就可以利用 ExecutionContext 類別的 SuppressFlow 方法來關閉此預設機制,以減輕效能損耗。

傳遞環境資訊

當上游要傳遞資訊給下游時,可呼叫 CallContext.LogicalSetData 來儲存欲傳遞的資訊。如果上游想要關閉此資訊流動機制,則可呼叫 ExecutionContext.SuppressFlow 方法來暫時抑制流動,之後如需恢復流動,則使用 ExecutionContext.RestoreFlow 方法。參考以下範例:

class Program
{
    static void Main(string[] args)
    {
        // 把欲傳遞至下游的資訊儲存至 CallContext 中。
        CallContext.LogicalSetData("Time", DateTime.Now);

        ThreadPool.QueueUserWorkItem(
            state =>
            {
                // 透過 CallContext 取得上游傳遞過來的環境資訊。
                Console.WriteLine("Time: {0}", CallContext.LogicalGetData("Time"));
            });

        // 抑制主執行緒的 execution context 流動機制。
        ExecutionContext.SuppressFlow();

        ThreadPool.QueueUserWorkItem(
            _ =>
            {
                Console.WriteLine("Time: {0}", CallContext.LogicalGetData("Time"));
            });

        // 恢復流動機制。
        ExecutionContext.RestoreFlow();

        Console.ReadLine();
    }
}

第 19 行那個底線並非打錯字,那是合法的變數名稱。當我們在寫 lambda 表示式時,如果不會用到傳入的參數,就可以簡單寫個底線。我不太喜歡這種寫法,有時候眼花,搞不好會看到程式碼裡面出現表情符號,例如 =_=>。

其餘程式碼皆已加註解,就不多解釋了。執行結果如下:


雖然這裡的範例是使用 ThreadPool.QueueUserWorkItem,但傳遞環境資訊以及抑制/恢復環境資訊流動機制的技巧同樣適用於 Task 物件。

參考資料