任務異步編程模型(TAP)為異步代碼提供了一種抽象。我們可以像往常一樣按照順序編寫代碼,每個語句看起來都會在下一個語句開始之前完成 **。編譯器會進行許多轉換,因為其中一些語句可能會啟動工作並返回表示正在進行的工作的 Task。
這種語法的目標是使代碼讀起來像一連串語句,但執行順序卻複雜得多,它基於外部資源分配和任務完成時間。這類似於為包含異步任務的流程提供指令的方式。在本文中,將使用製作早餐的示例來演示 async 和 await 關鍵字如何使包含一系列異步指令的代碼更易於理解。我們可以像以下列表一樣編寫指令來解釋如何製作早餐:
- 倒一杯咖啡。
- 加熱平底鍋,然後煎兩個雞蛋。
- 煎三片培根。
- 烤兩片面包。
- 在面包上塗上黃油和果醬。
- 倒一杯橙汁。
如果你有烹飪經驗,你會異步執行這些指令。你會先開始加熱平底鍋準備煎雞蛋,然後開始煎培根。你會把面包放進烤箱,然後開始煮雞蛋。在整個過程的每個步驟中,你都會啟動一個任務,然後轉向那些準備好需要你處理的任務。
製作早餐是一個很好的異步工作而非並行工作的例子。一個人(或線程)可以處理所有這些任務。延續早餐的比喻,一個人可以通過在第一個任務完成之前開始下一個任務來異步製作早餐。烹飪過程會繼續進行,無論是否有人在觀察。一旦你開始加熱平底鍋準備煎雞蛋,你就可以開始煎培根。一旦培根開始煎,你就可以把面包放進烤箱。
對於並行算法(Parallel algorithm),你需要多個廚師(或線程)。一個人負責煎雞蛋,一個人負責煎培根,以此類推。每個人專注於自己的任務。每個廚師(或線程)會同步地被阻塞,等待培根煎熟需要翻面,或者面包彈出來。
現在,我們考慮將這些指令寫成 C# 語句:
namespace AsyncBreakfast
{
// 這些類在此示例中是故意為空的。它們只是用於演示的標記類,不包含屬性,並且沒有其他作用。
internal class Bacon { }
internal class Coffee { }
internal class Egg { }
internal class Juice { }
internal class Toast { }
class Program
{
static void Main(string[] args)
{
Coffee cup = PourCoffee();
Console.WriteLine("咖啡已準備好");
Egg eggs = FryEggs(2);
Console.WriteLine("雞蛋已煎好");
Bacon bacon = FryBacon(3);
Console.WriteLine("培根已煎好");
Toast toast = ToastBread(2);
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("面包已烤好");
Juice oj = PourOJ();
Console.WriteLine("橙汁已倒好");
Console.WriteLine("早餐已準備好!");
}
private static Juice PourOJ()
{
Console.WriteLine("倒橙汁");
return new Juice();
![Untitled](ipfs://QmUUPaUCsngMZh4apLSSypnPovBtkymu2qhgSUUwz3JK8K)
}
private static void ApplyJam(Toast toast) =>
Console.WriteLine("在面包上塗果醬");
private static void ApplyButter(Toast toast) =>
Console.WriteLine("在面包上塗黃油");
private static Toast ToastBread(int slices)
{
for (int slice = 0; slice < slices; slice++)
{
Console.WriteLine("將一片面包放入烤面包機");
}
Console.WriteLine("開始烤面包...");
Task.Delay(3000).Wait();
Console.WriteLine("從烤面包機中取出面包");
return new Toast();
}
private static Bacon FryBacon(int slices)
{
Console.WriteLine($"將{slices}片培根放入平底鍋");
Console.WriteLine("煎培根的第一面...");
Task.Delay(3000).Wait();
for (int slice = 0; slice < slices; slice++)
{
Console.WriteLine("翻轉一片培根");
}
Console.WriteLine("煎培根的第二面...");
Task.Delay(3000).Wait();
Console.WriteLine("將培根放在盤子上");
return new Bacon();
}
private static Egg FryEggs(int howMany)
{
Console.WriteLine("預熱煎蛋鍋...");
Task.Delay(3000).Wait();
Console.WriteLine($"打開{howMany}個雞蛋");
Console.WriteLine("煮雞蛋...");
Task.Delay(3000).Wait();
Console.WriteLine("將雞蛋放在盤子上");
return new Egg();
}
private static Coffee PourCoffee()
{
Console.WriteLine("倒咖啡");
return new Coffee();
}
}
}
計算機不像人類一樣解讀這些指令。計算機會在每個語句上阻塞,直到工作完成後才繼續執行下一個語句。這樣會導致早餐準備時間很長,並且有些食物在上桌前就已經變涼了。
如果想讓計算機異步執行以上指令,必須編寫異步代碼。
這些問題對於我們現在編寫的程序非常重要。當編寫客戶端程序時,你希望用戶界面對用戶輸入有響應。在從網絡下載數據時,您的應用程序不應該讓手機出現卡頓。在編寫服務器程序時,你不希望線程被阻塞。這些線程可能正在處理其他請求。在異步代碼可替代的情況下使用同步代碼會影響你以更低成本擴展程序的能力。你要為這些被阻塞的線程付出代價。
成功的現代應用程序需要異步代碼。在沒有語言支持的情況下,編寫異步代碼需要使用回調、完成事件或其他方式,這會掩蓋代碼的原始意圖。同步代碼的優點在於它按步驟執行,易於掃描和理解。傳統的異步模型讓你更多關注代碼的異步特性,而不是代碼的基本操作。
不要阻塞(block),而是 await#
前面的代碼演示了一種糟糕的做法:編寫同步代碼來執行異步操作。按照寫法,這段代碼會阻止執行它的線程進行任何其他工作。當任何任務正在進行時,它都不會被中斷。這就好比你把面包放進面包機後盯著面包機。在面包片爆開之前,你不會理睬任何和你說話的人。
讓我們先更新這段代碼,使線程在任務運行時不會阻塞。**await
關鍵字提供了一種非阻塞的方式來啟動任務,然後在任務完成後繼續執行 **。製作早餐代碼的簡單異步版本如下所示:
static **async Task** Main(string[] args)
{
Coffee cup = PourCoffee();
Console.WriteLine("咖啡已準備好");
Egg eggs = await FryEggsAsync(2);
Console.WriteLine("雞蛋已煎好");
Bacon bacon = await FryBaconAsync(3);
Console.WriteLine("培根已煎好");
Toast toast = await ToastBreadAsync(2);
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("面包已烤好");
Juice oj = PourOJ();
Console.WriteLine("橙汁已倒好");
Console.WriteLine("早餐已準備好!");
}
在烹飪雞蛋或培根時,這段代碼不會阻塞。但這段代碼不會啟動任何其他任務。你還是會把面包片放進烤面包機,然後盯著它直到它爆開。但至少,你會對任何想引起你注意的人做出回應。在一家有多個訂單的餐廳裡,廚師可以在烹飪第一份早餐時開始另一份早餐。
現在,做早餐的線程在等待尚未完成的任務時不會被阻塞。對於某些應用程序來說,只需做出這樣的改變即可。圖形用戶界面應用程序只需做出這樣的改變,就能對用戶做出響應。但是,在這種情況下,您需要的更多。你不希望每個組件任務都按順序執行。最好是在等待前一個任務完成之前啟動每一個組件任務。
並發啟動任務(Start tasks concurrently)#
在許多場景中,你希望立即啟動多個獨立的任務。然後,當每個任務完成時,你可以繼續進行其他已準備好的工作。在早餐的比喻中,這是如何更快地完成早餐的方法。你還可以同時完成所有事情。你會得到一份熱騰騰的早餐。
System.Threading.Tasks.Task 和相關類型是你可以用來處理正在進行的任務的類。這使得你能夠編寫更接近製作早餐的代碼。你會同時開始煮雞蛋、培根和烤面包。當每個任務需要行動時,你會轉向該任務,處理下一個動作,然後等待需要你注意的其他任務。
你會啟動一個任務並保存代表該任務的 Task 對象。在處理每個任務的結果之前都要 await
。
讓我們對早餐代碼進行這些更改。第一步是將任務存儲起來,以便在任務開始時進行操作,而不是等待任務:
Coffee cup = PourCoffee();
Console.WriteLine("咖啡已準備好");
Task<Egg> eggsTask = FryEggsAsync(2);
Egg eggs = await eggsTask;
Console.WriteLine("雞蛋已煎好");
Task<Bacon> baconTask = FryBaconAsync(3);
Bacon bacon = await baconTask;
Console.WriteLine("培根已煎好");
Task<Toast> toastTask = ToastBreadAsync(2);
Toast toast = await toastTask;
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("面包已烤好");
Juice oj = PourOJ();
Console.WriteLine("橙汁已倒好");
Console.WriteLine("早餐已準備好!");
上述代碼不會讓早餐更快準備好。任務在開始後立即被 await
。接下來,你可以將對培根和雞蛋的 await
語句移動到方法末尾,在上菜之前進行等待:
Coffee cup = PourCoffee();
Console.WriteLine("咖啡已準備好");
Task<Egg> eggsTask = FryEggsAsync(2);
Task<Bacon> baconTask = FryBaconAsync(3);
Task<Toast> toastTask = ToastBreadAsync(2);
Toast toast = await toastTask;
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("面包已烤好");
Juice oj = PourOJ();
Console.WriteLine("橙汁已倒好");
Egg eggs = await eggsTask;
Console.WriteLine("雞蛋已煎好");
Bacon bacon = await baconTask;
Console.WriteLine("培根已煎好");
Console.WriteLine("早餐已準備好!");
這次採用異步準備早餐大約花了 20 分鐘,之所以能節省時間,是因為有些任務是並發運行的。
前面的代碼效果更好。你一次性啟動所有的異步任務。只有在需要結果時才等待每個任務。前面的代碼可能類似於一個向不同微服務發送請求並將結果合併到單個頁面中的網絡應用程序的代碼。你會立即發出所有請求,然後等待所有任務完成並組合網頁。
組合任務(Composition with tasks)#
除了面包片之外,早餐的一切都同時準備就緒。製作面包片包括一個異步操作(烤面包),以及同步操作(加黃油和果醬)。更新這段代碼可以說明一個重要的概念:
前面的代碼展示了可以使用 Task 或 Task 對象來保存正在運行的任務。在使用結果之前,你需要 await
每個任務的完成。下一步是創建代表其他工作組合的方法。在供應早餐之前,需要等待烤面包的任務完成,然後再添加黃油和果醬。可以用以下代碼表示這個工作:
static async Task<Toast> MakeToastWithButterAndJamAsync(int number)
{
var toast = await ToastBreadAsync(number);
ApplyButter(toast);
ApplyJam(toast);
return toast;
}
上述方法在其簽名中有 async
修飾符。這向編譯器發出信號,表明該方法包含一個 await
語句;它包含異步操作。該方法表示烤面包、加黃油和果醬的任務。該方法返回一個 Task,表示這三個操作的組合。現在主要的代碼塊變成了:
Coffee cup = PourCoffee();
Console.WriteLine("咖啡已準備好");
Task<Egg> eggsTask = FryEggsAsync(2);
Task<Bacon> baconTask = FryBaconAsync(3);
Task<Toast> toastTask = MakeToastWithButterAndJamAsync(2);
Egg eggs = await eggsTask;
Console.WriteLine("雞蛋已煎好");
Bacon bacon = await baconTask;
Console.WriteLine("培根已煎好");
Toast toast = await toastTask;
Console.WriteLine("面包已烤好");
Juice oj = PourOJ();
Console.WriteLine("橙汁已倒好");
Console.WriteLine("早餐已準備好!");
上述更改展示了處理異步代碼的重要技巧。通過將操作分離到一個新方法中,並返回一個任務來組合任務。你可以選擇何時等待該任務,也可以同時啟動其他任務。
異步異常(Asynchronous exceptions)#
到目前為止,你默認假設所有這些任務都能成功完成。異步方法和同步方法一樣,也會拋出異常。異步異常和錯誤處理的目標與一般的異步支持相同:你編寫的代碼應該讀起來像一系列同步語句。當任務無法成功完成時,它們會拋出異常。在 await
已啟動的任務時,客戶端代碼可以捕獲這些異常。例如,假設烤面包機在做面包時著火了。可以通過修改 ToastBreadAsync
方法來模擬這種情況,代碼如下:
private static async Task<Toast> ToastBreadAsync(int slices)
{
for (int slice = 0; slice < slices; slice++)
{
Console.WriteLine("將一片面包放入烤面包機");
}
Console.WriteLine("開始烤面包...");
await Task.Delay(2000);
Console.WriteLine("著火啦!烤面包毀了!");
throw new InvalidOperationException("烤面包機著火了");
await Task.Delay(1000);
Console.WriteLine("從烤面包機中取出面包");
return new Toast();
}
作出這些更改後運行應用程序,你將看到與下面文本類似的輸出結果:
....
培根已煎好
Unhandled exception. System.InvalidOperationException: 烤面包機著火了
at AsyncBreakfast.Program.ToastBreadAsync(Int32 slices) in Program.cs:line 65
at AsyncBreakfast.Program.MakeToastWithButterAndJamAsync(Int32 number) in Program.cs:line 36
at AsyncBreakfast.Program.Main(String[] args) in Program.cs:line 24
at AsyncBreakfast.Program.<Main>(String[] args)
你會注意到,在烤面包機著火和觀察到異常之間,有很多任務已經完成。當異步運行的任務拋出異常時,該任務就會發生故障(faulted*)*。任務對象會在 Task.Exception 屬性中保存拋出的異常。故障任務在 await 時會拋出異常。
有兩個重要機制需要了解:異常如何存儲在故障任務中,以及代碼在等待故障任務時如何解包並重新拋出異常。
當異步運行的代碼拋出異常時,異常會存儲在 Task 中。Task.Exception 屬性是 System.AggregateException,因為在異步工作期間可能會拋出不止一個異常。拋出的任何異常都會添加到 AggregateException.InnerExceptions 集合中。如果 Exception
屬性為 null,就會創建一個新的 AggregateException
,而拋出的異常就是集合中的第一個 item。
故障任務最常見的情況是 Exception
屬性正好包含一個異常。當代碼 await
故障任務時,AggregateException.InnerExceptions 集合中的第一個異常會被重新拋出。這就是為什麼本例的輸出顯示的是 InvalidOperationException
而不是 AggregateException
。提取第一個內部異常可使異步方法的工作盡可能與同步方法相似。當您的應用場景可能產生多個異常時,您可以檢查代碼中的 Exception
屬性。
在繼續之前,註釋掉 ToastBreadAsync
方法中的這兩行。你可不想再引起一場火災:
Console.WriteLine("著火啦!烤面包毀了!");
throw new InvalidOperationException("烤面包機著火了");
高效地等待任務(Await tasks efficiently)#
在前面的代碼中,通過使用 Task
類的方法可以改進一系列的 await
語句。其中一個 API 是 WhenAll,它可以在參數列表中的所有任務都完成時返回一個 Task,如下所示的代碼所示:
await Task.WhenAll(eggsTask, baconTask, toastTask);
Console.WriteLine("Eggs are ready");
Console.WriteLine("Bacon is ready");
Console.WriteLine("Toast is ready");
Console.WriteLine("Breakfast is ready!");
另一種方法是使用 WhenAny,它返回一個 Task<Task>
,當其參數中的任何一個完成時,它就會完成。你可以等待返回的 Task,知道它已經完成。下面的代碼展示了如何使用 WhenAny
來等待第一個完成的 Task,然後處理其結果。在處理完已完成的任務的結果後,你可以將該已完成任務從傳遞給 WhenAny
的任務列表中移除。
var breakfastTasks = new List<Task> { eggsTask, baconTask, toastTask };
while (breakfastTasks.Count > 0)
{
Task finishedTask = await Task.WhenAny(breakfastTasks);
if (finishedTask == eggsTask)
{
Console.WriteLine("Eggs are ready");
}
else if (finishedTask == baconTask)
{
Console.WriteLine("Bacon is ready");
}
else if (finishedTask == toastTask)
{
Console.WriteLine("Toast is ready");
}
await finishedTask;
breakfastTasks.Remove(finishedTask);
}
在接近結尾的地方,你會看到一行代碼 await finishedTask
;因為 await Task.WhenAny
並不等待已完成的任務。它等待的是 Task.WhenAny
返回的 Task
。Task.WhenAny
的結果是已完成(或發生故障)的任務。你應該再次等待該任務,即使你知道它已經運行完畢。這樣才能獲取其結果,或確保導致其故障的異常被拋出。
在所有這些更改之後,代碼的最終版本如下所示:
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace AsyncBreakfast
{
// 這些類在此示例中是故意為空的。它們只是用於演示的標記類,不包含屬性,並且沒有其他作用。
internal class Bacon { }
internal class Coffee { }
internal class Egg { }
internal class Juice { }
internal class Toast { }
class Program
{
static async Task Main(string[] args)
{
Coffee cup = PourCoffee();
Console.WriteLine("coffee is ready");
var eggsTask = FryEggsAsync(2);
var baconTask = FryBaconAsync(3);
var toastTask = MakeToastWithButterAndJamAsync(2);
var breakfastTasks = new List<Task> { eggsTask, baconTask, toastTask };
while (breakfastTasks.Count > 0)
{
Task finishedTask = await Task.WhenAny(breakfastTasks);
if (finishedTask == eggsTask)
{
Console.WriteLine("eggs are ready");
}
else if (finishedTask == baconTask)
{
Console.WriteLine("bacon is ready");
}
else if (finishedTask == toastTask)
{
Console.WriteLine("toast is ready");
}
await finishedTask;
breakfastTasks.Remove(finishedTask);
}
Juice oj = PourOJ();
Console.WriteLine("oj is ready");
Console.WriteLine("Breakfast is ready!");
}
static async Task<Toast> MakeToastWithButterAndJamAsync(int number)
{
var toast = await ToastBreadAsync(number);
ApplyButter(toast);
ApplyJam(toast);
return toast;
}
private static Juice PourOJ()
{
Console.WriteLine("倒橙汁");
return new Juice();
}
private static void ApplyJam(Toast toast) =>
Console.WriteLine("在面包上涂果酱");
private static void ApplyButter(Toast toast) =>
Console.WriteLine("在面包上涂黄油");
private static async Task<Toast> ToastBreadAsync(int slices)
{
for (int slice = 0; slice < slices; slice++)
{
Console.WriteLine("將一片面包放入烤面包機");
}
Console.WriteLine("開始烤面包...");
await Task.Delay(3000);
Console.WriteLine("從烤面包機中取出面包");
return new Toast();
}
private static async Task<Bacon> FryBaconAsync(int slices)
{
Console.WriteLine($"將{slices}片培根放入平底鍋");
Console.WriteLine("煎培根的第一面...");
await Task.Delay(3000);
for (int slice = 0; slice < slices; slice++)
{
Console.WriteLine("翻轉一片培根");
}
Console.WriteLine("煎培根的第二面...");
await Task.Delay(3000);
Console.WriteLine("將培根放在盤子上");
return new Bacon();
}
private static async Task<Egg> FryEggsAsync(int howMany)
{
Console.WriteLine("預熱煎蛋鍋...");
await Task.Delay(3000);
Console.WriteLine($"打開{howMany}個雞蛋");
Console.WriteLine("煮雞蛋...");
await Task.Delay(3000);
Console.WriteLine("將雞蛋放在盤子上");
return new Egg();
}
private static Coffee PourCoffee()
{
Console.WriteLine("倒咖啡");
return new Coffee();
}
}
}
異步準備早餐的最終版本大約花了 15 分鐘,因為某些任務並行運行,並且代碼可以同時監視多個任務,只在需要時才採取行動。
這個最終版的代碼是異步的。它更準確地反映了一個人做早餐的方式。將前面的代碼與本文開頭的第一個代碼示例進行比較。從代碼中仍然可以清晰地看出核心操作。你可以像閱讀本文開頭關於製作早餐的說明一樣閱讀這段代碼。async
和 await
的語言特性提供了每個人遵循書面說明的翻譯方法:盡可能啟動任務,並且不阻塞等待任務完成。