NDepend Blog

Improve your .NET code quality with NDepend

C# Async Await Explained

April 16, 2024 9 minutes read

C# async await explained

If your application involves I/O-bound operations like network requests, database access, or file system reads and writes, asynchronous programming is essential. Similarly, for CPU-bound tasks such as complex calculations, asynchronous programming is beneficial.

C# supports asynchronous programming at the language level since C# version 5 (released in 2012). This built-in support simplifies the development of asynchronous code without the need for managing callbacks or adhering to a specific library for asynchrony.

In C# only the two keywords async and await are required to perform asynchronous programming:

  • A method can be marked with the keyword async to indicate it executes non-blocking operations.
  • The keyword await in an async method causes it to return a task representing the remaining code to execute until completion.

In this article, we’ll start by exploring C# asynchronous programming with the keywords async and await, followed by a detailed explanation of its workflow. Let’s dive in!

Understand C# Asynchronous Programming with async and await

In .NET and C#, asynchronous programming revolves around Task and Task<T> objects. async and await keywords are designed to simplify task management in a wide range of scenarios:

CPU-Bound

To execute a CPU-bound operation on a background thread, you initiate it using the Task.Run() method. This method returns a task object that can be awaited through the await C# keyword.

I/O-Bound

The keywords async and await aren’t limited to just CPU-bound operations. They also encompass I/O-bound operations like reading file content and downloading a resource from the web. In this context, the await keyword is used to pause the execution while waiting for a method marked as async that returns a task.

What happens at runtime?

In both examples:

  • There is a time-consuming job to obtain a string result to wait for.
  • This job is started via a call prefixed with the await keyword within a method marked as async.
  • The thread that processes the job is a runtime implementation detail. It can be the current thread or a runtime pool thread.
  • The code after the await statement – which is return stringResult; here – is resumed once the time-consuming job ends.
  • The async method returns immediately once the await keyword is met. This is why asynchronous methods are said to be non-blocking:

The caller’s point of view

The non-blocking async method returns a Task<string> object. If the caller method is marked with async it can choose to:

  • await for this task to finish like in: string stringResult = await DownloadDataAsync();
  • to gather the task, do some work and await for the task later like in:

The caller might not be an asynchronous method. It can just return the task to its own caller who will be responsible for awaiting it:

How do C# keywords async and await work?

The C# compiler converts the usage of the keyword await into a state machine. This state machine calls Task<T> based APIs to manage various aspects like:

  • suspending execution when encountering an await operation
  • executing the CPU-Bound or I/O-bound task, eventually on a background thread
  • resuming execution of instructions after the await keyword once the task has been completed

.NET Task<T> Library

The .NET Base Class Library provides numerous APIs to work with tasks:

  • Task continuations provide a means to chain additional job(s) once a task finishes its execution. Task continuation is achieved through the ContinueWith() method designed to model a sequence of asynchronous operations.

  • Awaiting multiple tasks is possible with methods like Task.WhenAll() or Task.WhenAny(). While Task.WhenAll() let’s await the completion of all tasks executed concurrently to consolidate their results, Task.WhenAny() is designed to obtain the result of the task that completes first.
  •  Cancellation of a task on time-out for example is also possible as we explain in this blog post: On replacing Thread.Abort() in .NET Core

The plethora of asynchronous I/O Bound .NET APIs

The .NET Base Class Library .NET offers hundreds of asynchronous methods to achieve all sorts of I/O tasks including network access, database access, JSON, XML, binary serialization… file access, data compression, and more.

Here is a small example where we gather 3 website home pages to print their sizes in bytes on the console:

This program prints:

Notice that this program relies on C# top-level statement that works fine with the await keyword. It would be easy to modify this program for example to read asynchronously some file content.

C# Asynchronous Programming: Points to keep in mind

Before diving deeper into more understanding of how the await keyword impacts the workflow, bear in mind that:

  • async and await C# keywords are designed to simplify working with both asynchronous CPU-bound and I/O bound jobs.
  • Asynchronous jobs are represented through Task and Task<T> objects.
  • The async keyword marks a method as asynchronous. A call to such a method is non-blocking and can enable a responsive UI for example.
  • By convention, we suffix an async method with the Async suffix like in HttpClient.GetStringAsync().
  • The keyword await can only be used in an async method.
  • The keyword await is followed by a Task or Task<TResult> object.
  • When encountering the await keyword, the C# compiler generates a state machine to suspend the execution, triggers the asynchronous task, and resumes the execution upon task completion.

Understanding How C# Keywords await and async Impact the Workflow

Let’s illustrate the async and await workflow with the following small C# program below. Two tasks A and B run simultaneously.

  • Task A runs within a method marked as async. Task.Delay() is used instead of Task.Run() to simulate a CPU-bound operation.
  • Task B is executed synchronously after calling the async method.

Here is the result:

C# async await on console

Here are some remarks about the workflow obtained:

  • In the async method MethodAAsync(), once the keyword await is met for the first time the remaining code in the method is actually executed by some threads from the runtime thread pool.
  • As a consequence, the call to the async method MethodAAsync() is not blocking the main thread. First, it prints A0 on the console and then returns to run task B synchronously while task A continues on some background threads.
  • This is why the async method MethodAAsync() returns an Task<int> object named taskA. This task represents the remaining course of MethodAAsync() that will print A1, A2, A3, A4 and then returns an integer result.
  • Thread #1 and then threads #4 and #7 are involved to run task A. Each time the keyword await is executed, one cannot predict the pool thread that will be used to run the code remaining. The way how the runtime chooses the thread is an implementation detail. Keep in mind that this behavior results from running within a console application context where there is no SynchronizationContext (this will be explained in a later section).
  • Similarly, in the main method, the code after await taskA;  is executed on a random pool thread. Here it appears to be the same thread that executed the last part of MethodAAsync().

First, let’s explain the easy role of the async keyword. Then we’ll have a closer look at the influence of the await keyword.

The easy role of the async keyword

Focus your attention on the keyword await because the keyword async is just here to decorate a method. This keyword tells the C# compiler that this method contains at least one await keyword. The C# compiler could be smart enough to detect that a method contains the await keyword. However async was introduced both for readability and for backward compatibility to avoid breaking existing code that uses await as a variable name:

C# async keyword just a decorator

Consequently, an async method with no await keyword is executed synchronously. A warning is emitted in this situation.

C# async method with no await is synchronous

From now keep in mind that the keyword async is just a decorator that tells the C# compiler that the method contains at least one occurrence of the await keyword. By the way, since the main method also contains the await keyword it must also be declared as async and also returns a Task.

Additionally, let’s note that a main method can be declared as async since C# 7.1. In this situation, the main method name is Main() and doesn’t have the suffix Async.

Explaining the workflow resulting from the await keyword

In the program above there are two occurrences of the keyword await, in the Main() method and in the MethodAAsync() method. To understand the await workflow there are 3 points to carefully take into account:

A) The caller’s point of view:

Once the keyword await is met for the first time in an async method, the currently executing thread immediately returns. The caller doesn’t get a result but instead obtains a promise of a result, which is the Task<TResult> object returned by the async method. The caller’s thread can do some work (task B here) and then await the task later when it finally needs the result. By the way, the similar javascript construct is called a promise.

B) The awaited asynchronous task:

The keyword await is followed by a task object, that is not the task returned by the async method. Notice that:

  • The task might be started at that point as in await Task.Delay(100); that simulates a CPU-bound task. It could be replaced with something like await Task.Run(() => { ...computation intensive task running on a pool thread... });.
  • Or the task might already be running, as in the await taskA; in the Main() method.

C) The task returned by the async method is the code remaining once the awaited task terminates:

The beauty is that the keyword await doesn’t lead to any wasted thread awaiting the task’s end. When the task finishes (eventually with a result in the case of Task<TResult>) the infrastructure behind the await keyword chooses a thread to resume the remaining code in the async method that is after the keyword await. This remaining code to run is nested within a task object. This is the task object returned by the async method.

  • In the async method MethodAAsync() the code after the keyword await is the remaining loops and then the code that returns the result.
  • In the async Main() method, the code after the await keyword is ConsoleWriteLine($"The result of taskA is {taskA.Result}"); followed by Console.ReadKey();.

How many tasks are involved?

One key point not often understood is that there are at least 2 tasks involved in an async method:

  • The task that follows the await keyword that runs the CPU bound or I/O bound code.
  • The task returned by the async method that represents the remaining code to run upon the awaited task termination.

Creating tasks within a loop

In fact, in this short program above, there are many more than 2 tasks involved at runtime! These few lines of code are more subtle than they look. Indeed in MethodAAsync(), the keyword await is met in each loop and each time await Task.Delay(100); simulates a new task. As a consequence at each loop, a new task is created to run the remaining code once the task Task.Delay(100); terminates. So taskA returned by MethodAAsync() is actually a chain of tasks executed sequentially and each loop is executed by a thread chosen randomly. We can see in the console output that the pool threads with IDs 7 and 4 are involved in running sub-tasks of taskA. Notice that the first loop that prints A0 executed by the main thread is not a part of taskA.

Exception Handling in a C# Asynchronous Workflow

Let’s underline that the keyword await works as expected when an exception is thrown from an asynchronous processing.

Here is the output of this program:

C# async await and exception

On the other hand if the line await taskA; within the try { ... } catch scope is replaced with the line taskA.Wait();, the exception is not handled by the catch clause. This unexpected behavior illustrates well that when doing asynchronous programming, the keyword await should be the preferred way to await asynchronous methods.

Decompiling the Magic Behind the C# Keyword await

Now that we detailed the await keyword workflow we can measure how powerful it is. Some magic does occur under the hood to resume the execution once the task finishes. Let’s have a look at the thread stack trace after await taskA; in the main method.

Here it is:

C# await stack trace

The simple line await taskA; leads the C# compiler to generate a lot of code to pilot the runtime through the .NET task library. Methods named like AsyncState... and MoveNext() are parts of the state machine created for us by the C# compiler to implement task continuation. Here is the assembly content decompiled with the .NET decompiler ILSpy. We can see that a whole class is generated by the compiler for each usage of the await keyword:

ILSpy C# async await

Here is a call graph generated by NDepend of the methods of the Task Parallel Library (TPL) called by the generated code. To obtain such a graph with methods and fields generated by the compiler, the following setting must be disabled first: NDepend > Project Properties > Analysis > Merge Code Generated by Compiler into Application Code

C# async await TPL methods call graph

The details of what the C# compiler generates when it meets the keyword await is outside the scope of this article. For an in-depth exploration, you can refer to this Microsoft article: Dissecting the async methods in C#. For now bear in mind that:

  • the code executed after the keyword await can eventually be executed by a thread chosen randomly by the runtime
  • that a lot of code that calls the TPL is generated by the C# compiler to make this happen.

Now let’s explain how the tasks get dispatched through threads chosen by the runtime. This choice depends on the type of application.

The Synchronization Context

So far we only demonstrated code executed in the context of a console application. The context in which some asynchronous code runs actually influences its workflow a lot. For example, let’s run the same code in the context of a WPF application. Since it is convenient to keep the console output to show the results of our experiments, let’s set the output type of our WPF project to Console Application, so a console is shown when the WPF app starts.

WPF project output console application

Now let’s execute the exact same code from within a WPF button click event handler.

Before going through the result notice that:

  • An asynchronous event handler method like Button_Click() does not require the Async suffix, like for an asynchronous Main() method as explained above.
  • Using async void like in the code sample above should be limited to asynchronous event handlers. Indeed event handlers lack return types and therefore cannot utilize Task or Task<T>.

Here is the surprising result: there is no background thread involved, the main thread is used to run all tasks sequentially! Also, task A loops are postponed after task B loops (except the first one).

WPF C# async await SynchronizationContext

This mono-thread result is totally different than what we had with our console application. The key is that in a WPF context (and also in a Winforms context) there is a synchronization context object, that can be obtained through SynchronizationContext.Current.

WPF SynchronizationContext

There is no synchronization context in a console application.

Console SynchronizationContext

Asynchronous programming doesn’t necessarily require multi-threading at runtime. This is well illustrated by the code above.

The WPF and Winforms SynchronizationContext behavior

In the precedent WPF execution there is no background thread involved because there is no real asynchronous processing: remember we rely on await Task.Delay(100); to simulate it. Here is the output if we do some real processing instead:

WPF C# await real processing

Why do we need SynchronizationContext in WPF and Winforms scenarios?

In WPF there is a main UI thread that manages the UI (and a hidden thread that does the rendering). In Winforms, there is also a UI thread that does both the managing of controls and the rendering. When the UI thread gets too busy, the UI becomes unresponsive and the user gets nervous. This is why in both cases it is essential to run computation-intensive tasks on a pool thread and not on the UI thread. This is why both WPF and Winforms have their own synchronization contexts. So the runtime can resume by default on the UI thread to harness the result of an asynchronous operation that just terminated. Typically the result is used to refresh some controls with data obtained from the async job. To do so, these synchronization contexts rely on the internal infrastructure of the WPF and the Winforms platforms.

What is the runtime workflow in both WPF examples above?

In both WPF results above, we can see that A0 is displayed and then task B is running entirely from B0 to B4. Then task A can resume from A1 to A4. Remember that in task B we have Task.Delay(50).Wait(); that first simulate a task and then wait for its termination. This is a blocking call equivalent to Thread.Sleep(50); unlike await Task.Delay(100) in task A that is not blocking. This means that the UI thread is kept busy with the task B until it finishes. Only upon task B termination, the UI thread becomes available again and the WPF synchronization context can resume task A on it.

Disabling the WPF and Winforms SynchronizationContext behavior with task.ConfigureAwait(false)

This WPF and Winforms asynchronous contexts’ default behavior of resuming on the main UI thread after an asynchronous call can be discarded by calling the method ConfigureAwait(false) on the task in the await call. The value false is set to the parameter ConfigureAwait(bool continueOnCapturedContext). By default, this well-named parameter is set to true. With ConfigureAwait(false) called in a WPF or Winforms context, we go back to the console behavior where a random thread from the pool is chosen by the runtime to resume after the await call.

In the execution result below, only the await usage in the method MethodAAsync() is performed with ConfigureAwait(false), not the await usage in the Button_Click() method. This is why the main thread is used to print  "The result of taskA is 123", because of the WPF synchronization context behavior that is enabled here.

WPF C# async await ConfigureAwait

No SynchronizationContext in ASP.NET Core

Let’s notice that there is no synchronization context within an ASP.NET Core application. This was an important change because ASP.NET had an AspNetSynchronizationContext as discussed in this stackoverflow Q/A. On his blog, Stephen Cleary explains that the decision to discard AspNetSynchronizationContext was taken to obtain more simplicity and performance.

Finally let’s note that you can create custom synchronization context as explained on this github page, although you won’t likely do so. For a detailed explanation of ConfigureAwait(false)  you can refer to this Microsoft article: ConfigureAwait FAQ

Guidelines for Using the C# Keywords async and await

  • Asynchronous task should be pure: A pure method depends solely on its inputs to produce its output. Pure methods are inherently thread-safe because they don’t rely on shared state nor mutate data. Furthermore, it is easier to reason about pure methods because they have no side effects.
  • Avoid async void methods: async void is dangerous. In the case of an exception being raised from an async void method, such as the WPF event handler Button_Click() defined above, it cannot be caught because there is no caller client code. This is why you should use async void with great caution.
  • Async Suffix: Append the Async suffix to the names of your asynchronous methods. This is a common convention in .NET that helps distinguish between synchronous and asynchronous methods.
  • Consider using ValueTask<TResult> for better performance: Task and Task<T> are classes. As a consequence returning multiple task objects from async methods can lead to performance issues due to object allocations, especially in tight loops or when synchronous results are involved. On the other hand ValueTask<TResult> is a structure and doesn’t lead to object allocation.
  • Error Handling: Always handle exceptions with try..catch blocks in asynchronous methods to prevent uncaught exceptions.
  • Avoid Excessive Parallelism: Be cautious not to create excessive parallelism with async operations. Too many concurrent asynchronous operations can lead to resource exhaustion.
  • Use Cancellation Tokens: Consider using cancellation tokens to allow users to cancel long-running asynchronous operations.
  • Avoid Mixing Synchronous and Asynchronous Code: Minimize mixing synchronous and asynchronous code in the same method to maintain code clarity and consistency.

Conclusion

In this article, we focused on the C# async and await keywords and artifacts that can influence their behavior like the synchronization context or exception.

Hopefully, the asynchronous workflow obtained through the keyword await is now less mysterious to you. The await keyword leads to a lot of code generated by the C# compiler to achieve this workflow. In contrast, the async keyword primarily marks a method as asynchronous without introducing complexities similar to await.

Here are some resources to go further: