38
Visual Guide to C# async/await
Microsoft first introduced async/await
pattern in C# 5.0 back in 2011. I think it’s one of the greatest contributions to the asynchronous programming — at languages level— which led other programming languages to follow, like Python and JavaScript, to name a few. It makes asynchronous code more readable, more like the ordinary synchronous code. Remember those old-school BeginXxx
/ EndXxx
in Asynchronous Programming Model/APM in C# 1.0? I can still remember writing those in 2002 with Visual Studio .NET 2002.
Enough foreword. Do you still remember the famous “There Is No Thread” post from Stephen Cleary?
If you’re newbie to C#, go read it. I will wait. Perhaps, I should say await ReadAsync()
?😆
Okay, I’m glad you’re still here. This post is my attempt to help C# developers to better grasp what async/await is all about.
Let’s start with the synchronous version.
Nothing fancy here. I guess it’s pretty straightforward and self-explanatory.
Next, the asynchronous one.
As you can see, our asynchronous chef put await MethodAsync()
in the kitchen and then leaves the kitchen without waiting for the 🍜(Task<🍜>) to be ready. Our synchronous chef, whereas, in contrast, will be hanging around, perhaps forever in the kitchen, waiting for the 🍜.
Our asynchronous chef leaves the kitchen, returns to his home (thread pool ), waiting with his lovely family members (thread pool threads ) while keeping the door open (assuming ConfigureAwait(false)
; trying to simulate “no Synchronization Context” here). At this point, we are in the so-called “there is no thread” state. Nobody is in the kitchen. Our 🍜 is still sitting in the microwave (ongoing I/O operation). Sorry to disappoint you, my dear reader, it’s instant noodles 😂.
Once it’s heated, our super smart AI-powered-alarm-shaped drone (I/O Completion Port — IOCP) flies to our chef’s house to notify them. Remember that the door is left open (again, no Synchronization Context), but our chef is in the toilet 🚽, so he asked his wife — who happened to be a chef as well — to go to continue his work. She resumes her husband’s remaining tasks, picks where her husband left it off (resuming AsyncStateMachine
). The rest is the same as the synchronous version.
The animated GIFs are there to illustrate analogies in the our-almost-real-world. Let’s try to visualize it using Visual Studio.
We’ll be using the following code, a minimal APIs, new in .NET 6.
var builder = WebApplication.CreateBuilder(args);
await using var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.MapGet("/sleep", (CancellationToken cancellationToken) =>
{
while (!cancellationToken.IsCancellationRequested)
{
Enumerable.Range(1, 100).Select(x => x).ToList().ForEach(x =>
{
//WARNING: BAD CODE
Task.Run(() => Thread.Sleep(3 * 60 * 1_000), cancellationToken);
});
Thread.Sleep(2 * 60 * 1_000);
}
return "Done.";
});
app.MapGet("/delay", async (CancellationToken cancellationToken) =>
{
while (!cancellationToken.IsCancellationRequested)
{
Enumerable.Range(1, 100).Select(x => x).ToList().ForEach(x =>
{
//WARNING: BAD CODE
Task.Run(async () => await Task.Delay(3 * 60 * 1_000, cancellationToken), cancellationToken);
});
await Task.Delay(2 * 60 * 1_000, cancellationToken);
}
return "Done.";
});
await app.RunAsync();
First, we will inspect the synchronous version. Go to https://localhost:5001/sleep. Inspect the process using Process Explorer.
We can see that we are starting up 100 threads. Notice the scrollbar? We have a bunch of threads hanging around.
📝 Even though we are calling
Thread.Sleep
, it’s still a waste of resources.
Let’s go back to Visual Studio, use Break All to pause the application execution.
📝 You can use
Debugger.Break
to achieve the same effect. Details: https://docs.microsoft.com/en-us/dotnet/api/system.diagnostics.debugger.break
Look at the Parallel Stacks window.
A lot of sleeping threads are in “blocked” state. It’s like our synchronous chef in the kitchen doing nothing except waiting for the 🍜 to be heated in the microwave.
Now, switch the View to Threads view to see how it looks, grouped by threads. Hundreds of threads!
Let’s see Threads window. Notice the scrollbar?
Next, let’s see asynchronous version. Go to https://localhost:5001/delay. Inspect the process using Process Explorer.
We have started 100 tasks, but hey, there is no scrollbar!
Go back to Visual Studio, pause the application execution.
Look at the Parallel Stacks window.
Lots of tasks are in a “scheduled” state; scheduled to be fired in the future. In Thread column, you can see that there is no thread info, no thread ID.
Did you just scroll up to double-check the screenshot of the synchronous version? Welcome back! 😆
Now, switch the View to Threads view to see how it looks, grouped by threads.
Less than ten threads, that’s mostly the framework threads.
Let’s see Threads window. Notice that there is no scrollbar.
So yes, there is “no thread” here. No user code thread, to be precise. No thread, no chef. Like our asynchronous chef, instead of waiting in the kitchen, he returns to his home sweet home.
That’s it!
So what does this mean? For ASP.NET, this “ no thread” will translate to a scalability improvement since we don’t block our precious thread pool threads; blocking might lead to thread pool starvation. Here is the analogy. We only have five chefs. All of them are now waiting in the kitchen, doing nothing. We can no longer process additional cooking orders. But if they don’t simply wait in the kitchen, they would be able to process the same amount of orders with, say, just only two chefs. Given that two busy chefs in the kitchen, we still have three remaining chefs idle, waiting for the new additional cooking orders.
38