Table of Contents
Intro
If you’re a C# developer, chances are you see the async/await keywords everywhere an IO operation is involved.
Introduced in C# 5, async/await made the way to write asynchronous code remarkably simple. It very much fulfills its promise – developers can write async code in almost the same way they write synchronous one.
I find this simplicity fascinating. Asynchronous execution is something complex in its’ nature. There are all sorts of trickeries related to scheduling continuations, transferring execution contexts, handling exceptions, and whatnot.
This complexity doesn’t just go away with the introduction of async/await. Instead, it’s moved behind some abstraction layer implemented in the C# compiler.
At this point, you can:
- Say thanks to the compiler and move on happily, putting async and await here and there until the solution builds.
- Get intimate with the implementation details, and understand why and how it works the way it does.
If you choose the second option, I believe you’ll find this and the next articles in the series very informative.
This article’s content is influenced by the async/await material in the book C# in Depth by Jon Skeet and the blog post Dissecting the async methods in C# by Sergey Tepliakov.
What You Will Learn Here
This is the first article in a series where I’ll explore how the C# compiler translates async methods to IL (Intermediate Language) code, so it just works for the developers.
Concretely, I’ll dive into the mighty async/await State Machine that you’ve probably heard of but never really spend the time to understand fully.
A logical first step for this is to become familiar with the Awaitable Pattern. It plays a vital role in the whole async/await gymnastics.
This pattern is relatively simple to grasp and can be a pretty good introduction to the matter. That’s why I decided to start with it in this introductory article.
What You Will Not Learn Here
In these articles, you won’t find anything beyond the compiler-generated IL (Intermediate Language) code.
I will not research any of the low-level details of asynchronous execution. You won’t see anything from the Operating System’s APIs, device drivers, etc.
In case you are interested in this – you can explore the topic online, starting with the well-known There Is No Thread article by Stephen Cleary.
Why Care About the Internals of async/await?
Every abstraction leaks at some point. Async/await is not an exception. You can stay ignorant of the inner details in 99.9% of the cases, but you will have to deal with unforeseen complications at some point.
Let’s see a few reasons to understand async/await at a deeper level.
- You’ll get a better insight for cases when you need to look beyond the abstraction – things like ConfigureAwait, AggregateException, inability to use await in locked sections, etc.
- Performance – you will comprehend the idea of hot paths and the purpose of
ValueTask<T>
. - Such knowledge often outreaches the specific programming environment and adds valuable skills to your toolbelt.
In case you’re still interested, let’s move on and make a quick conceptual overview of the await workflow.
For additional in-depth materials on async/await in C#, you can check this Pluralsight course.
Evaluation of an “await” Expression – Conceptually
First, let’s see what happens when the await keyword is being evaluated:
When we await some operation, it first gets a chance to complete synchronously.
Let’s see an example.
Suppose you have the following async method.
public async Task MyAsyncMethod(int x) { if(x == 0) return; await Task.Delay(1000); // Do something after await }
And you call it like so:
static async Task Main() { await MyAsyncMethod(0); }
In this case, the input x
is 0, so MyAsyncMethod
will run to completion synchronously because it wouldn’t reach the asynchronous call (Task.Delay
).
Note this doesn’t mean the compiler won’t generate a state machine for the MyAsyncMethod
method. It will. But the state machine execution will run the operation synchronously until it completes or an asynchronous call is reached.
Don’t worry if this sounds confusing for now. I’ll introduce the state machine and its’ behavior in the upcoming articles.
However, if we change the input to something different:
static async Task Main() { await MyAsyncMethod(1); }
That’s when we unlock the async/await superpowers.
When the await point in MyAsyncMethod
is reached:
await Task.Delay(1000);
Task.Delay
will return a Task object. This Task has an awaiter that can be retrieved by calling the GetAwaiter() method.
Dissecting TaskAwaiter
Task.GetAwaiter() returns an object of type TaskAwaiter. It has three core methods/properties that are used by the compiler-generated code for an await expression.
Indicates whether the async operation ran to completion – point 3 in Figure 1.
This method is used to fetch the result when the operation completes – point 4 in Figure 1. The return type of GetResult()
is the same as the return type of the operation we’re awaiting. For example, for this statement:
int x = await someAwaitable;
The return type of GetResult()
will be and int
.
GetResult()
also has a void version. This is used in cases when the async operation doesn’t return any data like Task.Delay
, for example.
void OnCompleted(Action continuation)
This method sets the continuation to run when the async operation completes – point 5 in Figure 1.
The three methods described above play a crucial role in the Awaitable Pattern. If you want to create an awaitable type – you need to implement them.
Let’s define that more formally.
The Shape of an Awaitable Type
The Awaitable Pattern defines a set of rules for building your own awaitable type. In other words, you enable the await keyword for that type.
Let’s make a comparison with the using keyword. If you want to enable using for your type, the rule you need to follow is to implement IDisposable
.
With the Awaitable Pattern, the approach is different, though. You don’t need to implement a specific interface. However, you still need to define some methods and properties with particular names and return types.
The conditions are:
- Your type needs a
GetAwaiter()
method that returns an awaiter type. - The awaiter type must implement the
System.Runtime.INotifyCompletion
interface. That interface contains a single method:void OnCompleted(Action)
. - The awaiter type must have a readable instance property
bool IsCompleted
. - The awaiter type must have an instance method
GetResult()
. This method can return data, or it can be void. The return type of this method determines the result of the async operation.
If your type implements all of those rules, it will be awaitable!
Evaluation of an “await” Expression – Concretely
You are now familiar with how an await expression is evaluated. You’ve also seen the required pieces to implement an awaitable type.
With this, let’s improve on Figure 1 and present the await evaluation in terms of the core components of the Awaitable Pattern.
It’s time to see all of this in action by implementing a simple awaitable type!
Building a Custom Awaitable Type
The code listing below implements the Awaitable Pattern.
It’s relatively straightforward. Please spend a minute comprehending how we can await the custom MyAwaitable
type by following the rules from the previous sections.
Note that this is very much a toy example. The purpose is to understand the high-level role of every component of the pattern. Of course, some real-world implementation can be very involved as it has to coordinate with the whole async infrastructure. For example, it may need to post the continuation to a certain SynchronizationContext
. I will present some of these details in the upcoming articles.
class Program { private static bool _returnCompletedTask; static async Task Main(string[] args) { Console.WriteLine("Before first await"); _returnCompletedTask = true; var res1 = await new MyAwaitable(); Console.WriteLine(res1); Console.WriteLine("Before second await"); _returnCompletedTask = false; var res2 = await new MyAwaitable(); Console.WriteLine(res2); } public class MyAwaitable { public MyAwaiter GetAwaiter() => new MyAwaiter(); } public class MyAwaiter : INotifyCompletion { public bool IsCompleted { get { Console.WriteLine("IsCompleted called."); return _returnCompletedTask; } } public int GetResult() { Console.WriteLine("GetResult called."); return 5; } public void OnCompleted(Action continuation) { Console.WriteLine("OnCompleted called."); continuation(); } } }
This program outputs the following:
Before the first await.
IsCompleted called.
GetResult called.
5
Before the second await.
IsCompleted called.
OnCompleted called.
GetResult called.
5
Notice the usage of the returnCompletedTask
variable. The main point is to demonstrate how the runtime will attach a continuation by calling OnCompleted
only if IsCompleted
returns false.
I encourage you to play with a similar example on your own, so you wrap your head around the building blocks.
Summary
In this article, you’ve explored the Awaitable Pattern, or how to create your awaitable type.
This is important to understand as it plays a vital role in the overall async/await workflow.
In the next article, I’ll start exploring the async/await state machine.
See you next time!