在現代應用程式開發中,非同步程式設計幾乎是必備技能。無論是等待 API 回應、讀取檔案或存取資料庫,如果用同步的方式去寫,程式常會「卡住」導致效能低下。C# 提供了 async / await 關鍵字,讓程式可以像同步一樣直覺撰寫,但實際上卻是非同步執行。參考資料
TAP(Task Asynchronous Programming)是 .NET 針對非同步程式設計的一套統一模型,他的特色如下:
Task
/ Task<T>
物件封裝「進行中的工作」。Task 非同步程式設計模型可以比擬人類在處理帶有非同步任務的流程時的指令方式,再參考文章內使用「製作早餐」的例子,說明 async 和 await 關鍵字如何讓包含一系列非同步指令的程式碼更容易理解。
例如,早餐的指令清單可能是:
如果你有烹飪經驗,你會發現這些步驟常常是「非同步」完成的。你會一邊加熱鍋子準備煎蛋,一邊開始煎薯餅。你會把麵包放進烤麵包機,然後回來煎蛋。每一步驟中,你啟動了一個任務,然後轉移到下一個可以處理的工作。
可以透過以下列範例看到如何將「同步」的早餐流程用 C# 撰寫:
using System;
using System.Threading.Tasks;
namespace AsyncBreakfast
{
// 這些類別只是用來做範例標記,沒有實際屬性或功能
internal class HashBrown { }
internal class Coffee { }
internal class Egg { }
internal class Juice { }
internal class Toast { }
class Program
{
static void Main(string[] args)
{
Coffee cup = PourCoffee();
Console.WriteLine("coffee is ready");
Egg eggs = FryEggs(2);
Console.WriteLine("eggs are ready");
HashBrown hashBrown = FryHashBrowns(3);
Console.WriteLine("hash browns are ready");
Toast toast = ToastBread(2);
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("toast is ready");
Juice oj = PourOJ();
Console.WriteLine("oj is ready");
Console.WriteLine("Breakfast is ready!");
}
private static Juice PourOJ()
{
Console.WriteLine("Pouring orange juice");
return new Juice();
}
private static void ApplyJam(Toast toast) =>
Console.WriteLine("Putting jam on the toast");
private static void ApplyButter(Toast toast) =>
Console.WriteLine("Putting butter on the toast");
private static Toast ToastBread(int slices)
{
for (int slice = 0; slice < slices; slice++)
{
Console.WriteLine("Putting a slice of bread in the toaster");
}
Console.WriteLine("Start toasting...");
Task.Delay(3000).Wait();
Console.WriteLine("Remove toast from toaster");
return new Toast();
}
private static HashBrown FryHashBrowns(int patties)
{
Console.WriteLine($"putting {patties} hash brown patties in the pan");
Console.WriteLine("cooking first side of hash browns...");
Task.Delay(3000).Wait();
for (int patty = 0; patty < patties; patty++)
{
Console.WriteLine("flipping a hash brown patty");
}
Console.WriteLine("cooking the second side of hash browns...");
Task.Delay(3000).Wait();
Console.WriteLine("Put hash browns on plate");
return new HashBrown();
}
private static Egg FryEggs(int howMany)
{
Console.WriteLine("Warming the egg pan...");
Task.Delay(3000).Wait();
Console.WriteLine($"cracking {howMany} eggs");
Console.WriteLine("cooking the eggs ...");
Task.Delay(3000).Wait();
Console.WriteLine("Put eggs on plate");
return new Egg();
}
private static Coffee PourCoffee()
{
Console.WriteLine("Pouring coffee");
return new Coffee();
}
}
}
就電腦的方式來理解這些指令,早餐會花大約 30 分鐘準備完成。因為總時間 = 每個任務時間的加總,程式碼在每一個語句上「阻塞」,直到工作完成才會繼續下一個語句。這樣會導致整體效率低下。
前一個程式碼範例凸顯了一個不佳的程式設計習慣:使用同步程式碼執行非同步作業。這樣的程式碼會阻塞目前的執行緒,使它無法去處理其他工作。當任務進行中時,程式碼並沒有釋放執行緒,而是持續等待。這種模式的結果,就像是你把麵包放進烤麵包機後,整個人盯著它看。你不理會其他狀況,也不會去冰箱拿奶油與果醬。甚至可能錯過爐子上開始冒出的火苗。理想狀況是,你既能烤麵包,又能同時處理其他事情,程式碼也是如此。
可以先更新程式碼,讓執行緒在任務進行時不會被阻塞。await 關鍵字提供了一種「非阻塞」的方式來啟動任務,並在任務完成後繼續執行程式。下面是一個簡單的非同步版本早餐程式碼片段:
static async Task Main(string[] args)
{
Coffee cup = PourCoffee();
Console.WriteLine("coffee is ready");
Egg eggs = await FryEggsAsync(2);
Console.WriteLine("eggs are ready");
HashBrown hashBrown = await FryHashBrownsAsync(3);
Console.WriteLine("hash browns are ready");
Toast toast = await ToastBreadAsync(2);
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("toast is ready");
Juice oj = PourOJ();
Console.WriteLine("oj is ready");
Console.WriteLine("Breakfast is ready!");
}
FryEggs、FryHashBrowns、ToastBread 的方法本體都被修改為回傳 Task<Egg>
、Task<HashBrown>
、Task<Toast>
。方法名稱也加上了 Async 後綴:FryEggsAsync、FryHashBrownsAsync、ToastBreadAsync。Main 方法本身回傳一個 Task 物件,雖然沒有實際回傳值,這是設計使然。
在大多數操作中,我們會希望立即啟動幾個獨立的任務,當某個任務完成時,可以立刻啟動其他已準備好的工作。將這種方法應用到「早餐範例」時,你能更快地準備早餐。你還能讓所有東西幾乎同時完成,這樣就能享受一頓熱騰騰的早餐。System.Threading.Tasks.Task
類別和相關型別是你可以用來處理這種正在進行的任務的工具。這種方法讓你的程式碼更貼近真實生活中準備早餐的方式:你同時開始煎蛋、煎薯餅和烤吐司。當某個食物需要你處理時,你就轉向那個任務,處理完後再去等待下一個需要注意的項目。
在程式碼中,啟動一個任務,並保存代表這個工作的 Task 物件。當你需要使用結果時,再用 await 關鍵字去等待該任務完成。
第一步是:在任務開始時就儲存任務物件,而不是馬上用 await 表達式等待結果:
Coffee cup = PourCoffee();
Console.WriteLine("Coffee is ready");
Task<Egg> eggsTask = FryEggsAsync(2);
Egg eggs = await eggsTask;
Console.WriteLine("Eggs are ready");
Task<HashBrown> hashBrownTask = FryHashBrownsAsync(3);
HashBrown hashBrown = await hashBrownTask;
Console.WriteLine("Hash browns are ready");
Task<Toast> toastTask = ToastBreadAsync(2);
Toast toast = await toastTask;
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("Toast is ready");
Juice oj = PourOJ();
Console.WriteLine("Oj is ready");
Console.WriteLine("Breakfast is ready!");
但這樣的修改並不會讓早餐更快完成,因為每個任務一開始就被 await 阻塞住了。
第二步:將 await 延後到最後
把薯餅和雞蛋的 await 移到方法最後,在正式上桌前再處理結果:
using System;
using System.Threading.Tasks;
namespace AsyncBreakfast
{
// These classes are intentionally empty for the purpose of this example. They are simply marker classes for the purpose of demonstration, contain no properties, and serve no other purpose.
internal class HashBrown { }
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 = Task.Run(() => FryEggs(2));
var hashBrownTask = Task.Run(() => FryHashBrowns(3));
var toastTask = Task.Run(() => ToastBread(2));
// 等待所有任務完成
await Task.WhenAll(eggsTask, hashBrownTask, toastTask);
// 處理吐司
var toast = toastTask.Result;
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("Toast is ready");
// 倒柳橙汁
Juice oj = PourOJ();
Console.WriteLine("Oj is ready");
// 取得其他已完成任務的結果
var eggs = eggsTask.Result;
var hashBrown = hashBrownTask.Result;
Console.WriteLine("Eggs are ready");
Console.WriteLine("Hash browns are ready");
Console.WriteLine("Breakfast is ready!");
}
private static Juice PourOJ()
{
Console.WriteLine("Pouring orange juice");
return new Juice();
}
private static void ApplyJam(Toast toast) =>
Console.WriteLine("Putting jam on the toast");
private static void ApplyButter(Toast toast) =>
Console.WriteLine("Putting butter on the toast");
private static Toast ToastBread(int slices)
{
for (int slice = 0; slice < slices; slice++)
{
Console.WriteLine("Putting a slice of bread in the toaster");
}
Console.WriteLine("Start toasting...");
Task.Delay(3000).Wait();
Console.WriteLine("Remove toast from toaster");
return new Toast();
}
private static HashBrown FryHashBrowns(int patties)
{
Console.WriteLine($"putting {patties} hash brown patties in the pan");
Console.WriteLine("cooking first side of hash browns...");
Task.Delay(3000).Wait();
for (int patty = 0; patty < patties; patty++)
{
Console.WriteLine("flipping a hash brown patty");
}
Console.WriteLine("cooking the second side of hash browns...");
Task.Delay(3000).Wait();
Console.WriteLine("Put hash browns on plate");
return new HashBrown();
}
private static Egg FryEggs(int howMany)
{
Console.WriteLine("Warming the egg pan...");
Task.Delay(3000).Wait();
Console.WriteLine($"cracking {howMany} eggs");
Console.WriteLine("cooking the eggs ...");
Task.Delay(3000).Wait();
Console.WriteLine("Put eggs on plate");
return new Egg();
}
private static Coffee PourCoffee()
{
Console.WriteLine("Pouring coffee");
return new Coffee();
}
}
}
輸出:
這樣,就能異步地準備早餐,大約只需要 20 分鐘。總耗時縮短了,因為部分任務可以同時進行。
這樣的程式碼更新確實加快了準備流程,縮短了烹飪時間,但也帶來一個問題:雞蛋和薯餅被燒焦了。
原因是:你同時啟動了所有非同步任務,卻只在需要結果時才去等待。這種模式和 Web 應用程式 很像:程式同時向不同的微服務發送請求,最後才用 await 收集所有結果,然後組合成一個完整的網頁。
今日的非同步學習從一開始的按部就班,一行一行跑程式碼,到學或如何在A在執行時讓B也可以同步執行,無需等待A完整結束才開始,這在許多場景都相當是用。但今天的學習進度尚未是最終版的非同步程式,明日會接續著基今日的內容往下寫,讓非同步更符合我們日常所用。