16
C# Async Await, Deeply
Note: This article had to be edited to align with lessons from Beware of how you create a Task.
So this is it - I am finally ready to describe async/await and hopefully in later posts be in a better position to immunise you against common performance problems related to incorrect usage.
Strap yourself in for a long post - I promise it will be worth it if you want to better understand async/await!
I have described asynchronous programming. I have also briefly described why you might want to use Thread Pools. I then did another brief introduction to UI Threads.
Why did I do this rather than just slam you with an async/await example straight up? Well I think you need to understand the underlying threading model used lest you risk falling ill with thread-pool-starvitise or application-lockup-a-dilly. (Note in the next series I will cover how to diagnose these problems).
Prior to the introduction of async and await, the code we had to write to get asynchronous behaviour was yucky. Code was strewn all over the place and it was very hard for a human to follow.
Using async/await basically provides syntactic sugar to make asynchronous code look like easy-to-read synchronous code.
First let's do some work synchronously ...
public class Example { public string Hello { get; set; } }
static void Main()
{
// Let's go to sleep synchronously ...
Thread.Sleep(1000);
// Let's load some JSON files synchronously ...
var example = JsonSerializer.Deserialize<Example>(File.ReadAllText("example.json"));
// Let's make a web request synchronously ...
var request = new HttpRequestMessage(HttpMethod.Get, "http://dev.to");
var client = new HttpClient();
var response = client.Send(request);
}
In the example above, the main thread of the console application is blocked until the program execution completes.
Next let's do the same work asynchronously ...
public class Example { public string Hello { get; set; } }
static async Task Main()
{
// let's sleep asynchronously ...
await Task.Delay(1000);
// Let's load some JSON files asynchronously ...
await using FileStream stream = File.OpenRead("example.json");
var example = await JsonSerializer.DeserializeAsync<Example>(stream);
// Let's make a web request asynchronously ...
var request = new HttpRequestMessage(HttpMethod.Get, "http://dev.to");
var client = new HttpClient();
var response = await client.SendAsync(request);
}
In this second example, the code is almost identical to the synchronous example but we are now using the "await" keyword and "async Task" in the signature of Main.
So I guess you are now saying "So what?". Well with a few modifications to the asynchronous code, we can execute all jobs concurrently to the fullest extent of what the thread pool will allow.
Let's see a benchmark of just the JSON file deserialisation. You can skip the code because it's the graph afterwards that is what I really want to explain.
public class Example { public string Hello { get; set; } }
[RPlotExporter]
[SimpleJob(RunStrategy.ColdStart, RuntimeMoniker.Net50, baseline: true)]
public class Benchmarker
{
[Params(16, 64, 256)] public int N;
[Benchmark]
public void A_SyncLoop() => A_SyncLoop(N);
[Benchmark]
public void B_SyncThreaded() => B_SyncThreaded(N);
[Benchmark]
public async Task C_AsyncLoop() => await C_AsyncLoop(N);
[Benchmark]
public async Task D_AsyncPooled() => await D_AsyncPooled(N);
public void A_SyncLoop(int numberOfFiles)
{
for (var ii = 0; ii < numberOfFiles; ++ii)
{
var example = JsonSerializer.Deserialize<Example>(File.ReadAllText("example.json"));
}
}
public void B_SyncThreaded(int numberOfFiles)
{
var threads = new Thread[numberOfFiles];
for (var ii = 0; ii < numberOfFiles; ++ii)
{
threads[ii] = new Thread(() =>
{
var example = JsonSerializer.Deserialize<Example>(File.ReadAllText("example.json"));
});
// Don't block waiting for the result of this specific
// thread ...
threads[ii].Start();
}
// ... block waiting for the results of all threads
// running concurrently.
for (var ii = 0; ii < numberOfFiles; ++ii)
threads[ii].Join();
}
public async Task C_AsyncLoop(int numberOfFiles)
{
for (var ii = 0; ii < numberOfFiles; ++ii)
{
await using FileStream stream = File.OpenRead("example.json");
var example = await JsonSerializer.DeserializeAsync<Example>(stream);
}
}
// Note this code looks strange because want to
// separate the task creation from the task run. We cannot
// simply return a task from an async method because it
// will be started automatically. I have tried to make this
// code look as similar as possible to the threaded
// implementation.
public async Task D_AsyncPooled(int numberOfFiles)
{
var tasks = new Task[numberOfFiles];
for (var ii = 0; ii < numberOfFiles; ++ii)
{
var func = new Func<Task>(async () =>
{
await using FileStream stream = File.OpenRead("example.json");
var example = await JsonSerializer.DeserializeAsync<Example>(stream);
});
tasks[ii] = func.Invoke();
}
// ... await the results of all tasks running as concurrently as the
// thread pool will allow.
await Task.WhenAll(tasks);
}
}
public static void Main()
{
var summary = BenchmarkRunner.Run<Benchmarker>();
}
The only takeaway I want from this graph is that the de-serialisation of the JSON files that uses the Thread Pool generally executes in the least amount of time as the number of files to de-serialise increases. Using the async/await approach to use the thread pool is so easy it almost looks like synchronous code! (Note there is some overhead with async/await which makes the synchronous approach faster for a small number of files. But as the number of files increases, the Thread Pool really starts to shine and performance is much improved).
With respect to UI threads, using async/await is a no brainer. To get a responsive UI where the number of UI threads is usually 1, you have to use async/await to avoid blocking the UI thread.
Notice how the simple explanation is now more nuanced once I explained Thread Pools and UI Threads? Now let's go deeper still ...
You noticed in the simple explanation that instead of using Threads directly, we started to use Tasks. In the simple example, Tasks did not show up except for appearing in the signature of Main. However, the deeper you go, the more you need a quick overview of what a Task actually is.
Tasks are an abstraction of some unit of work that needs to be executed. Tasks promise to do something in the future and optionally return a result. There are 2 types of tasks: one that doesn't return a value and one that does return a value. Tasks are reference types and hence are allocated on the heap. Now async methods can actually execute synchronously in some circumstances (it's up to the Thread Pool to decide) so in simple cases you get penalised with quite a bit of overhead. (To minimise the number of heap allocations, a new ValueTask has been introduced with C# 7).
The async keyword is a flag to the compiler to transform the method and create an asynchronous state machine for for it. Let's write a simple async method and see what the stack trace looks like ...
public class Example
{
public static async Task GoToSleep()
{
// Note I should really be using a logger that uses the
// async file API but I feel lazy so ...
Console.WriteLine($"Async Thread ID: {Thread.CurrentThread.ManagedThreadId}");
await Task.Delay(1000);
}
}
class Program
{
static async Task Main()
{
Console.WriteLine($"Main Thread ID: {Thread.CurrentThread.ManagedThreadId}");
await Example.GoToSleep();
}
}
The output of this program is ...
Main Thread ID: 1
Async Thread ID: 1
So like I mentioned earlier we are getting penalised with the overhead of the state machine creation without any benefit other than to show you the stack trace ...
So without going too deep, you can start to see some of the state machine in action when debugging the code. I must say the current stack trace looks quite intuitive compared to what it used to look like when async/await was released in C# 5! There are plenty of posts out there if you really want to get your hands dirty (like this one), but that is about as deep as I am going to go with async.
The await operator suspends the execution of statements until the asynchronous method completes. If the async method returns a Task of type T then a value or reference to T is returned. If the async method has already completed, the result will be returned without suspending the execution of statments. Note that the thread that has been suspended waiting for the result is not blocked.
I hope you have gleaned a deeper understanding of async/await from this post. Typically I have found you need to read quite a lot of posts to get a deeper understanding of the topic. People like Stephen Toub and Stephen Cleary are very good sources for information if you want further reading.
In my next set of posts, I will go over common application misbehaviour that may be related to incorrect use of the async/await approach in C#.
16