前陣子試寫SignalR時,學到.NET 4.0在多工執行上提供了新類別--Task。初試之下,發現用它取代傳統Thread、ThreadPool寫法,能大幅簡化同步邏輯的寫法,頗為便利。整理幾個範例展示Task的使用方式,分享兼備忘。

先從最簡單的開始。test1()用以另一條Thread執行Thread.Sleep()及Console.WriteLine(),效果與ThreadPool.QueueUserWorkItem()相當。

排版顯示純文字
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Threading;
using System.Diagnostics;
 
namespace TaskLab
{
    class Program
    {
        static void Main(string[] args)
        {
            test1();
            Console.Read();
        }
 
        static void test1() 
        {
            //Task可以代替TheadPool.QueueUserWorkItem使用
              Task.Factory.StartNew(() =>
            {
                Thread.Sleep(1000);
                Console.WriteLine("Done!");
            });
            Console.WriteLine("Async Run...");
        }
    }
}

StartNew()完會立刻執行下一行,故會先看到Aync Run,1秒後印出Done。

Async Run...
Done!

同時啟動數個作業多工並行,但要等待各作業完成再繼續下一步是常見的應用情境,傳統上可透過WaitHandle、AutoResetEvent、ManualResetEvent等機制實現;Task的寫法相對簡單,建立多個Task物件,再當成Task.WaitAny()或Task.WaitAll()的參數就搞定囉!

排版顯示純文字
        static void test2()
        {
            var task1 = Task.Factory.StartNew(() =>
            {
                Thread.Sleep(3000);
                Console.WriteLine("Done!(3s)");
            });
            var task2 = Task.Factory.StartNew(() =>
            {
                Thread.Sleep(5000);
                Console.WriteLine("Done!(5s)");
            });
            //等待任一作業完成後繼續
            Task.WaitAny(task1, task2);
            Console.WriteLine("WaitAny Passed");
            //等待兩項作業都完成才會繼續執行
            Task.WaitAll(task1, task2);
            Console.WriteLine("WaitAll Passed");
        }

task1耗時3秒、task2耗時5秒,所以3秒後WaitAny()執行完成、5秒後WaitAll()執行完畢。

Done!(3s)
WaitAny Passed
Done!(5s)
WaitAll Passed

如果要等待多工作業傳回結果,透過StartNew<T>()指定傳回型別建立作業,隨後以Task.Result取值,不用額外寫Code就能確保多工作業執行完成後才讀取結果繼續運算。

排版顯示純文字
        static void test3()
        {
            var task = Task.Factory.StartNew<string>(() =>
            {
                Thread.Sleep(2000);
                return "Done!";
            });
            //使用馬錶計時
            Stopwatch sw = new Stopwatch();
            sw.Start();
            //讀task.Result時,會等到作業完畢傳回值後才繼續
              Console.WriteLine("{0}", task.Result);
            sw.Stop();
            //要取得task.Result耗時約2秒
    Console.WriteLine("Duration: {0:N0}ms", sw.ElapsedMilliseconds);
        }

實際執行,要花兩秒才能跑完Console.WriteLine("{0}", task.Result),其長度就是Task執行並傳回結果的時間。

Done!
Duration: 2,046ms

如果要安排多工作業完成後接連執行另一段程式,可使用ContinueWith():

排版顯示純文字
        static void test4()
        {
            Task.Factory.StartNew(() =>
            {
                Thread.Sleep(1000);
                Console.WriteLine("Done!");
            }).ContinueWith(task =>
            {
                //ContinueWith會等待前項工作完成才執行
                   Console.WriteLine("In ContinueWith");
            });
            Console.WriteLine("Async Run...");
        }

如預期,ContinueWith()裡的程式會在Task完成後才被執行。

Async Run...
Done!
In ContinueWith

.ContinueWith()傳回值仍是Task物件,所以我們可以跟jQuery一樣玩接接樂,在ContinueWith()後方再接上另一個ContinueWith(),各段邏輯便會依順序執行。

排版顯示純文字
        static void test5()
        {
            //ContinueWith()可以串接
              Task.Factory.StartNew(() =>
            {
                Thread.Sleep(2000);
                Console.WriteLine("{0:mm:ss}-Done", DateTime.Now);
            })
            .ContinueWith(task =>
            {
                Console.WriteLine("{0:mm:ss}-ContinueWith 1", DateTime.Now);
                Thread.Sleep(2000);
            })
            .ContinueWith(task =>
            {
                Console.WriteLine("{0:mm:ss}-ContinueWith 2", DateTime.Now);
            });
            Console.WriteLine("{0:mm:ss}-Async Run...", DateTime.Now);
        }

Task耗時兩秒,第一個ContinueWith()耗時2秒,最後一個ContinueWith()接續在4秒後執行。

59:13-Async Run...
59:15-Done
59:15-ContinueWith 1
59:17-ContinueWith 2

最後一個例子比較複雜。ContinueWith()中的Action<Task>都會有一個輸入參數,藉以得知前一Task的執行狀態,有IsCompleted, IsCanceled, IsFaulted幾個屬性可用。

要取消執行,得借助CancellationTokenSource及其所屬CancellationToken類別,做法是在Task中持續呼叫CancellationToken.ThrowIfCancellationRequested(),一旦外部呼叫CancellationTokenSource.Cancel(),便會觸發OperationCanceledException,Task有機制偵測此種例外狀況,將結束作業執行後續的ContinueWith(),並指定Task.IsCanceled為True以為識別;而當Task程式發生Exception,也會結束作業觸發ContinueWith(),此時則Task.IsFaulted為True,ContinueWith()中可透過Task.Exception.InnerExceptions取得錯誤細節。

以下程式同時可測試Task正常、取消及錯誤三種情境,使用者透過輸入1,2或3來決定要測試哪一種。在Task外先宣告一個CancellationTokenSource類別,將其中的Token屬性當成StartNew()的第二項參數,而Task中則保留最初的五秒可以取消,方法是每隔一秒呼叫一次CancellationToken.ThrowIfCancellationRequested(),當程式外部呼叫CancellationTokenSource.Cancel(),Task就會結束。5秒後若未取消,再依使用者決定的測試情境return結果或是抛出Exception。ContinueWith()則會檢查IsCanceled, IsFaulted等旗標,並輸出結果。

排版顯示純文字
static void test6()
{
    CancellationTokenSource cts = new CancellationTokenSource();
    CancellationToken cancelToken = cts.Token;
    Console.Write("Test Option 1, 2 or 3 (1-Complete / 2-Cancel / 3-Fault) : ");
    var key = Console.ReadKey(); Console.WriteLine();
    Task.Factory.StartNew<string>(() =>
    {
        //保留5秒偵測是否要Cancel
        for (var i = 0; i < 5; i++)
        {
            Thread.Sleep(1000);
            //如cancelToken.IsCancellationRequested
            //抛出OperationCanceledException
            cancelToken.ThrowIfCancellationRequested();
        }
        switch (key.Key)
        {
            case ConsoleKey.D1: //選1時
                return "OK";
            case ConsoleKey.D3: //選2時
                throw new ApplicationException("MyException");
        } 
        return "Unknown Input";
    }, cancelToken).ContinueWith(task =>
    {
        Console.WriteLine("IsCompleted: {0} IsCanceled: {1} IsFaulted: {2}",
            task.IsCompleted, task.IsCanceled, task.IsFaulted);
        if (task.IsCanceled)
        {
            Console.WriteLine("Canceled!");
        }
        else if (task.IsFaulted)
        {
            Console.WriteLine("Faulted!");
            foreach (Exception e in task.Exception.Flattern().InnerExceptions)
            {
                Console.WriteLine("Error: {0}", e.Message);
            }
        }
        else if (task.IsCompleted)
        {
            Console.WriteLine("Completed! Result={0}", task.Result);
        }
    });
    Console.WriteLine("Async Run...");
    //如果要測Cancel,2秒後觸發CancellationTokenSource.Cancel
    if (key.Key == ConsoleKey.D2)
    {
        Thread.Sleep(2000);
        cts.Cancel();
    }
}

以下是三種測試情境的結果。

正常執行:

Test Option 1, 2 or 3 (1-Complete / 2-Cancel / 3-Fault) : 1
Async Run...
IsCompleted: True IsCanceled: False IsFaulted: False
Completed! Result=OK

取消: (IsCanceled為True,但留意IsCompleted也算True)

Test Option 1, 2 or 3 (1-Complete / 2-Cancel / 3-Fault) : 2
Async Run...
IsCompleted: True IsCanceled: True IsFaulted: False
Canceled!

錯誤: (IsFaulted為True,IsCompleted也是True)

Test Option 1, 2 or 3 (1-Complete / 2-Cancel / 3-Fault) : 3
Async Run...
IsCompleted: True IsCanceled: False IsFaulted: True
Faulted!
Error: MyException

【小結】

說穿了,Task能做的事,過去使用Thread/ThreadPool配合Event、WaitHandle一樣能辦到,但使用Task能以較簡潔的語法完成相同工作,使用.NET 4.0開發多工作業程式應可多加利用。同時,Task也是.NET 4.5 async await的基礎概念之一,值得大家花點時間熟悉,有益無害。


Comments

# by bill chung

TPL,讚

# by Edward

黑大您好,跟您請教一個問題, 我使用interop.adodb 與 interop.cdo來載入.eml的檔案, 以讀取.eml的相關資訊, 不知道是否自己程式的寫法有問題, 在執行loadfromfile的函數時, 會出現「存放空間不足,無法完成此操作」0x8007000E。 共有五萬多個.eml檔案, 但是讀到第兩千~七千的某筆時(發生時間不一定), 就會出現以上錯誤。 我在每讀一個檔案後,會把宣告的物件設為null, 要讀下一個檔案時,會重新new一個物件。 以上,煩請指導,謝謝。

# by Jeffrey

to Edward, 直覺是CDO的問題(or Bug?)導致類似Memory Leak的狀況。記憶體管理問題出錯,錯誤發生的筆數不固定倒也合理,要追查可能不太容易! 或許可以考量換用其他元件或程式庫處理EML。

# by Edward

黑大您好, 中斷點是停在adodb.stream.loasfromfile, 還沒執行到cdo的部份, 當然也可能是前幾次cdo造成的吧。 還有一個奇怪的問題, 相同的程式,在windows 2003 r2上執行會有這個bug, 但在win xp卻沒有, 這又可能是什麼原因呢? 以上,煩請指導,謝謝。

# by Edward

黑大您好: 不知道您是否有建議的元件或函式庫可以處理.eml的檔案, 能讀取mail相關資訊(附件、to、from……) 謝謝。

# by Edward

黑大您好神,我把cdo相關程式註解後測試,就不會有錯誤了。 但是為什麼xp不會出錯呢?令人費解。

# by Edward

黑大您好: 加上GC.Collect後,問題已解決, 只是不懂為什麼在win 2003才會有這個記憶體問題, 而win xp不會, 不知道黑大您認為可能的原因是什麼。

# by Jeffrey

to Edward, 我的猜測是兩台機器上的CDO版本有所差異導致不同結果,安裝Outlook或Exchange Server時也可能去更動CDO版本。除此之外,Windows或其他環境參數也可能會有影響,實務上同樣的OS,一台OK一台不行的狀況也不算罕見,要繼續追下去還得耗一番功夫,動用到進階工具,很難以目前蒐集的資訊論定問題根源。

# by Edward

了解了,謝謝黑大的指導。

# by Edward

黑大您好: 請教您一個問題, 我有一個批次檔,內容是重覆呼叫某個執行檔,但參數不同,例如: a.exe 1 2 3 a.exe 4 5 6 a.exe 7 8 9 想請教的是,執行這類的批次檔,是否可以使用此討論的task, 若是可以,可以請教大概的寫法嗎??

# by Edward

黑大您好: 您說的方法我知道, 我想請教的事, 如果我把要執行的多個a.exe放在一個文字檔, 是否可以利用 task 一次讀取這個文字檔中多個a.exe來執行。 謝謝

# by Jeffrey

to Edward, 不是很懂「利用 task 一次讀取這個文字檔中多個a.exe來執行」。意思是「寫一個批次檔run.bat,裡面有兩行 a.exe 1 a.exe 2 .NET程式將其當成文字檔讀入,用Task同時啟動兩個a.exe Process,一個傳入參數1,一個傳入參數2」嗎?

# by Edward

黑大,不好意思,表達能力不好,就是您理解的那樣。 目前我是有一個批次檔,裡面有上百個指令如下 a.exe 1 a.exe 2 a.exe 3 . . 批次檔是一行執行完才執行下一行, 因此想說是不是能利用.net 的 task 同時讀取多行一併執行

# by Jeffrey

to Edward, 理論上是可行的。但必須衡量某些情境下執行的Process過多,反而會造成效能下降。一般來說,若a.exe為單緒且執行需耗用大量CPU,則最適同時執行的數量等於CPU核數;若a.exe執行涉及大量本機硬碟讀寫,同時執行個數過多會造成磁碟讀寫頭切換頻繁,反而效能下降。若a.exe要等待網路回應,則是最適合增加同時執行個數的情境(但要小心不要壓垮遠端的伺服器)。 因此,這類情境我會先決定要同時執行的Process個數(例如:8個),先將上百個指令轉作工作資料放進Queue裡,開啟8個Task,每個Task由Queue中取出一個工作,執行完畢後再取下一個工作,可想像成8個作業員自己去籃子裡拿出材料加工,當Queue中沒有工作後,Task再自行結束。 實作細節推薦參考安德魯的系列文章:http://columns.chicken-house.net/columns/post/2007/12/14/ThreadPool-e5afa6e4bd9c-1-e59fbae69cace6a682e5bfb5.aspx

# by Edward

黑大,謝謝您,我的是屬於要等待網路回應的那類, 我會好好參考安德魯的文章

# by 烏龍茶

Hi~ 我之前是寫C 想學習C# 但我看不懂這寫法@@ () =>{} Task.Factory.StartNew(() => { Thread.Sleep(1000); Console.WriteLine("Done!"); }); 是否能解釋一下呢? 謝謝

# by Saint

黑大新年快樂,安德魯的連結失效,可否提供關鍵字方便查詢,剛好過完年也需要修改一隻批次程式,情景和前位網友相同,祝你豬事順利

Post a comment