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

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

這是此語法的目標:啟用類似語句序列讀取的程式碼,但會根據外部資源配置和工作完成時,以更複雜的循序執行。 這類似於人員為包含非同步工作之程序提供指示的方式。 在本文中,您將使用指示範例來瞭解 和 await 關鍵字如何 async 讓您更輕鬆地推斷包含一系列非同步指示的程式碼。 您撰寫了類似下列清單的指示,來說明如何準備早餐:

  1. 倒杯咖啡。
  2. 熱一個平移,然後擷取兩個水。
  3. 煎三片培根。
  4. 烤兩片吐司。
  5. 在吐司塗上奶油和果醬。
  6. 倒杯柳橙汁。

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

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

若要進行平行演算法,您需要多個廚師 (或執行緒)。 一個人會負責煎蛋、一個人會負責煎培根,依此類推。 每個人只會專注於一個工作。 每個 (或執行緒) 會同步封鎖,等候 bacon 準備好翻轉,或快顯快顯通知。

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

重要

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

提示

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

注意

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

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

現在,準備早餐的執行緒在等候任何已開始但尚未完成的工作時,不會遭到封鎖。 對於某些應用程式而言,只需要這項變更。 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 陳述式移至方法結尾,並在供應早餐之前:

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 物件會保存 屬性中擲回的 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);
}

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

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

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

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

後續步驟