C# 多執行緒筆記之 6,終於進入 Task Parallel Library 了... orz

是啊,經過前面幾篇筆記的緩慢爬梳,從執行緒的基本概念到如何使用執行緒集區(ThreadPool)來建立及起始非同步工作,再談到非同步工作的取消與逾時。除了異常處理(exception handling)之外,非同步操作的基本功大致都點到了吧(沒點到請舉手)。

有了這些基本功,希望接下來的 TPL 可以像倒吃甘蔗,漸入佳境,也漸漸加速(希望啦)。

Task Parallel Library

使用 Thread 類別的 QueueUserWorkItem 方法來進行非同步操作雖然很簡單,可是它缺了兩項基本功能:得知非同步工作何時完成,以及當工作完成時取得其執行結果。倒不是說,使用 QueueUserWorkItem 就完全無法得知非同步工作的完成時機與結果,而是開發人員得額外花一番工夫才能做到。因此,微軟從 .NET Framework 4 開始提供了以工作為基礎的(task-based)概念,並將相關類別放在 System.Threading.Tasks 命名空間裡。這些類別形成了一組 API,統稱為 Task Parallel Library,簡稱 TPL。到了 .NET Framework 4.5,TPL 又有一些擴充和改進,程式寫起來又稍微容易些。

起始非同步工作

TPL 的核心類別是 Task,所以接著就來看看如何使用 Task 類別來處理非同步工作。首先要了解的,當然就是建立和起始非同步工作了。

範例程式:

static void Main(string[] args)
{
    // 寫法 1 - .NET 2 開始提供
    ThreadPool.QueueUserWorkItem(state => MyTask());

    // 寫法 2 - .NET 4 開始提供 Task 類別。
    var t = new Task(MyTask);   // 等同於 new Task(new Action(MyTask));
    t.Start();

    // 寫法 3 - 也可以用靜態方法直接建立並開始執行工作。
    Task.Factory.StartNew(MyTask);

    // 寫法 4 - .NET 4.5 的 Task 類別新增了靜態方法 Run。
    Task.Run(() => MyTask());

    Console.ReadLine();
}

static void MyTask()
{
    Console.WriteLine("工作執行緒 #{0}", Thread.CurrentThread.ManagedThreadId);
}

執行結果:


此範例的重點不在執行結果,而是在示範各種寫法。第一種寫法,也就是先前介紹過的,使用 .NET 2.0 開始就提供的 ThreadPool.QueueUserWorkItem 方法來建立非同步工作。第二種寫法使用 .NET 4 提供的 TPL,以 new Task 的方式來建立 Task 物件──代表一項工作;建立好的工作可以在稍後需要的時候才呼叫 Task 物件的 Start 方法來開始執行工作。這表示我們甚至可以先建立好 Task 物件,然後把它當作參數傳遞給其他函式,並由其他函式在適當時機起始工作。不過,比較常見的情形還是在建立工作之後緊接著執行工作,所以 .NET Framework 另外提供了簡便的方法,讓我們可透過 Task.Factory 屬性來取得 TaskFactory 類別的執行個體,再呼叫它的 StartNew 方法來開始執行工作(寫法 3)。

Task.Factory.StartNew 雖然能夠一次完成建立工作和起始工作的程序,可是它的多載版本多達 16 種,雖然提供了更細緻的控制,但是對於初次使用的人來說,恐怕容易眼花撩亂,不知該用哪個版本。於是 .NET 4.5 為 Task 類別提供了比較陽春、簡便的靜態方法:Run 。此方法僅 8 種版本,功能沒有 Task.Factory.StartNew 那麼多,但相對容易上手。

剛才的範例中,欲執行的非同步工作是不帶任何輸入參數的 MyTask 方法。如需傳入參數,程式寫法其實也差不多,。這裡再提供一個帶參數的版本,方便參考對照。

static void Main(string[] args)
{
    ThreadPool.QueueUserWorkItem(state => MyTask(1));

    var t = new Task(() => MyTask(2));
    t.Start();

    Task.Factory.StartNew(() => MyTask(3));

    Task.Run(() => MyTask(4));
}

static void MyTask(int num)
{
    Console.WriteLine("num = {0}", num);
}

接著要稍微岔開,順便提個小細節,與泛型委派的型別相容有關。(以下細節討論似乎有點囉嗦,考慮刪去)

即便上述範例中的 MyTask 方法需要傳入一個 int 型別的參數,在呼叫 Task.Factory.StartNew 和 Tas.Run 方法時,傳入的委派其實都是不帶任何輸入參數的 Action 委派(.NET 提供了多種 Action 委派的泛型版本,主要用於無回傳值以及零至多個輸入參數的場合;詳情請參閱 MSDN線上文件)。除了傳遞 Action 委派,Task.Factory.StartNew 還提供了一個多載版本可以傳入 Action<Object> 委派,但 Task.Run 方法僅支援 Action 委派,亦即無回傳值、亦無任何輸入參數。以下是 Task.Factory.StartNew 方法的其中兩個多載版本的原型宣告:

public Task StartNew(Action action)
public Task StartNew(Action<Object> action, Object state)

如果要使用 Action<Object> 來傳遞自訂參數,直覺上可能會以為只要把先前範例中的 Task.Factory.StartNew 敘述稍微修改一下就行,例如底下這三種寫法:

Task.Factory.StartNew(MyTask, 2); // 編譯失敗: 找不到符合的多載方法。
Task.Factory.StartNew(new Action<int>(MyTask), 2); // 同上。
Task.Factory.StartNew(new Action<Object>(MyTask), 2); // 編譯失敗:
            // MyTask 沒有提供傳入 'System.Action<Object>' 委派的多載方法。

如註解所說,上述寫法皆無法通過編譯。可是,MyTask 函式確實是接受一個 int 參數啊,為什麼連第二種寫法都編譯失敗呢?

看一下前面列出的兩個 StartNew 多載方法的原型宣告,第二個版本是接受兩個參數,其中第一個參數的型別是  Action<Object> 委派。然而前述範例卻是傳入 Action<int> 委派。這裡產生了型別不相容的問題。

再往源頭追,從 MSDN 線上資源找到 Action<T> 委派的原型宣告:

public delegate void Action<in t>(T obj)

注意泛型參數 T 前面的修飾詞「in」,代表這是個逆變型(contravariant)參數。用白話文來解釋,就是此型別參數 T 只接受 T 本身及其父代型別,而不接受子代型別。因此,先前範例中傳入的 Action<int> 不符規定──int 既不是 Object 也不是 Object 的父代型別──編譯失敗。

有關泛型的共變(covariance)與逆變(contravariance),可參閱另一篇筆記<C# 4.0:Covariance 與 Contravariance 觀念入門>或 MSDN 文件:Covariance and Contravariance in Generics
結論是,如欲傳遞 Action<Object> 委派給 TaskFactory 的 StartNew 方法,目標函式(即範例中的 MyTask 方法)必須傳入一個 Object 型別的參數。正解如下:

static void Main(string[] args)
{
    Task.Factory.StartNew(MyTask, 1); // OK!
    Task.Factory.StartNew(new Action<Object>(MyTask), 2); // OK! 效果同上。
}

static void MyTask(Object state)
{
    int num = (int) state;
    Console.WriteLine("num = {0}", num);
}

取得工作執行結果

稍早提過,TPL 比 ThreadPool 類別好用的一個地方,是它本身就提供了取得非同步工作的執行結果的機制。執行結果當然是得等到工作執行完畢方能獲得,所以本節所要介紹的非同步操作技巧其實包含兩個步驟:先等待工作執行完畢,然後取得執行結果。底下是個簡單範例:
static void Main(string[] args)
{
    string url = "https://www.huanlintalk.com/";
    var task = new Task<int>(GetContentLength, url);    // 建立工作。

    task.Start();   // 起始工作。

    task.Wait();    // 等待工作。

    int length = task.Result;   // 取得結果。
    Console.WriteLine("Content length: {0}", length);
}

static int GetContentLength(object state)
{
    var client = new System.Net.Http.HttpClient();
    var url = state as string;
    return client.GetStringAsync(url).Result.Length;
}

程式說明:
  • GetContentLength 方法的任務是傳回指定網頁的內容長度,網頁的 URL 是由參數 state 指定。
  • 由於 Task.Run 靜態方法所提供的多載版本並未支援傳入參數,故此範例採用建立 Task 物件的方式來執行非同步工作。欲執行之非同步工作 GetContentLength 有回傳值,所以建立 Task 物件時所使用之類別為其泛型版本 Task<TResult>;而回傳的型別是 int,於是將 TResult 換成 int,便得到Task<int>。
  • 建立 Task 物件之後,呼叫 Start 方法來起始非同步工作,然後呼叫 Wait 以等待工作執行完畢,再透過 Task 物件的 Result 屬性取得結果。這裡的 Wait 呼叫亦可省略,因為存取 Task 的 Result 屬性時也會等待工作執行完畢。

執行結果:


需特別說明的是,Task 物件開始執行之後,呼叫 Wait 方法會令目前的執行緒暫停,等到非同步工作執行完畢之後才會繼續往下執行。但如果 Task 物件並未開始執行(未曾呼叫其 Start 方法)就呼叫了 Wait 方法,視情況而定,有可能會執行非同步工作並立即返回,也有可能產生奇怪的狀況。

以這個例子來說,若將 task.Start() 這行程式碼註解掉,程式執行到 task.Wait() 時會陷入無窮等待的鎖死狀態(dead lock)。那麼,究竟什麼條件下能夠令它順利執行,什麼條件下會導致鎖死呢?這得看當時使用的 TaskScheduler 物件(此範例係使用預設的 TaskScheuler 執行個體)。

至於 TaskScheduler 類別,它也是隸屬 System.Threading.Tasks 命名空間,主要是負責將非同步工作排入佇列並配給執行緒等等比較低階的處理。目前先大概知道這東西做啥用的就夠了,將來有空再細究吧(或者直接參考 MSDN 文件有關 TaskScheduler 的說明)。

小結

這篇筆記簡單介紹了 TPL 的一部分基本用法,包括建立與起始非同步工作,以及等待工作執行完成並取得執行結果。另外還有工作取消、異常處理的基本課題,再加上末尾出現的 TaskSchedular,以及父子巢狀工作的處理.....

Happy coding!

參考資料