C#使用Task執行平行任務的原理和詳細範例

C#使用Task執行平行任務的原理和詳細範例

在C#中,使用Task可以很方便地執行平行任務。

最後更新 2023/3/28 下午8:17
沙漠尽头的狼
預計閱讀 13 分鐘
分類
.NET
標籤
.NET C#

本文透過與ChatGPT的連續對話完成,程式碼都經過驗證。

在C#中,使用Task可以很方便地執行平行任務。Task是一個表示非同步操作的類別,它提供了一種簡單、輕量級的方式來建立多執行緒應用程式。

一、Task執行平行任務的原理

使用Task執行平行任務的原理是將任務分成多個小區塊,每個小區塊都可以在不同的執行緒上執行。然後,使用Task.Run方法將這些小區塊作為不同的任務提交給執行緒集區。執行緒集區會自動管理執行緒的建立和銷毀,並根據系統資源的可用情況來自動調整執行緒數量,從而達到最大化利用CPU資源的效果。

二、5個範例展示

範例1

以下是一個簡單的範例,展示如何使用Task來執行平行任務:

void Task1()
{
    // 建立任務陣列
    var tasks = new Task[10];

    for (var i = 0; i < tasks.Length; i++)
    {
        var taskId = i + 1;

        // 使用Task.Run方法提交任務
        tasks[i] = Task.Run(() =>
        {
            Console.WriteLine("任務 {0} 執行於執行緒 {1} 中", taskId, Task.CurrentId);
            // 執行任務邏輯
        });
    }

    // 等待所有任務完成
    Task.WaitAll(tasks);

    Console.WriteLine("所有任務執行完成。");
    Console.ReadKey();
}

在這個範例中,我們建立了一個長度為10的任務陣列,然後使用Task.Run方法將每個任務提交給執行緒集區。在任務執行時,使用Task.CurrentId屬性取得目前任務的ID,並列印出來以便觀察。最後,我們使用Task.WaitAll方法等待所有任務完成並列印出一條完成訊息。

執行結果:

任務 3 執行於執行緒 11 中
任務 4 執行於執行緒 12 中
任務 8 執行於執行緒 16 中
任務 1 執行於執行緒 9 中
任務 2 執行於執行緒 10 中
任務 5 執行於執行緒 13 中
任務 6 執行於執行緒 14 中
任務 7 執行於執行緒 15 中
任務 9 執行於執行緒 17 中
任務 10 執行於執行緒 18 中
所有任務執行完成。

值得注意的是,在實際開發中,需要根據具體情況來評估任務的大小和數量,以確保平行任務的效率和可靠性。

範例2

另一個使用Task的範例是計算費氏數列。我們可以將費氏數列的每一項看成一個任務,然後使用Task.WaitAll方法等待所有任務完成。

void Task2()
{
    static long Fib(int n)
    {
        if (n is 0 or 1)
        {
            return n;
        }
        else
        {
            return Fib(n - 1) + Fib(n - 2);
        }
    }

    const int n = 10; // 計算費氏數列的前n項

    var tasks = new Task<long>[n];

    for (var i = 0; i < n; i++)
    {
        var index = i; // 需要在閉包內使用迴圈變數時需要賦值給另外一個變數

        if (i < 2)
        {
            tasks[i] = Task.FromResult((long)i);
        }
        else
        {
            tasks[i] = Task.Run(() => Fib(index));
        }
    }

    // 等待所有任務完成
    Task.WaitAll(tasks);

    // 列印結果
    for (var i = 0; i < n; i++)
    {
        Console.Write("{0} ", tasks[i].Result);
    }

    Console.ReadKey();
}

在這個範例中,我們使用Task陣列來儲存所有的任務。如果需要計算的是前兩項,則直接使用Task.FromResult建立完成任務,否則使用Task.Run方法建立非同步任務並呼叫Fib方法計算結果。在等待所有任務完成後,我們遍歷Task陣列,並使用Task.Result屬性取得每個任務的結果並列印出來。

執行結果:

0 1 1 2 3 5 8 13 21 34

需要注意的是,在建立非同步任務時,由於迴圈變數在閉包內的值是不確定的,因此需要將其賦值給另外一個變數,並在閉包內使用該變數。否則,所有任務可能會使用同一個迴圈變數的值,導致結果錯誤。

範例3

除了使用Task陣列儲存所有任務,還可以使用Task.Factory.StartNew方法建立平行任務。這個方法與Task.Run方法類似,都可以建立非同步任務並提交給執行緒集區。

void Task3()
{
    long Factorial(int n)
    {
        if (n == 0) return 1;
        return n * Factorial(n - 1);
    }

    const int n = 5; // 計算階乘的數

    var task = Task.Factory.StartNew(() => Factorial(n));

    Console.WriteLine("計算階乘...");

    // 等待任務完成
    task.Wait();

    Console.WriteLine("{0}! = {1}", n, task.Result);
    Console.ReadKey();
}

在這個範例中,我們使用Task.Factory.StartNew方法建立一個計算階乘的非同步任務,並等待任務完成後列印結果。

執行結果:

計算階乘...
5! = 120

需要注意的是,儘管Task.Run和Task.Factory.StartNew方法都可以建立非同步任務,但它們的行為略有不同。特別是,Task.Run方法總是使用TaskScheduler.Default作為任務排程器,而Task.Factory.StartNew方法可以指定任務排程器、任務類型和其他選項。因此,在選擇使用哪種方法時,需要根據具體情況進行評估。

範例4

另一個使用Task的範例是非同步讀取檔案。在這個範例中,我們使用Task.FromResult方法建立一個完成任務,並將檔案內容作為結果傳回。

void Task4()
{
    const string filePath = "test.txt";

    var task = Task.FromResult(File.ReadAllText(filePath)); // 只是方便舉例,更好的程式碼應該是:File.ReadAllTextAsync(filePath); 

    Console.WriteLine("讀取檔案內容...");

    // 等待任務完成
    task.Wait();

    Console.WriteLine("檔案內容: {0}", task.Result);
    Console.ReadKey();
}

在這個範例中,我們使用Task.FromResult方法建立一個完成任務,並透過File.ReadAllText方法讀取檔案內容並將其作為結果傳回。在等待任務完成後,我們可以透過呼叫Task.Result屬性來取得任務的結果。

文中記事本請隨意建立,以下是測試結果:

讀取檔案內容...
檔案內容: Dotnet9,專注ASP.NET Core網站開發、MAUI跨平台應用開發、WPF客戶端開發,同時以 https://Dotnet9.com 網站分享一些技術類文章,歡迎交流學習。

需要注意的是,在實際開發中,如果需要處理大型檔案或需要執行長時間的I/O操作,則應該使用非同步程式碼來避免封鎖UI執行緒。例如,在讀取大型檔案時,我們可以使用非同步程式碼來避免封鎖UI執行緒,從而提高應用程式的效能和回應速度。

範例5

最後一個範例是使用Task和async/await實現非同步任務。在這個範例中,我們將一個耗時的操作封裝為非同步方法,並使用async/await關鍵字來等待該操作完成。

async Task Task5()
{
    async Task<string> LongOperationAsync()
    {
        // 模擬耗時操作
        await Task.Delay(TimeSpan.FromSeconds(3));

        return "完成";
    }

    Console.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}開始耗時操作...");

    // 等待非同步方法完成
    var result = await LongOperationAsync();

    Console.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}耗時操作完成: {result}");
    Console.ReadKey();
}

在這個範例中,我們使用async/await關鍵字將LongOperationAsync方法宣告為非同步方法,並使用await關鍵字等待Task.Delay操作完成。在主程式中,我們可以使用await關鍵字等待LongOperationAsync完成並取得其結果。

2023-03-28 20:54:09.111開始耗時操作...
2023-03-28 20:54:12.143耗時操作完成: 完成

需要注意的是,在使用async/await關鍵字時,應該避免在非同步方法內部使用封鎖執行緒的操作,否則可能會導致UI執行緒被封鎖。如果必須執行封鎖操作,可以將其放在不同的執行緒上執行,或者使用非同步IO操作來避免封鎖執行緒。

三、使用async/await關鍵字注意

在使用async/await關鍵字時,還有一些細節需要注意,再給出兩個範例。

範例1

範例程式碼如下所示:

async Task Task6()
{
    async Task<string> LongOperationAsync(int id)
    {
        // 模擬耗時操作
        await Task.Delay(TimeSpan.FromSeconds(1 + id));

        return $"{DateTime.Now:ss.fff}完成 {id}";
    }

    Console.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}開始耗時操作...");

    // 等待多個非同步任務完成
    var task1 = LongOperationAsync(1);
    var task2 = LongOperationAsync(2);
    var task3 = LongOperationAsync(3);

    var results = await Task.WhenAll(task1, task2, task3);
    var resultStr = string.Join(",", results);

    Console.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}耗時操作完成: {resultStr}");
    Console.ReadKey();
}

在這個範例中,我們使用Task.WhenAll方法等待多個非同步任務完成,並使用Join方法將所有任務的結果連接起來作為最終結果。

2023-03-28 21:15:42.855開始耗時操作...
2023-03-28 21:15:46.894耗時操作完成: 44.888完成 1,45.883完成 2,46.893完成 3

範例2

另一個需要注意的問題是,在使用async/await關鍵字時,應該盡可能避免使用ConfigureAwait(false)方法。這個方法可以讓非同步操作不必恢復到原始的SynchronizationContext上,從而減少執行緒切換的開銷並提高效能。

然而,在某些情況下,如果在非同步操作完成後需要返回到原始的SynchronizationContext上,使用ConfigureAwait(false)會導致呼叫者無法正確處理結果。因此,建議僅在確定不需要返回到原始的SynchronizationContext上時才使用ConfigureAwait(false)方法。

範例程式碼: 假設我們有一個主控台應用程式,其中有兩個非同步方法:MethodAAsync()和MethodBAsync()。MethodAAsync()會等待1秒鐘,然後傳回一個字串。MethodBAsync()會等待2秒鐘,然後傳回一個字串。程式碼如下所示:

async Task<string> MethodAAsync()
{
    await Task.Delay(1000);
    return $"{DateTime.Now:ss.fff}>Hello";
}

async Task<string> MethodBAsync()
{
    await Task.Delay(2000);
    return $"{DateTime.Now:ss.fff}>World";
}

現在,我們想要同時呼叫這兩個方法,並將它們的結果合併成一個字串。我們可以像下面這樣編寫程式碼:

async Task<string> CombineResultsAAsync()
{
    var resultA = await MethodAAsync();
    var resultB = await MethodBAsync();
    return $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}: {resultA} | {resultB}";
}

這個程式碼看起來非常簡單明瞭,但是它存在一個效能問題。當我們呼叫CombineResultsAAsync()方法時,第一個await操作將使執行上下文切換回原始SynchronizationContext(即主執行緒),因此我們的非同步操作將在UI執行緒上執行。由於我們要等待1秒鐘才能從MethodAAsync()中取得結果,因此UI執行緒將被封鎖,直到非同步操作完成並且結果可用為止。

這種情況下,我們可以使用ConfigureAwait(false)方法來指定不需要保留目前上下文的執行緒執行狀態,從而讓非同步操作在一個執行緒集區執行緒上執行。這可以透過下面的程式碼實現:

async Task<string> CombineResultsBAsync()
{
    var resultA = await MethodAAsync().ConfigureAwait(false);
    var resultB = await MethodBAsync().ConfigureAwait(false);
    return $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}: {resultA} | {resultB}";
}

透過使用ConfigureAwait(false)方法,我們告訴非同步操作不需要保留目前上下文的執行緒執行狀態,這樣非同步操作就會在一個執行緒集區執行緒上執行,而不是在UI執行緒上執行。這樣做可以避免一些潛在的效能問題,因為我們的UI執行緒不會被封鎖,並且非同步操作可以在一個新的執行緒集區執行緒上執行。

四、總結

在使用async/await關鍵字時,應該遵循一些最佳實務,以提高程式碼的可讀性、可維護性和效能。以下是一些常見的最佳實務:

  1. 盡可能將非同步方法宣告為TaskTask<TResult>類型,以便可以使用await關鍵字等待其完成。如果非同步方法不傳回任何內容,則應將其宣告為Task類型。

  2. 在非同步方法內部盡可能避免使用封鎖執行緒的操作,而應該使用非封鎖操作來模擬延遲。如果必須執行封鎖操作,可以將其放在不同的執行緒上執行,或者使用非同步IO操作來避免封鎖執行緒。

  3. 在非同步方法內部不要捕獲例外並立即處理,因為這會導致程式碼變得複雜難以維護。應該讓呼叫者自行處理例外。如果必須在非同步方法內部捕獲例外,也應該將其包裝成AggregateException例外,並將其傳遞給呼叫者。

  4. 在使用ConfigureAwait(false)方法時要小心,只有在確定不需要返回到原始的SynchronizationContext上時才使用,否則可能會導致呼叫者無法正確處理結果。

  5. 盡量避免在非同步方法中使用不安全的執行緒API,例如Thread.SleepThread.Join等方法,以確保程式碼的可移植性和穩定性。應該使用非封鎖的非同步方法來模擬延遲。

  6. 在使用async/await關鍵字時,應該遵循一些命名約定,例如非同步方法的名稱應該以Async結尾,以便於區分同步和非同步方法。

  7. 在需要同時等待多個非同步任務完成時,可以使用Task.WhenAll方法等待所有任務完成。如果只需要等待其中一個任務完成,則可以使用Task.WhenAny方法等待任意一個任務完成。

  8. 在非同步方法內部,應該將耗時的操作封裝為另外的非同步方法,並在需要的地方使用async/await關鍵字呼叫它們,以提高程式碼的可讀性和可維護性。

  9. 在使用async/await關鍵字時,應該盡可能避免使用執行緒同步機制,例如lock關鍵字或Monitor類別,因為這會導致UI執行緒被封鎖。而應該使用非同步鎖或其他非封鎖的執行緒同步機制。

總之,使用Task和async/await可以大大簡化非同步程式設計,提高程式碼的可讀性、可維護性和效能。但是,需要注意一些細節和最佳實務,以確保程式碼的正確性和穩定性。

繼續探索

延伸閱讀

更多文章
同分類 / 同標籤 2026/2/7

AOT使用經驗總結

從專案建立伊始,就應養成良好的習慣,即只要添加了新功能或使用了較新的語法,就及時進行 AOT 發布測試。

繼續閱讀