使用 async 和 await 進行非同步程式設計

非同步工作程式設計模型 (TAP) 在非同步程式碼上提供一個抽象概念。 您可以和往常一樣,將程式碼撰寫成一連串的陳述式。 您可以將該程式碼讀成每個陳述式會先完成,再開始下一個陳述式。 編譯器會執行許多轉換工作,因為部分陳述式可能會開始運作,並傳回 Task 來代表進行中的工作。

這是此語法的目標:讓程式碼讀起來就像一連串的陳述式,但會根據外部資源配置和工作完成時間,以比較複雜的順序執行。 這類似於人員為包含非同步工作之程序提供指示的方式。 在本文中,您將以準備早餐的指示為例,來了解 asyncawait 關鍵字如何讓您更輕鬆地理解包含一連串非同步指示的程式碼。 您撰寫了類似下列清單的指示,來說明如何準備早餐:

  1. 倒杯咖啡。
  2. 熱鍋,然後煎兩顆蛋。
  3. 煎三片培根。
  4. 烤兩片吐司。
  5. 在吐司塗上奶油和果醬。
  6. 倒杯柳橙汁。

如果您有烹飪經驗,您會非同步地執行這些指示。 您會從為雞蛋熱鍋開始,然後開始煎培根。 等到將麵包放入烤麵包機,再開始煎蛋。 在程序的每個步驟,您會開始一個工作,然後將注意轉移到其他需要您注意的工作。

準備早餐很適合用來示範非平行的非同步工作。 一個人 (或執行緒) 可處理所有這些工作。 繼續以早餐為例,一位人員可以透過非同步方式準備早餐,不需等到第一個工作完成,就可開始下一個工作。 不論是否有旁觀者,烹飪都會進行。 開始為雞蛋熱鍋之後,您可以開始煎培根。 等到開始煎培根,您可以將麵包放入烤麵包機。

若要進行平行演算法,您需要多個廚師 (或執行緒)。 一個人會負責煎蛋、一個人會負責煎培根,依此類推。 每個人只會專注於一個工作。 每位廚師 (或幫廚) 禁止同步等候培根準備好翻面,或等候吐司從烤麵包機彈出。

現在,考慮將這些相同指示撰寫成 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();
        }
    }
}

synchronous breakfast

同步準備早餐大約需要 30 分鐘,因為總時長是每個工作時間的總和。

電腦解譯這些指示的方式與人類不同。 電腦會封鎖每個陳述式,直到工作完成為止,再繼續下一個陳述式。 這會導致早餐無法令人滿意。 後續工作必須等到先前工作完成才能開始。 這會花更長的時間來準備早餐,且在上菜前,有些菜可能會變涼。

如果您想要電腦以非同步方式執行上述指示,您必須撰寫非同步程式碼。

這些考量對您現今撰寫的程式很重要。 當您撰寫用戶端程式時,您想要 UI 可以回應使用者輸入。 您的應用程式不應該讓手機在從網路下載資料時呈現凍結。 當您撰寫伺服器程式時,您不想要執行緒被封鎖。 這些執行緒可能會服務其他要求。 在存在替代的非同步程式碼時使用同步程式碼,會導致您無法以較不耗費成本的方式擴充。 這些封鎖的執行緒會耗費成本。

現代化應用程式需要非同步程式碼才能成功。 由於沒有語言支援,撰寫非同步程式碼需要回呼、完成事件,或隱藏程式碼原本意圖的其他方式。 同步程式碼的優點是其逐步動作,可讓您輕鬆瀏覽並了解其中內容。 傳統非同步模型迫使您將重點放在程式碼的非同步本質,而不是程式碼的基本動作。

不要封鎖,而是等候

上述程式碼示範不正確的做法:建構同步程式碼來執行非同步作業。 如內容所指,此程式碼會防止執行緒執行任何其他工作。 在任何工作進行時,它不會遭到中斷。 就像是將麵包放入烤麵包機之後,直盯著烤麵包機。 在吐司彈出之前,您不會理會任何人對您說的話。

我們將從更新此程式碼開始,讓執行緒在工作執行時不會遭到封鎖。 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!");
}

重要

耗用的總時間大致上與初始同步版本相同。 程式碼尚未利用非同步程式設計的某些主要功能。

提示

FryEggsAsyncFryBaconAsyncToastBreadAsync 的方法主體都已更新,以分別傳回 Task<Egg>Task<Bacon>Task<Toast>。 方法會從其原始版本重新命名,以包含 "Async" 尾碼。 其實作會在本文稍後的最終版本部分顯示。

注意

Main 方法會傳回 Task,儘管沒有 return 運算式,但這是原來的設計。 如需詳細資訊,請參閱傳回空值非同步函式的評估

此程式碼不會在煎蛋或煎培根時封鎖其他工作。 但此程式碼也不會開始任何其他工作。 您仍會將吐司放入烤麵包機,並在彈出前直盯著它瞧。 但至少,您會回應任何需要您注意的人。 在點了多份早餐的餐廳中,廚師可能會在第一份早餐還在準備時,就開始準備另一份早餐。

現在,準備早餐的執行緒在等候任何已開始但尚未完成的工作時,不會遭到封鎖。 對於某些應用程式而言,只需要這項變更。 GUI 應用程式仍會只以這項變更來回應使用者。 不過在此案例中,您需要不只一項變更。 您不想要循序執行每個元件工作。 較好的做法是開始每個元件工作,然後等候先前的工作完成。

同時開始工作

在許多情況下,您想要立即開始數個獨立工作。 然後,在每個工作完成時,您可以繼續其他準備好的工作。 以早餐為例,這會讓您更快速地完成早餐。 您也會幾乎同時完成所有工作。 因此,您會有熱騰騰的早餐。

System.Threading.Tasks.Task 和相關類型是您可以用來理解進行中工作的類別。 這可讓您撰寫出更類似於早餐準備方式的程式碼。 您會同時開始煎蛋、煎培根和烤吐司。 由於每個工作都需要執行動作,因此您會將注意力轉移到該工作、處理下一個動作,然後等候其他需要您留意的項目。

您會開始一個工作,並保存表示該工作的 Task 物件。 您會 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!");

asynchronous breakfast

非同步準備早餐大約需要 20 分鐘,其中所節省的時間是因為有些工作會同時執行。

上述程式碼的效果更好。 您會同時開始所有非同步工作。 只有需要結果時,才會等候每個工作。 上述程式碼可能會類似於提出不同微服務要求,然後將結果合併成單一頁面的 Web 應用程式程式碼。 您會立即提出所有要求,然後 await 所有這些工作並撰寫網頁。

工作組合

您同時準備好早餐的每道菜,除吐司以外。 準備吐司是非同步作業 (烤土司) 以及同步作業 (塗上奶油和果醬) 的組合。 更新此程式碼說明一個重要概念:

重要

非同步作業後面接著同步工作的組合會是非同步作業。 換句話說,如果作業有任何部分為非同步,則整個作業是非同步。

上述程式碼顯示您可以使用 TaskTask<TResult> 物件來保存執行中的工作。 您會 await 每個工作,再使用其結果。 下一個步驟是建立表示其他工作組合的方法。 在供應早餐之前,您想要等候表示烤土司後再塗上奶油和果醬的工作。 您可以使用下列程式碼來表示該工作:

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

    return toast;
}

上述方法的簽章中有 async 修飾詞。 這會通知編譯器,此方法包含 await 陳述式,其中包含非同步作業。 此方法表示烤土司後再塗上奶油和果醬的工作。 此方法會傳回 Task<TResult>,表示這三項作業的組合。 程式碼的 Main 區塊現在會變成:

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!");
}

上述變更說明使用非同步程式碼的重要技術。 您可以透過分隔作業,將多個工作組合成傳回一個工作的新方法。 您可以選擇何時等候該工作。 您可以同時開始其他工作。

非同步例外狀況

到目前為止,您已隱含假設所有這些工作都會成功完成。 非同步方法會擲回例外狀況,正如同其同步對應項目。 例外狀況和錯誤處理的非同步支援會努力達成與一般非同步支援相同的目標:您應該撰寫類似一系列同步陳述式的程式碼。 當工作無法成功完成時,會擲回例外狀況。 當啟動的工作為 awaited,用戶端程式代碼可以攔截這些例外狀況。 例如,假設在烤土司時,烤麵包機著火了。 您可以模擬該情況,藉由修改 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.Exception 屬性中保存擲回的例外狀況。 發生錯誤的工作會在等候時擲回例外狀況。

請了解以下兩個重要機制:例外狀況如何儲存在發生錯誤的工作中,以及當程式碼等候發生錯誤的工作時,應如何將例外狀況解除封裝並重新擲回。

當非同步執行的程式碼擲回例外狀況,該例外狀況會儲存在 Task 中。 Task.Exception 屬性是 System.AggregateException,因為非同步工作期間可能會擲回多個例外狀況。 任何擲回的例外狀況都會新增至 AggregateException.InnerExceptions 集合。 如果該 Exception 屬性為 Null,則會建立新的 AggregateException,且擲回的例外狀況是集合中的第一個項目。

發生錯誤工作的最常見案例是 Exception 屬性只包含一個例外狀況。 當程式碼 awaits 錯誤的工作時,系統會重新擲回 AggregateException.InnerExceptions 集合中的第一個例外狀況。 這就是為什麼此範例的輸出會顯示 InvalidOperationException,而不是 AggregateException。 擷取第一個內部例外狀況會讓使用非同步方法與使用其同步對應項目盡可能類似。 若案例可能產生多個例外狀況,您可以檢查程式碼中的 Exception 屬性。

提示

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

開始之前,請在 ToastBreadAsync 方法中註解化這兩行。 您不想引發另一場火災:

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

有效率地等候工作

上述程式碼結尾的一連串 await 陳述式,可以透過 Task 類別的方法來改善。 其中一個 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>。 您可以等候傳回的工作,並知道它已完成。 下列程式碼範例示範如何使用 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 程式碼不會等候已完成的工作。 其會 awaitTask.WhenAny 所傳回的 TaskTask.WhenAny 結果是已完成 (或發生錯誤) 的工作。 即使知道工作已執行完畢,您仍應該再次 await 該工作。 這就是您擷取其結果的方式,或確保可擲回發生錯誤的例外狀況。

完成上述所有變更之後,程式碼的最終版本看起來像這樣:

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();
        }
    }
}

when any async breakfast

非同步準備早餐的最終版本大約需要 6 分鐘,因為有些工作會同時執行,而程式碼會一次監視多個工作,並且只在需要時採取動作。

此最終程式碼為非同步。 它會更精確地反映人員準備早餐的方式。 將上述程式碼與本文中的第一個程式碼範例做比較。 閱讀程式碼仍會清楚了解核心動作。 閱讀此程式碼的方式,如同閱讀本文開頭準備早餐的指示。 asyncawait 之語言功能為所有遵循下列書面指示的人員提供轉譯:盡可能開始多個工作且不要防止等候工作完成。

下一步