myesn

myEsn2E9

hi
github

C#: Asynchronous programming with async and await

Task Asynchronous Programming Model (TAP) provides an abstraction for asynchronous code. We can write code in sequence as usual, where each statement appears to complete before the next one begins**. The compiler performs many transformations, as some of these statements may start work and return a Task that represents the ongoing work.

The goal of this syntax is to make the code read like a series of statements, but the execution order is much more complex, based on external resource allocation and task completion times. This is similar to providing instructions for a process that includes asynchronous tasks. In this article, an example of making breakfast will be used to demonstrate how the async and await keywords make code containing a series of asynchronous instructions easier to understand. We can write instructions like the following list to explain how to make breakfast:

  1. Pour a cup of coffee.
  2. Heat the skillet and fry two eggs.
  3. Fry three slices of bacon.
  4. Toast two slices of bread.
  5. Spread butter and jam on the bread.
  6. Pour a glass of orange juice.

If you have cooking experience, you would execute these instructions asynchronously. You would start heating the skillet to prepare the eggs, then begin frying the bacon. You would put the bread in the toaster and then start cooking the eggs. At each step of the process, you would start a task and then turn to those tasks that are ready for you to handle.

Making breakfast is a great example of asynchronous work rather than parallel work. One person (or thread) can handle all these tasks. Continuing the breakfast metaphor, one person can asynchronously make breakfast by starting the next task before the first one is completed. The cooking process continues regardless of whether someone is watching. Once you start heating the skillet to prepare the eggs, you can start frying the bacon. Once the bacon starts frying, you can put the bread in the toaster.

For parallel algorithms, you need multiple cooks (or threads). One person is responsible for frying the eggs, another for frying the bacon, and so on. Each person focuses on their task. Each cook (or thread) will be blocked synchronously, waiting for the bacon to be flipped or the bread to pop up.

Now, let's consider writing these instructions as C# statements:

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

            Bacon bacon = FryBacon(3);
            Console.WriteLine("Bacon is fried");

            Toast toast = ToastBread(2);
            ApplyButter(toast);
            ApplyJam(toast);
            Console.WriteLine("Bread is toasted");

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

        private static Juice PourOJ()
        {
            Console.WriteLine("Pouring orange juice");
            return new Juice();
![Untitled](ipfs://QmUUPaUCsngMZh4apLSSypnPovBtkymu2qhgSUUwz3JK8K)

        }

        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("Removing bread from the toaster");

            return new Toast();
        }

        private static Bacon FryBacon(int slices)
        {
            Console.WriteLine($"Putting {slices} slices of bacon in the pan");
            Console.WriteLine("Cooking the 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("Putting bacon on the plate");

            return new Bacon();
        }

        private static Egg FryEggs(int howMany)
        {
            Console.WriteLine("Preheating the egg pan...");
            Task.Delay(3000).Wait();
            Console.WriteLine($"Cracking {howMany} eggs");
            Console.WriteLine("Cooking the eggs...");
            Task.Delay(3000).Wait();
            Console.WriteLine("Putting eggs on the plate");

            return new Egg();
        }

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

Untitled

Computers do not interpret these instructions like humans. The computer will block on each statement until the work is completed before continuing to the next statement. This will lead to a long breakfast preparation time, and some food may have already cooled down before being served.

If you want the computer to execute the above instructions asynchronously, you must write asynchronous code.

These issues are crucial for the programs we are writing now. When writing client applications, you want the user interface to be responsive to user input. When downloading data from the web, your application should not cause the phone to freeze. When writing server applications, you do not want threads to be blocked. These threads may be handling other requests. Using synchronous code where asynchronous code is available will affect your ability to scale the application at a lower cost. You will pay the price for these blocked threads.

Successful modern applications require asynchronous code. Writing asynchronous code without language support requires using callbacks, completion events, or other means, which obscures the original intent of the code. The advantage of synchronous code is that it executes step by step, making it easy to scan and understand. Traditional asynchronous models make you focus more on the asynchronous characteristics of the code rather than the fundamental operations of the code.

Don't block, but await#

The previous code demonstrates a bad practice: writing synchronous code to perform asynchronous operations. As written, this code will block the thread executing it from doing any other work. When any task is in progress, it will not be interrupted. It's like you put the bread in the bread machine and stared at the bread machine. You won't pay attention to anyone talking to you until the bread pops up.

Let's first update this code so that the thread does not block while the tasks are running. **await provides a non-blocking way to start tasks and then continue execution after the tasks complete**. A simple asynchronous version of the breakfast code is shown below:

static **async Task** Main(string[] args)
{
    Coffee cup = PourCoffee();
    Console.WriteLine("Coffee is ready");

    Egg eggs = await FryEggsAsync(2);
    Console.WriteLine("Eggs are fried");

    Bacon bacon = await FryBaconAsync(3);
    Console.WriteLine("Bacon is fried");

    Toast toast = await ToastBreadAsync(2);
    ApplyButter(toast);
    ApplyJam(toast);
    Console.WriteLine("Bread is toasted");

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

This code does not block while cooking eggs or bacon. But this code does not start any other tasks. You would still put the slices of bread in the toaster and then stare at it until it pops up. But at least, you would respond to anyone trying to get your attention. In a restaurant with multiple orders, a chef could start another breakfast while cooking the first one.

Now, the thread making breakfast is not blocked while waiting for tasks that have not yet completed. For some applications, just making this change is enough. Graphical user interface applications can become responsive to users with just this change. However, in this case, you need more. You do not want each component task to execute sequentially. It is better to start each component task before waiting for the previous task to complete.

Start tasks concurrently#

In many scenarios, you want to start multiple independent tasks immediately. Then, when each task completes, you can continue with other work that is ready. In the breakfast metaphor, this is how to get breakfast done faster. You can also accomplish everything simultaneously. You will get a hot breakfast.

System.Threading.Tasks.Task and related types are classes you can use to handle ongoing tasks. This allows you to write code that is closer to making breakfast. You will start cooking eggs, bacon, and toasting bread at the same time. When each task needs action, you will turn to that task, handle the next action, and then wait for other tasks that need your attention.

You will start a task and save the Task object that represents that task. You must await before processing the result of each task.

Let's make these changes to the breakfast code. The first step is to store the tasks so that you can operate on them when they start, rather than waiting for them:

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

Task<Egg> eggsTask = FryEggsAsync(2);
Egg eggs = await eggsTask;
Console.WriteLine("Eggs are fried");

Task<Bacon> baconTask = FryBaconAsync(3);
Bacon bacon = await baconTask;
Console.WriteLine("Bacon is fried");

Task<Toast> toastTask = ToastBreadAsync(2);
Toast toast = await toastTask;
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("Bread is toasted");

Juice oj = PourOJ();
Console.WriteLine("Orange juice is poured");
Console.WriteLine("Breakfast is ready!");

The above code does not make breakfast ready faster. The tasks are immediately await after starting. Next, you can move the await statements for bacon and eggs to the end of the method, waiting until just before serving:

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("Bread is toasted");
Juice oj = PourOJ();
Console.WriteLine("Orange juice is poured");

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

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

Untitled 1

This time, making breakfast asynchronously took about 20 minutes, and the time saved is because some tasks are running concurrently.

The previous code performs better. You start all asynchronous tasks at once. You only wait for each task when you need the result. The previous code might resemble code in a web application that sends requests to different microservices and combines the results into a single page. You would issue all requests immediately, then wait for all tasks to complete and combine the web page.

Composition with tasks#

Everything for breakfast is ready at the same time except for the toast. Making toast involves an asynchronous operation (toasting) and synchronous operations (adding butter and jam). Updating this code can illustrate an important concept:

The previous code shows that you can use Task or Task objects to hold ongoing tasks. You need to await the completion of each task before using the result. The next step is to create methods that represent the combination of other work. Before serving breakfast, you need to wait for the toasting task to complete before adding butter and jam. This work can be represented with the following code:

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

    return toast;
}

The above method has the async modifier in its signature. This signals to the compiler that the method contains an await statement; it contains asynchronous operations. This method represents the task of toasting bread, adding butter, and jam. The method returns a Task<TResult that represents the combination of these three operations. Now the main code block becomes:

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

Task<Egg> eggsTask = FryEggsAsync(2);
Task<Bacon> baconTask = FryBaconAsync(3);
Task<Toast> toastTask = MakeToastWithButterAndJamAsync(2);

Egg eggs = await eggsTask;
Console.WriteLine("Eggs are fried");

Bacon bacon = await baconTask;
Console.WriteLine("Bacon is fried");

Toast toast = await toastTask;
Console.WriteLine("Bread is toasted");

Juice oj = PourOJ();
Console.WriteLine("Orange juice is poured");
Console.WriteLine("Breakfast is ready!");

The above changes demonstrate an important technique for handling asynchronous code. By separating operations into a new method and returning a task to compose tasks, you can choose when to await that task while also starting other tasks concurrently.

Asynchronous exceptions#

So far, you have assumed that all these tasks will complete successfully. Asynchronous methods can throw exceptions just like synchronous methods. The goal of asynchronous exceptions and error handling is the same as general asynchronous support: the code you write should read like a series of synchronous statements. When tasks cannot complete successfully, they throw exceptions. When awaiting a task that has started, client code can catch these exceptions. For example, suppose the toaster catches fire while making toast. You can simulate this situation by modifying the ToastBreadAsync method as follows:

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! The toast is ruined!");
    throw new InvalidOperationException("The toaster caught fire");
    await Task.Delay(1000);
    Console.WriteLine("Removing bread from the toaster");

    return new Toast();
}

After making these changes and running the application, you will see output similar to the following text:

....
Bacon is fried
Unhandled exception. System.InvalidOperationException: The toaster caught 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)

You will notice that many tasks have already completed between the toaster catching fire and the exception being observed. When a task running asynchronously throws an exception, that task becomes faulted. The task object will store the thrown exception in the Task.Exception property. A faulted task will throw an exception when awaited.

There are two important mechanisms to understand: how exceptions are stored in faulted tasks and how code unpacks and rethrows exceptions when awaiting faulted tasks.

When code running asynchronously throws an exception, the exception is stored in the Task. The Task.Exception property is an instance of System.AggregateException, as multiple exceptions may be thrown during asynchronous work. Any thrown exceptions are added to the AggregateException.InnerExceptions collection. If the Exception property is null, a new AggregateException is created, and the thrown exception becomes the first item in the collection.

The most common case for a faulted task is that the Exception property contains exactly one exception. When the code awaits a faulted task, the first exception in the AggregateException.InnerExceptions collection is rethrown. This is why the output in this example shows InvalidOperationException instead of AggregateException. Extracting the first inner exception allows asynchronous methods to work as similarly as possible to synchronous methods. When your use case may produce multiple exceptions, you can check the Exception property in your code.

Before proceeding, comment out these two lines in the ToastBreadAsync method. You do not want to start a fire again:

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

Await tasks efficiently#

In the previous code, you can improve a series of await statements by using methods from the Task class. One of the APIs is WhenAll, which returns a Task that completes when all tasks in the parameter list are complete, as shown in the following code:

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

Another method is to use WhenAny, which returns a Task<Task> that completes when any of its parameters complete. You can await the returned Task until it completes. The following code demonstrates how to use WhenAny to wait for the first completed Task and then handle its result. After processing the result of the completed task, you can remove that completed task from the list of tasks passed to 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);
}

Near the end, you will see a line of code await finishedTask; because await Task.WhenAny does not wait for the completed task. It waits for the Task.WhenAny to return. The result of Task.WhenAny is the task that has completed (or faulted). You should await that task again, even if you know it has already run. This is necessary to obtain its result or ensure that any exceptions that caused it to fault are thrown.

After all these changes, the final version of the code is as follows:

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("Orange juice 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("Removing bread from the 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("Putting 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("Putting eggs on plate");

            return new Egg();
        }

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

Untitled 2

The final version of the asynchronous breakfast preparation took about 15 minutes because some tasks ran in parallel, and the code could monitor multiple tasks simultaneously, only taking action when needed.

This final version of the code is asynchronous. It more accurately reflects how a person makes breakfast. Compare the previous code with the first code example at the beginning of this article. The core operations are still clearly visible in the code. You can read this code as you would read the instructions about making breakfast at the beginning of this article. The language features of async and await provide a translation method for everyone to follow written instructions: start tasks as much as possible and do not block while waiting for tasks to complete.

References#

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.