共用方式為


使用 async 和 await 進行異步程序設計

工作異步程序設計 (TAP) 模型提供一層抽象概念,以取代一般異步編碼。 在此模型中,您會撰寫程式代碼做為語句序列,與往常相同。 差別在於,您可以讀取以工作為基礎的程式代碼,因為編譯程式會處理每個語句,並在它開始處理下一個語句之前。 若要完成此模型,編譯程式會執行許多轉換來完成每個工作。 有些語句可以起始工作,並傳回 Task 代表進行中工作的對象,編譯程式必須解析這些轉換。 工作異步程式設計的目標是實現像語句序列一樣可讀的程式碼,但會以更複雜的順序執行。 執行是以外部資源配置和工作完成時為基礎。

工作異步程序設計模型類似於人們如何提供包含異步工作之程式的指示。 本文使用含有製作早餐指示的範例來展示 asyncawait 關鍵詞如何讓您更輕鬆地推理包含一系列異步指令的程式碼。 製作早餐的指示可以列表的形式呈現:

  1. 倒一杯咖啡。
  2. 加熱鍋,然後炒兩個雞蛋。
  3. 煎三片培根。
  4. 烤兩片麵包。
  5. 將黃油和果醬撒在烤麵包上。
  6. 倒一杯橙汁。

如果您有烹飪經驗,您可能會 以異步方式完成這些指示。 你開始把鍋加熱來煎蛋,然後開始煎培根。 你把麵包放在烤箱裡,然後開始煮雞蛋。 在流程的每個步驟中,您都會開始一項任務,然後轉換到其他已準備好讓您注意的任務。

做早餐是一種非同步但是非並行的工作的好例子。 一個人(或線程)可以處理所有工作。 一個人可以在上一個工作完成之前啟動下一項工作,以異步方式進行早餐。 無論有人是否正在專注觀看這個過程,每個烹飪工作都會進行。 當你開始加熱鍋子準備煎蛋時,就可以開始煎培根。 培根開始烹煮後,您可以將麵包放進烤麵包機中。

為了實現平行演算法,您需要多個人員進行烹飪(或多個線程)。 一個人煮雞蛋,另一個炸熏肉,等等。 每個人都專注於他們的一個特定工作。 正在烹飪的每個人(或每個線程)都會被同步阻塞,等待目前的工作完成:培根準備翻轉、麵包準備在烤麵包機中彈出,等等。

此圖為準備早餐的指示,顯示一個在 30 分鐘內完成的七項連續任務清單。

請考慮以 C# 程式代碼語句撰寫的同步指令清單:

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 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("coffee is ready");

            Egg eggs = FryEggs(2);
            Console.WriteLine("eggs are ready");

            Bacon bacon = FryBacon(3);
            Console.WriteLine("bacon is 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 Bacon FryBacon(int slices)
        {
            Console.WriteLine($"putting {slices} slices of bacon in the pan");
            Console.WriteLine("cooking first side of bacon...");
            Task.Delay(3000).Wait();
            for (int slice = 0; slice < slices; slice++)
            {
                Console.WriteLine("flipping a slice of bacon");
            }
            Console.WriteLine("cooking the second side of bacon...");
            Task.Delay(3000).Wait();
            Console.WriteLine("Put bacon on plate");

            return new Bacon();
        }

        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 分鐘才能準備。 持續時間是個別工作時間的總和。 電腦會封鎖每個指令,直到所有工作完成,然後繼續進行下一個任務指令。 這種方法可能需要相當長的時間。 在早餐範例中,計算機方法會建立不滿意早餐。 同步清單中的後續工作,例如烤麵包,要等到前面的工作完成後才會開始。 早餐前,一些食物變冷了。

如果您想要讓電腦以異步方式執行指令,您必須撰寫異步程序代碼。 當您撰寫用戶端程式時,您希望 UI 回應使用者輸入。 從 Web 下載資料時,您的應用程式不應該凍結所有互動。 當您撰寫伺服器程式時,您不想封鎖可能正在處理其他要求的線程。 當有異步替代方案時,使用同步程式代碼會損害您以較低成本進行擴展的能力。 您須為被封鎖的線程付費。

成功的新式應用程式需要異步程序代碼。 如果沒有語言支援,撰寫異步程式代碼需要回呼、完成事件或其他表示會遮蔽程式代碼的原始意圖。 同步程式代碼的優點是逐步動作,可讓您輕鬆掃描和瞭解。 傳統的異步模型會強制您將焦點放在程式碼的異步本質,而不是將焦點放在程式碼的基本動作上。

不要封鎖,請改用等待

先前的程式碼強調一個不幸的程式設計做法:撰寫同步程式碼以執行異步操作。 程式碼會阻止目前的線程執行任何其他工作。 程序代碼不會在有執行中的工作時中斷線程。 此模型的結果類似於在您放入麵包後盯著烤麵包機。 您忽略任何中斷,而且在麵包彈出之前,不要啟動其他工作。 你不要把奶油和果醬從冰箱裡拿出來。 你可能會錯過爐子上開始著火。 您想要同時烤麵包並處理其他事情。 您的程序代碼也是如此。

您可以從更新程式代碼開始,讓線程在執行工作時不會封鎖。 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");

    Bacon bacon = await FryBaconAsync(3);
    Console.WriteLine("bacon is 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!");
}

程式碼會更新FryEggsFryBaconToastBread的方法主體,使其分別傳回Task<Egg>Task<Bacon>Task<Toast>物件。 更新的方法名稱包括 「Async」 後綴: FryEggsAsyncFryBaconAsyncToastBreadAsyncMain 方法會傳回 Task 物件,儘管設計上它沒有 return 表達式。 如需詳細資訊,請參閱 回傳 void 的非同步函式的評估

備註

更新的程式代碼尚未利用異步程序設計的主要功能,這可能會導致較短的完成時間。 程式代碼會以與初始同步版本大致相同的時間來處理工作。 如需完整的方法實作,請參閱本文稍後 的程式代碼最終版本

讓我們將早餐範例套用至更新的程序代碼。 當雞蛋或培根正在烹飪時,線程不會封鎖,但程式代碼也不會啟動其他工作,直到目前的工作完成。 你仍然把麵包放進烤麵包機,盯著它直到麵包彈出,但現在你可以隨時應對任何打擾。 在一家同時處理多個訂單的餐廳,廚師可以開始準備新的菜單,但其他訂單已在烹調中。

在更新的程式代碼中,在等候任何未完成的工作時,不會封鎖處理早餐的線程。 對於某些應用程式,這項變更是您需要的。 您可以在從 Web 下載資料時,讓應用程式支援用戶互動。 在其他案例中,您可能會想要在等候上一個工作完成時啟動其他工作。

同時啟動工作

針對大部分作業,您想要立即啟動數個獨立的任務。 當每個工作完成時,您都會起始其他準備好開始的工作。 當您將此方法套用至早餐範例時,可以更快速地準備早餐。 你也會把所有東西差不多同時準備好,這樣你就可以享受熱騰騰的早餐。

類別 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<Bacon> baconTask = FryBaconAsync(3);
Bacon bacon = await baconTask;
Console.WriteLine("Bacon is 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 表達式移動到方法的結尾,然後再提供早餐:

Coffee cup = PourCoffee();
Console.WriteLine("Coffee is ready");

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("Toast is ready");
Juice oj = PourOJ();
Console.WriteLine("Oj is ready");

Egg eggs = await eggsTask;
Console.WriteLine("Eggs are ready");
Bacon bacon = await baconTask;
Console.WriteLine("Bacon is ready");

Console.WriteLine("Breakfast is ready!");

您現在有一個提前準備的早餐,需要大約 20 分鐘才能完成準備。 總烹飪時間會減少,因為某些工作會同時執行。

此圖顯示準備早餐的指示,共有八個非同步任務,約在 20 分鐘內完成。不幸的是,雞蛋和培根焦掉了。

程式碼更新會藉由減少烹調時間來改善準備過程,但會導致一個缺陷,讓雞蛋和培根燒焦。 您一次啟動所有異步工作。 只有在您需要結果時才等候每一項任務。 程序代碼可能類似於 Web 應用程式中的程式,它會對不同的微服務提出要求,然後將結果合併成單一頁面。 您立即提出所有要求,然後將表達式套用 await 到所有這些工作,並撰寫網頁。

支援任務組合

先前的程式代碼修訂可協助讓所有早餐項目同時準備好,唯獨烤麵包除外。 製作吐司的過程是異步操作(烤麵包)與同步操作(在吐司上塗抹黃油和果醬)的組合。 此範例說明異步程序設計的重要概念:

這很重要

異步操作加上同步工作的組合仍然是異步操作。 以另一種方式表示,如果作業的任何部分是異步的,則整個作業都是異步的。

在先前的更新中,您已瞭解如何使用 TaskTask<TResult> 物件來保存執行中的工作。 您先等候每個任務的完成,再使用它的結果。 下一個步驟是建立代表其他工作組合的方法。 在準備早餐之前,您應該先等待麵包烤好,然後再塗抹黃油和果醬。

您可以使用下列程式代碼來表示此工作:

static async Task<Toast> MakeToastWithButterAndJamAsync(int number)
{
    var toast = await ToastBreadAsync(number);
    ApplyButter(toast);
    ApplyJam(toast);

    return toast;
}

MakeToastWithButterAndJamAsync方法在其async宣告中有修飾符,向編譯程序發出訊號,指出方法包含await表達式並包含非同步操作。 方法代表烤麵包的任務,然後塗抹黃油和果醬。 方法會傳回 物件,代表三個 Task<TResult> 作業的組成。

修改後的主要程式代碼區塊現在看起來像這樣:

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 eggs = await eggsTask;
    Console.WriteLine("eggs are ready");

    var bacon = await baconTask;
    Console.WriteLine("bacon is ready");

    var toast = await toastTask;
    Console.WriteLine("toast is ready");

    Juice oj = PourOJ();
    Console.WriteLine("oj is ready");
    Console.WriteLine("Breakfast is ready!");
}

此程式代碼變更說明使用異步程序代碼的重要技術。 您可以透過將作業分離到一個傳回工作的新的方法中,來組織這些工作。 您可以決定何時等待該任務。 您可以同時啟動其他工作。

處理異步例外狀況

此時,您的程式代碼會隱含地假設所有工作都成功完成。 異步方法會擲回例外狀況,就像同步方法一樣。 異步支援例外狀況和錯誤處理的目標與一般異步支援相同。 最佳做法是撰寫類似一系列同步語句的程序代碼。 工作在無法順利完成時擲回例外狀況。 當表達式套用至已啟動的工作時, await 用戶端程式代碼可以攔截這些例外狀況。

在早餐範例中,假設烤箱在烤麵包時起火。 您可以藉由修改 ToastBreadAsync 方法來模擬該問題,以符合下列程式代碼:

private static async Task<Toast> ToastBreadAsync(int slices)
{
    for (int slice = 0; slice < slices; slice++)
    {
        Console.WriteLine("Putting a slice of bread in the toaster");
    }
    Console.WriteLine("Start toasting...");
    await Task.Delay(2000);
    Console.WriteLine("Fire! Toast is ruined!");
    throw new InvalidOperationException("The toaster is on fire");
    await Task.Delay(1000);
    Console.WriteLine("Remove toast from toaster");

    return new Toast();
}

備註

當您編譯此程式碼時,您會看到有關無法到達的程式碼的警告。 此錯誤是設計所致。 在烤麵包機起火後,作業不會正常進行,而且程式會傳回錯誤。

進行程式代碼變更之後,請執行應用程式並檢查輸出:

Pouring coffee
Coffee is ready
Warming the egg pan...
putting 3 slices of bacon in the pan
Cooking first side of bacon...
Putting a slice of bread in the toaster
Putting a slice of bread in the toaster
Start toasting...
Fire! Toast is ruined!
Flipping a slice of bacon
Flipping a slice of bacon
Flipping a slice of bacon
Cooking the second side of bacon...
Cracking 2 eggs
Cooking the eggs ...
Put bacon on plate
Put eggs on plate
Eggs are ready
Bacon is ready
Unhandled exception. System.InvalidOperationException: The toaster is on fire
   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)

請注意,有相當多的工作是在烤麵包機著火和系統觀察到例外情況之間完成的。 當以異步方式執行的工作擲回例外狀況時,該工作 就會發生錯誤Task 物件保存擲回在 Task.Exception 屬性中的例外狀況。 失敗的任務會在 await 表達式套用至任務時擲出例外狀況。

有兩個重要的機制來瞭解此過程:

  • 如何在發生錯誤的任務中儲存例外
  • 當程式代碼在錯誤的工作上等候 (await) 時,如何解除封裝並重新擲回例外狀況

當以異步方式執行的程式代碼擲回例外狀況時,例外狀況會儲存在物件中 Task 。 屬性 Task.ExceptionSystem.AggregateException 物件,因為異步工作期間可能會擲回多個例外狀況。 擲回的任何例外狀況都會新增至 AggregateException.InnerExceptions 集合。 如果Exception屬性為 null,則會建立新的AggregateException物件,且擲回的例外將被加入為集合中的第一個項目。

錯誤工作的最常見案例是 Exception 屬性只包含一個例外狀況。 當程式碼等候發生故障的任務時,它會重新擲出集合中的第一個AggregateException.InnerExceptions例外狀況。 此結果是範例輸出顯示 System.InvalidOperationException 物件而非 AggregateException 物件的原因。 擷取第一個內部的例外會使使用非同步方法盡可能類似於使用其同步對應方法。 當您的案例可能會產生多個例外狀況時,您可以在程式代碼中檢查 Exception 屬性。

小提示

建議的做法是讓任何參數驗證例外狀況從返回任務的方法同步出現。 如需詳細資訊和範例,請參閱 工作傳回方法中的例外狀況

在您繼續下一節之前,請註解掉方法 ToastBreadAsync 中的下列兩個語句。 您不想啟動另一個火災:

Console.WriteLine("Fire! Toast is ruined!");
throw new InvalidOperationException("The toaster is on fire");

有效率地將 await 表達式應用於任務。

您可以使用 await 類別的方法,改善前一個程式代碼結尾的 Task 表達式序列。 其中一個 WhenAll API 是 WhenAll 方法,它會傳回 物件,而當其參數清單中的所有工作都完成時該物件也會完成。 下列程式代碼示範此方法:

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> 物件。 您可以等候傳回的工作,因為您知道工作已完成。 下列程式代碼示範如何使用 WhenAny 方法等候第一個工作完成,然後處理其結果。 處理已完成工作的結果之後,您會從傳遞至 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 方法所 Task.WhenAny 傳回的物件。 Task.WhenAny 方法的結果是已完成(或出錯)的任務。 最佳做法是再次等待任務,即使您知道任務已完成。 如此一來,您就可以擷取工作結果,或確保擲回導致工作發生錯誤的任何例外狀況。

檢閱最終程序代碼

以下是程式代碼的最終版本:

using System;
using System.Collections.Generic;
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 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("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 async Task<Toast> ToastBreadAsync(int slices)
        {
            for (int slice = 0; slice < slices; slice++)
            {
                Console.WriteLine("Putting a slice of bread in the toaster");
            }
            Console.WriteLine("Start toasting...");
            await Task.Delay(3000);
            Console.WriteLine("Remove toast from toaster");

            return new Toast();
        }

        private static async Task<Bacon> FryBaconAsync(int slices)
        {
            Console.WriteLine($"putting {slices} slices of bacon in the pan");
            Console.WriteLine("cooking first side of bacon...");
            await Task.Delay(3000);
            for (int slice = 0; slice < slices; slice++)
            {
                Console.WriteLine("flipping a slice of bacon");
            }
            Console.WriteLine("cooking the second side of bacon...");
            await Task.Delay(3000);
            Console.WriteLine("Put bacon on plate");

            return new Bacon();
        }

        private static async Task<Egg> FryEggsAsync(int howMany)
        {
            Console.WriteLine("Warming the egg pan...");
            await Task.Delay(3000);
            Console.WriteLine($"cracking {howMany} eggs");
            Console.WriteLine("cooking the eggs ...");
            await Task.Delay(3000);
            Console.WriteLine("Put eggs on plate");

            return new Egg();
        }

        private static Coffee PourCoffee()
        {
            Console.WriteLine("Pouring coffee");
            return new Coffee();
        }
    }
}

程序代碼會在大約15分鐘內完成異步早餐工作。 總時間會減少,因為某些工作會同時執行。 程式代碼會同時監視多個工作,並視需要採取動作。

此圖顯示有關準備早餐的指示,包括六個需約 15 分鐘完成的非同步任務,並且程式碼會監視可能的中斷。

最終程式代碼是異步的。 它更準確地反映了一個人如何烹飪早餐。 比較最終程式代碼與文章中的第一個程式代碼範例。 藉由讀取程序代碼,核心動作仍然清楚。 您可以閱讀最終程序代碼,就像閱讀進行早餐的指示清單一樣,如文章開頭所示。 語言特性提供關鍵詞 asyncawait,指導每個人遵循書面指示:儘量啟動任務,並且在等待任務完成時不會被阻塞。

後續步驟