5 things to deepen your knowledge about asynchronous programming in .NET
Asynchronous programming has become second nature to many of us. Using async and await feels almost effortless, seamlessly integrating into our daily coding routines. But beyond the basics, there are always new insights and nuances to explore. 🔍
In this blog post, I’ll share 5 things about asynchronous programming in .NET that might not be essential knowledge, but they will give you something to think about. Even if you’re already familiar with some of them, I hope at least one of these topics surprises you!
1. Is using async/await always necessary?
Did you know you don’t have to write async/await every time when calling an async method inside of it? If you immediately return the result of that async call and don’t actually do anything with the result, you could also write the following:
// Instead of writing this
public async Task<HttpResponseMessage> GetGoogle() {
return await _httpClient.GetAsync("www.google.com");
}
// Write this!
public Task<HttpResponseMessage> GetGoogle() {
return _httpClient.GetAsync("www.google.com");
}
Of course, the real advantage isn’t just about writing two fewer words of code, it’s actually about performance! 🚀
Well, the proof is in the pudding, let’s run some benchmarks.
[MemoryDiagnoser]
public class AsyncAwaitBenchmark
{
private Task<int> GetMagicNumber()
{
return Task.FromResult(42);
}
private async Task<int> GetNumberWithAsyncAwait()
{
return await GetMagicNumber();
}
private Task<int> GetNumberWithoutAsyncAwait()
{
return GetMagicNumber();
}
[Benchmark]
public async Task WithAsyncAwait()
{
await GetNumberWithAsyncAwait();
}
[Benchmark]
public async Task WithoutAsyncAwait()
{
await GetNumberWithoutAsyncAwait();
}
}
If you are curious about the library I’m using to run those benchmarks, it’s BenchmarkDotNet. Really useful every time you want to run a quick benchmark of some kind. 🧪
Here are the results:
In this example, the performance and memory usage is 50% better when not using async/await. There is actually a big reason why there is such a difference. A state machine is created every time we use async/await for a method. It contains and manages the asynchronous state for that method. So, if we don’t use the async/await, then the state machine is never created, and the overhead is avoided. Simple as that!
That’s the short explanation, if you really want to know how it all works behind the scenes, I can recommend the following blog post from Stephen Toub where he dives deep into async/await, and how it all works behind the scenes.
That means we should always do this, right? Well… not exactly. The side effect of not using async/await is that you lose a part of the stack trace. Now, sometimes that’s not necessarily a bad thing. Libraries do this all the time. Let’s take the HttpClient
as an example. If we look at the GetAsync
call, you’ll see that there is no async/await inside the method:
public Task<HttpResponseMessage> GetAsync([StringSyntax("Uri")] string? requestUri)
{
return this.GetAsync(HttpClient.CreateUri(requestUri));
}
This method simply returns a Task
without using await
. When an exception occurs inside GetAsync
, the stack trace will not include this method in the call stack because it doesn’t actually participate in the state machine transformation that happens when using async/await. Now in this instance, that’s not really a problem. We’re not going to be interested in this delegated method if we’re analyzing a stack trace.
In summary, depending on what you want, you can pick and choose based on your specific scenario.
2. WhenEach when you actually need it
For a long time now, there was actually no proper way to start a group of tasks, and do something with each result immediately when a task of that group completes.
Let’s say we need to invite a group of people for an event, and when each of them gets invited, we show a green marker ✅ next to his/her name. We want these invites to be sent in parallel, but whenever one of them completes, we show that green marker.
To achieve that, you could write something like this:
var tasks = new List<Task<string>>
{
InvitePerson(1),
InvitePerson(2),
InvitePerson(3),
InvitePerson(4),
InvitePerson(5)
};
while (tasks.Any())
{
var finishedTask = await Task.WhenAny(tasks);
tasks.Remove(finishedTask);
Console.WriteLine(await finishedTask);
}
async Task<string> InvitePerson(int order)
{
// Simulate latency
var randomDelay = Random.Shared.Next(100, 3_000);
await Task.Delay(randomDelay);
return $"Green marker \u2705 for person {order} after {randomDelay}ms";
}
And in fact, this was how we used to do it for a very long time. But now, since .NET 9, we can use WhenEach
💫.
await foreach (var task in Task.WhenEach(tasks))
{
Console.WriteLine(await task);
}
This method is basically going to yield
each task as it completes, giving us the possibility to use an await foreach
here.
3. Configure the right ConfigureAwait
Did you know each Task
object comes with a ConfigureAwait
method? Before we dive deeper into what this method actually does, we first need to discuss the SynchronizationContext
.
The SynchronizationContext
represents a context that can post work to a specific thread or queue. It’s commonly used in UI applications to ensure that UI updates occur on the main thread. For instance, in Windows Forms or WPF applications, the SynchronizationContext
ensures that operations affecting the UI are executed on the UI thread.
Now ok Jarne, that’s nice, but when is this actually useful? Let’s look at an example:
private async void Button_Click(object sender, RoutedEventArgs e)
{
await DoBackgroundWork().ConfigureAwait(false);
_button.Background = new SolidColorBrush(Colors.Green);
}
private async Task DoBackgroundWork()
{
await Task.Delay(1_000);
}
In this code, I have a UI button that turns green when clicked. Before that, some background work is executed. While the background task runs, await
releases the thread back to the thread pool for reuse 🔄. Once the task is completed, the method needs to resume execution. But does the Button_Click
method resume on the same thread or a different one? That’s where ConfigureAwait
comes into play!
ConfigureAwait
lets you specify whether the method should continue execution within the original context or not. Passing true
means it resumes within the same context, while false
means it resumes in a more general thread pool context, which improves performance, but doesn’t guarantee returning to the original context.
But plot twist, the code above won’t work! 🤯 We’ll get an exception after continuing in the Button_Click
method to set the background of the button:
System.InvalidOperationException:
"The calling thread cannot access this object because a different thread owns it."
What I haven’t told you before is that this is a WPF application, and things like setting the background color need to happen on the UI thread. But since we configured the await
to resume on any context, it won’t necessarily choose the one with the UI thread on it, causing the exception. That’s also the reason why by default, ConfigureAwait
is set to true
. This configuration will work in more cases than having a default of false
.
Most people will do ConfigureAwait(false)
when working on a library, here we can actually benefit from the performance gain without having to worry about the context your method resumes on. That’s also why in non-UI related libraries, you’ll find ConfigureAwait(false)
in the source code on tasks that are awaited.
So why isn’t this implemented consistently everywhere? 🤔 It’s a minor performance enhancement, and most developers, me included, will prioritize readability over negligible performance gains.
4. Synchronous execution in an async
world
There could be scenarios where we need to run a Task
in a synchronous context. In a constructor for example. In these cases, we cannot use async
and await
. Instead, we would need to wait until the Task
is completed.
☝🏻 This happens very rarely. Always prefer running something in an asynchronous context. If we’re running a task synchronously, we’re not allowing the thread to go back into the thread pool so it can be reused. The current thread is held inside the method until the task is completed, effectively blocking the thread. This could lead to thread pool starvation.
We have several options on how we can approach this. We can use Task.Wait()
, Task.Result
or Task.GetAwaiter().GetResult()
. So which one should we use? Task.GetAwaiter().GetResult()
is always preferred over Task.Wai()
and Task.Result
, because it directly propagates exceptions instead of encapsulating them in an AggregateException
. An AggregateException
will wrap the original exception inside an InnerExceptions
array, basically boxing it.
// Using Task.Wait()
try
{
Task.Run(() => throw new InvalidOperationException()).Wait();
}
catch (Exception ex)
{
Console.WriteLine(ex.GetType()); // System.AggregateException
}
// Using Task.Result
try
{
var result = Task.Run(() => throw new InvalidOperationException()).Result;
}
catch (Exception ex)
{
Console.WriteLine(ex.GetType()); // System.AggregateException
}
// Using Task.GetAwaiter().GetResult()
try
{
Task.Run(() => throw new InvalidOperationException()).GetAwaiter().GetResult();
}
catch (Exception ex)
{
Console.WriteLine(ex.GetType()); // System.InvalidOperationException
}
☝🏻 There are still use cases where an
AggregateException
is useful. This blogpost explains it pretty good.
Ok, so why do we still have Task.Wait()
and Task.Result
then? It has to do with compatibility. Task.Wait()
was introduced in .NET Framework 4.0, while Task.GetAwaiter().GetResult()
became available with .NET Framework 4.5. They kept Task.Wait()
and it’s original behavior around so to not break older codebases.
When you write await task
, the compiler translates that into the Task.GetAwaiter()
method, which returns an instance that has a GetResult()
method. When used on a faulted Task, GetResult()
will propagate the original exception.
So as a conclusion, we want to use Task.GetAwaiter().GetResult()
in this scenario.
5. Breaking the loop with Task.Yield
Before I start talking about
Talk.Yield
, it’s important to point out that there are very niche scenarios where this is actually needed. Still, it’s nice to know! 🧠
Consider an environment where we are continuously handling small requests on the server in a while loop:
async Task HandleAsyncRequests()
{
while (true)
{
await ProcessRequestAsync();
}
}
async Task ProcessRequestAsync()
{
// Simulate micro work
await Task.Delay(5);
}
Imagine we have several such loops in our code. Perhaps handling requests from a custom message bus or something else (the specifics don’t matter right now). What does matter is what happens to the threads executing this work.
Let’s assume that the synchronization context decides to reuse the same threads for these loops, keeping them constantly engaged instead of returning them to the thread pool. Now, if another method requires a worker thread for a one-time task, such as making an API call, it might experience an unexpected delay. This happens because the available threads are occupied within the loops, making it harder for new tasks to acquire a worker thread. In extreme cases, this could even lead to work being blocked entirely. 🧱
To mitigate that issue, we use await Task.Yield()
. This forces the current thread to return back to the thread pool and choose another thread to execute the remaining work. It effectively releases the thread to go and perform other tasks. This reminds me a little of Round-robin scheduling, which is a processor scheduling algorithm that describes similar elements.
In hindsight, you should use this scarcely. There aren’t a lot of practical use cases where the same thread would be continuously occupied with the same work. But it could still happen…
I hope at least one of these 5 topcis has given you something to reflect on, or experiment with in your own projects. Whether it’s avoiding common pitfalls, improving performance, or simply gaining a new perspective! 👀