I often see code that shares some fundamental problems with how the await operator has been applied to asynchronous method calls in C#.
This article aims to demystify asynchronous operations. To dispel some of the myths about async code, and to correct some of the invalid assumptions you may have when writing async code. I will do this by first explaining some of the fundamentals of async operations in C#. Then I will apply a scientific approach by creating some testable code, running those tests, and proving the best approach in a variety of scenarios.
So let's get started.
What is Asynchronous Programming?
It makes sense at this point to discuss what asynchronous programming actually is. To do that it is easiest to think about the problems it tries to solve.
1. Allowing a processor's time to be shared amongst multiple threads.
If an application has been written without asynchronous code, the application can be thought of as consisting of a single thread. The underlying OS may rapidly switch application threads to give the illusion of multitasking between applications (or provide actual multi-tasking in the case of multiple CPU cores). However, since our application is a single indivisible thread, the application itself must execute sequentially. This can pose problems where, for example, the UI can appear unresponsive when it is awaiting the execution of underlying services. Asynchronous programming attempts to solve this by splitting the application into multiple threads so that the CPU can thread them individually.
2. Allowing dependant threads to wait for their dependencies to become available.
Splitting an application into multiple threads can cause errors in an application if you have no way to indicate that a particular thread is dependent on the result of another executing thread. The CPU could place both into an executing state and the dependant thread would error without the results it requires. Therefore, asynchronous programming must provide some means to signal these dependencies and stop a thread from being executed until the desired threads have run to completion.
3. Allowing multiple threads to execute simultaneously. Additionally, there are times when two threads are not dependent on one another. To take advantage of multiple CPU cores, these must be able to be executed concurrently on separate cores.
How does C# handle Asynchronous Programming?
C# provides some language-level constructs for enabling asynchronous programming within an application. As of C# 5, developers can mark a method as async
to indicate that some part of it can execute asynchronously. Additionally, the await
keyword will pause a thread until the function on the RHS completes its execution.
These language level constructs, when paired with the .NET provided Task
objects, are all you need to enable the preceding 3 goals of asynchronous programming. Collectively, they allow us to apply the Task-Based Asynchronous Pattern or TAP for short.
What could go wrong?
Whenever a language feature is provided, there is an opportunity for developers to abuse it. Most of the time, this is borne out of a misunderstanding as to what the correct usage is. This appears to be the case with most of the problems I see with async / await operations.
Developers would do well to remember these key concepts:
Awaiting just waits.
This seems simple on the surface, you are indicating that this thread needs to await the result of the RHS of the await
operator. But this is the most common mistake I see, developers use the await
keyword without spawning a Task
in advance.
This is the same as trying to save time by asking another developer to write some code for you, but then waiting for them to finish before continuing.
To demonstrate this, I will use the following functions.
private static string SayWithDelay(string word, int delay, Stopwatch stopwatch)
{
if (delay < 1) throw new ArgumentException($"Delay must be greater than 0!");
Console.WriteLine($"Starting to say {word}");
Task.Delay(delay).Wait();
Console.WriteLine($"Finished saying {word} @ {stopwatch.Elapsed.AsElapsedString()}");
return word;
}
private static async Task<string> SayWithDelayAsync(string word, int delay, Stopwatch stopwatch)
{
if (delay < 1) throw new ArgumentException($"Delay must be greater than 0!");
Console.WriteLine($"Starting to say {word}");
await Task.Delay(delay);
Console.WriteLine($"Finished saying {word} @ {stopwatch.Elapsed.AsElapsedString()}");
return word;
}
The SayWithDelay
function is not asynchronous, it will indicate that it has started before blocking whilst it puts the thread to sleep.
In contrast, the SayWithDelayAsync
function makes use of the async
keyword to indicate that this function can be run asynchronously. It returns a Task<string>
which is essentially a wrapper object that provides access to the execution context of the thread and will eventually contain the result once the thread completes. It is important to note that internally this function is still blocking whilst the thread sleeps for the milliseconds provided in the delay
parameter.
How we call the SayWithDelayAsync function is where the magic happens.
public static async Task Execute()
{
Stopwatch stopwatch = new();
stopwatch.Start();
string string1 = SayWithDelay("Hello", 3000, stopwatch);
string string2 = SayWithDelay("World", 3000, stopwatch);
Console.WriteLine($"{string1} {string2}");
stopwatch.Stop();
Console.WriteLine($"Synchronous RunTime {stopwatch.Elapsed.AsElapsedString()} \n");
stopwatch.Reset();
stopwatch.Start();
string1 = await SayWithDelayAsync("Hello", 3000, stopwatch);
string2 = await SayWithDelayAsync("World", 3000, stopwatch);
Console.WriteLine($"{string1} {string2}");
stopwatch.Stop();
Console.WriteLine($"Await RunTime {stopwatch.Elapsed.AsElapsedString()} \n");
stopwatch.Reset();
stopwatch.Start();
Task<string> helloTask = SayWithDelayAsync("Hello", 3000, stopwatch);
Task<string> worldTask = SayWithDelayAsync("World", 3000, stopwatch);
string1 = await helloTask;
string2 = await worldTask;
Console.WriteLine($"{string1} {string2}");
stopwatch.Stop();
Console.WriteLine($"Task RunTime {stopwatch.Elapsed.AsElapsedString()} \n");
}
Here we use a stopwatch to time the execution of three different approaches.
1. Synchronous SayWithDelay
The first approach is clearly synchronous, it calls SayWithDelay
which is synchronous. If each delay is 3 seconds and they are run sequentially, it should take slightly longer than 6 seconds to execute both.
2. SayWithDelayAsync
, run synchronously
You may be confused with this one, thinking "but we are calling an asynchronous function and using the async / await constructs". This is where the folly lies.
You see, we should be reading this code the same way it is written. When we await
the function at the same time we are calling it, we are essentially saying "please spawn another thread to do this work, I will wait until it completes before proceeding". As a result, running both of these results in the same execution time as the original synchronous code (a little more than 6 seconds).
3. Asynchronous SayWithDelayAsync
This time we are going to do things right. We need to ask for a new thread to start doing the work before we need the result. The key here is we create and start executing the Task
as early as possible and only await
the result when it is actually required. On a computer with multiple cores, this will result in a reduced execution time as they are executed concurrently (roughly 3 seconds total).
It is important to note that on a single-core CPU, it will still take roughly 6 seconds but this is not 1999, we often have access to multiple cores.
Starting to say Hello
Finished saying Hello @ 00:00:03.01
Starting to say World
Finished saying World @ 00:00:06.02
Hello World
Synchronous RunTime 00:00:06.02
Starting to say Hello
Finished saying Hello @ 00:00:03.00
Starting to say World
Finished saying World @ 00:00:06.00
Hello World
Await RunTime 00:00:06.00
Starting to say Hello
Starting to say World
Finished saying Hello @ 00:00:03.00
Finished saying World @ 00:00:03.00
Hello World
Task RunTime 00:00:03.01
WaitAll waits for all tasks to complete.
At this point, you may be wondering why I didn't just you useWaitAll()
?
Should I use WaitAll
? Or maybe WhenAll
?
The answer is usually no, to both of these, except in very specific circumstances.
Both WaitAll
and WhenAll
will ensure all provided tasks run to completion. On the surface, this might seem like exactly what you want. But what if an exception occurs during the execution of one of the tasks you passed as an argument to WaitAll()? The way this is handled is that it swallows the exception until all tasks are complete, then it throws an AggregateException with all of the exceptions it encountered along the way.
To demonstrate this, I will use a slightly modified version of SayWithDelayAsync
:
private static async Task<string> SayWithDelayV2Async(string word, int delay, Stopwatch stopwatch, bool throwException)
{
if (delay < 1) throw new ArgumentException($"Delay must be greater than 0!");
Console.WriteLine($"Starting to say {word}");
await Task.Delay(delay / 2);
if (throwException) throw new Exception($"An exception occured saying '{word}' and it exited @ {stopwatch.Elapsed.AsElapsedString()}!");
await Task.Delay(delay / 2);
Console.WriteLine($"Finished saying {word} @ {stopwatch.Elapsed.AsElapsedString()}");
return word;
}
This is a subtle waste of resources in most cases. You see, if you are waiting for a set of tasks to complete and depend on the results for some other operation, you usually need all of the results or none of them.
This means that we should short circuit the moment an exception is encountered in any task we are awaiting. The simplest way to do that is to await the tasks individually.
Some notable exceptions to this are:
- If you want to handle the aggregate exception separately (potentially a retry policy, discarding or using default values for failure results).
- If you do not depend on the results (fire and forget).
- Any other reason where specifically need all tasks to run to completion regardless of the state of the other tasks in the collection.
Let's run a test to see if this indeed saves time:
public static async Task Execute()
{
Stopwatch stopwatch = new();
string string1;
string string2;
stopwatch.Reset();
stopwatch.Start();
Task<string> helloTask2 = SayWithDelayV2Async("Hello", 3000, stopwatch, true);
Task<string> worldTask2 = SayWithDelayV2Async("World", 3000, stopwatch, false);
try
{
Task.WaitAll(helloTask2, worldTask2);
string1 = helloTask2.Result;
string2 = worldTask2.Result;
Console.WriteLine($"{string1} {string2}");
}
catch (Exception ex)
{
Console.WriteLine(ex.Message.ToString());
}
stopwatch.Stop();
Console.WriteLine($"WaitAll with exception RunTime {stopwatch.Elapsed.AsElapsedString()} \n");
stopwatch.Reset();
stopwatch.Start();
Task<string> helloTask3 = SayWithDelayV2Async("Hello", 3000, stopwatch, true);
Task<string> worldTask3 = SayWithDelayV2Async("World", 3000, stopwatch, false);
try
{
var whenAllTask = Task.WhenAll(helloTask3, worldTask3);
await whenAllTask;
string1 = helloTask3.Result;
string2 = worldTask3.Result;
Console.WriteLine($"{string1} {string2}");
}
catch (Exception ex)
{
Console.WriteLine(ex.Message.ToString());
}
stopwatch.Stop();
Console.WriteLine($"WhenAll with exception RunTime {stopwatch.Elapsed.AsElapsedString()} \n");
stopwatch.Reset();
stopwatch.Start();
Task<string> helloTask4 = SayWithDelayV2Async("Hello", 3000, stopwatch, true);
Task<string> worldTask4 = SayWithDelayV2Async("World", 3000, stopwatch, false);
try
{
string1 = await helloTask4;
string2 = await worldTask4;
Console.WriteLine($"{string1} {string2}");
}
catch (Exception ex)
{
Console.WriteLine(ex.Message.ToString());
}
stopwatch.Stop();
Console.WriteLine($"Await with exception RunTime {stopwatch.Elapsed.AsElapsedString()} \n");
}
Here we try three different scenarios. We have 2 tasks running in each scenario, however, the first task exits with an exception halfway through. We will test the total execution time in each of these scenarios.
- Using
WaitAll
- Using
WhenAll
- Awaiting the tasks individually.
The results speak for themselves.
Starting to say Hello
Starting to say World
Finished saying World @ 00:00:03.01
One or more errors occurred. (An exception occurred saying 'Hello' and it exited @ 00:00:01.50!)
WaitAll with exception RunTime 00:00:03.03
Starting to say Hello
Starting to say World
Finished saying World @ 00:00:03.01
An exception occurred saying 'Hello' and it exited @ 00:00:01.50!
WhenAll with exception RunTime 00:00:03.02
Starting to say Hello
Starting to say World
An exception occurred saying 'Hello' and it exited @ 00:00:01.51!
Await with exception RunTime 00:00:01.52
When we await the tasks individually we half the execution time because we return to the first awaiting caller at the point exception is encountered.
This code is available in my Github Repo, if you would like to test it for yourself or just want a basic template for experimenting with async / await.
Thanks for reading, I hope this helped clear up any misconceptions you may have around async/await in C#.
Let me know in the comments if this was helpful or if you have any questions. If you would like me to write about any other concepts in C#, hit me up on my Twitter @alt_dev_au