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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// Again here is an async method that returns a Task<string> public async Task<string> CalculateResultAsync(string stringInput) { string stringResult = await Task.Run(() => CalculateComplexOutput(stringInput)); // Return the string result when the processing is completed return stringResult; } // Complex processing, here we just reverse the string input private string CalculateComplexOutput(string stringInput) { StringBuilder sb = new StringBuilder(); for (int i = stringInput.Length - 1; i >= 0; i--) { sb.Append(stringInput[i]); } return sb.ToString(); } |
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.
1 2 3 4 5 6 7 8 9 10 11 |
// Notice that the method is declared as 'async' and returns a Task<string> public async Task<string> DownloadDataAsync() { using (var httpClient = new HttpClient()) { // Use the await keyword to execute a non-blocking GET request string stringResult = await httpClient.GetStringAsync("https://ndepend.com/data"); // Return the result obtained when the request is completed return stringResult; } } |
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 asasync
. - 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 isreturn 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:
123456async Task<string> CallerAsync() {var taskDownloadData = DownloadDataAsync();// do some workstring stringResult = await taskDownloadData;return stringResult;}
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:
1 2 3 4 5 |
Task<string> Caller() { var taskDownloadData = DownloadDataAsync(); // do some work return taskDownloadData; } |
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.
1 2 |
string stringResult = await httpClient.GetStringAsync("https://ndepend.com/data") .ContinueWith(task => CalculateComplexOutput(task.Result)); |
- Awaiting multiple tasks is possible with methods like
Task.WhenAll()
orTask.WhenAny()
. WhileTask.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:
1 2 3 4 5 6 7 8 9 10 11 12 |
var tasks = new Task<string>[] { new HttpClient().GetStringAsync("https://www.google.com/"), new HttpClient().GetStringAsync("https://www.microsoft.com/"), new HttpClient().GetStringAsync("https://www.ndepend.com/") }; await Task.WhenAll(tasks); // Print the size of the webpages Console.WriteLine( $"Home page sizes: {tasks.Select(t => t.Result.Length.ToString()).Aggregate((str1,str2) => str1+","+str2)}"); Console.ReadKey(); |
This program prints:
1 |
Home page sizes: 52891,193871,39755 |
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.
1 2 3 4 5 6 |
var tasks = new Task<string>[] { File.ReadAllTextAsync(@"C:\Program Files\dotnet\dotnet.exe"), File.ReadAllTextAsync(@"C:\Windows\explorer.exe"), File.ReadAllTextAsync(@"C:\Windows\py.exe"), }; ... |
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
andawait
C# keywords are designed to simplify working with both asynchronous CPU-bound and I/O bound jobs.- Asynchronous jobs are represented through
Task
andTask<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 inHttpClient.GetStringAsync()
. - The keyword
await
can only be used in an async method. - The keyword
await
is followed by aTask
orTask<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 ofTask.Run()
to simulate a CPU-bound operation. - Task B is executed synchronously after calling the
async
method.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
class Program { static async Task Main() { ConsoleWriteLine($"Start Program"); Task<int> taskA = MethodAAsync(); for (int i = 0; i < 5; i++) { ConsoleWriteLine($" B{i}"); Task.Delay(50).Wait(); } ConsoleWriteLine("Wait for taskA termination"); await taskA; ConsoleWriteLine($"The result of taskA is {taskA.Result}"); Console.ReadKey(); } static async Task<int> MethodAAsync() { for (int i = 0; i < 5; i++) { ConsoleWriteLine($" A{i}"); await Task.Delay(100); } int result = 123; ConsoleWriteLine($" A returns result {result}"); return result; } // Convenient helper to print colorful threadId on console static void ConsoleWriteLine(string str) { int threadId = Thread.CurrentThread.ManagedThreadId; Console.ForegroundColor = threadId == 1 ? ConsoleColor.White : ConsoleColor.Cyan; Console.WriteLine( $"{str}{new string(' ', 26 - str.Length)} Thread {threadId}"); } } |
Here is the result:
Here are some remarks about the workflow obtained:
- In the async method
MethodAAsync()
, once the keywordawait
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 printsA0
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 anTask<int>
object namedtaskA
. This task represents the remaining course ofMethodAAsync()
that will printA1
,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 noSynchronizationContext
(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 ofMethodAAsync()
.
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:
Consequently, an async
method with no await
keyword is executed synchronously. A warning is emitted in this situation.
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 likeawait Task.Run(() => { ...computation intensive task running on a pool thread... });
. - Or the task might already be running, as in the
await taskA;
in theMain()
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 keywordawait
is the remaining loops and then the code that returns the result. - In the async
Main()
method, the code after theawait
keyword isConsoleWriteLine($"The result of taskA is {taskA.Result}");
followed byConsole.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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
static async Task Main(string[] args) { ConsoleWriteLine($"Start Program"); ... ConsoleWriteLine("Wait for taskA termination"); try { await taskA; ConsoleWriteLine($"The result of taskA is {taskA.Result}"); } catch (ApplicationException ex) { ConsoleWriteLine($"{ex.GetType().ToString()} Msg:{ex.Message}"); } Console.ReadKey(); } static async Task<int> MethodAAsync() { for (int i = 0; i < 5; i++) { ConsoleWriteLine($" A{i}"); await Task.Delay(100); ConsoleWriteLine($" A throws exception"); throw new ApplicationException("Boum"); } int result = 123; ConsoleWriteLine($" A returns result {result}"); return result; } ... |
Here is the output of this program:
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.
1 2 3 4 5 6 7 8 9 |
... ConsoleWriteLine("Wait for taskA termination"); await taskA; Console.WriteLine(new System.Diagnostics.StackTrace()); ConsoleWriteLine($"The result of taskA is {taskA.Result}"); Console.ReadKey(); } |
Here it is:
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:
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
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.
Now let’s execute the exact same code from within a WPF button click event handler.
1 2 3 4 5 6 7 8 |
public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); } private async void Button_Click(object sender, RoutedEventArgs e) { ConsoleWriteLine($"Start Program"); Task<int> taskA = MethodAAsync(); ... |
Before going through the result notice that:
- An asynchronous event handler method like
Button_Click()
does not require theAsync
suffix, like for an asynchronousMain()
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 utilizeTask
orTask<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).
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
.
There is no synchronization context in a console application.
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:
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.
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 anasync void
method, such as the WPF event handlerButton_Click()
defined above, it cannot be caught because there is no caller client code. This is why you should useasync 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
andTask<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 handValueTask<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:
- Task Parallel Library (TPL)
- On replacing Thread.Abort() in .NET Core
- ConfigureAwait FAQ
- Dissecting the async methods in C#
- Writing async/await from scratch in C# with Scott Hanselman and Stephen Toub (1h 06 video)