本文透過與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關鍵字時,應該遵循一些最佳實務,以提高程式碼的可讀性、可維護性和效能。以下是一些常見的最佳實務:
盡可能將非同步方法宣告為
Task或Task<TResult>類型,以便可以使用await關鍵字等待其完成。如果非同步方法不傳回任何內容,則應將其宣告為Task類型。在非同步方法內部盡可能避免使用封鎖執行緒的操作,而應該使用非封鎖操作來模擬延遲。如果必須執行封鎖操作,可以將其放在不同的執行緒上執行,或者使用非同步IO操作來避免封鎖執行緒。
在非同步方法內部不要捕獲例外並立即處理,因為這會導致程式碼變得複雜難以維護。應該讓呼叫者自行處理例外。如果必須在非同步方法內部捕獲例外,也應該將其包裝成
AggregateException例外,並將其傳遞給呼叫者。在使用
ConfigureAwait(false)方法時要小心,只有在確定不需要返回到原始的SynchronizationContext上時才使用,否則可能會導致呼叫者無法正確處理結果。盡量避免在非同步方法中使用不安全的執行緒API,例如
Thread.Sleep或Thread.Join等方法,以確保程式碼的可移植性和穩定性。應該使用非封鎖的非同步方法來模擬延遲。在使用async/await關鍵字時,應該遵循一些命名約定,例如非同步方法的名稱應該以
Async結尾,以便於區分同步和非同步方法。在需要同時等待多個非同步任務完成時,可以使用
Task.WhenAll方法等待所有任務完成。如果只需要等待其中一個任務完成,則可以使用Task.WhenAny方法等待任意一個任務完成。在非同步方法內部,應該將耗時的操作封裝為另外的非同步方法,並在需要的地方使用
async/await關鍵字呼叫它們,以提高程式碼的可讀性和可維護性。在使用async/await關鍵字時,應該盡可能避免使用執行緒同步機制,例如
lock關鍵字或Monitor類別,因為這會導致UI執行緒被封鎖。而應該使用非同步鎖或其他非封鎖的執行緒同步機制。
總之,使用Task和async/await可以大大簡化非同步程式設計,提高程式碼的可讀性、可維護性和效能。但是,需要注意一些細節和最佳實務,以確保程式碼的正確性和穩定性。