10 Steps of what's happening when you await a Task.


This is an evolving blog, here I try to understand what is happenning in asynchronous programming on a level I can fathom.
Before we jump into writing asynchronous code, let’s set the stage with a bit of theory.
Process – Think of a process as a little world of its own. It has its own memory, resources, and at least one thread running inside it.
Thread – A thread is a single line of execution within that process. And just like a kitchen can have more than one chef, a single process can have multiple threads working in parallel.
Analogy: Imagine a restaurant. The restaurant itself is the process. Inside the kitchen, you have chefs (threads) working on different dishes at the same time.
State Machine - a piece of code that keep track of what step it’s in.
-1
: Initial or running (default before anyawait
)0
: Paused after the first await (the method will resume here)-2
: Completed (done
Now let’s proceed to the example. Here we are going to cook.
Here is the order, we are going to:
1.) Boil the water
2.) Cook the pasta
3.) Fry the chicken
Now let’s simulate a very big loop to keep our local threads busy.
Thread.CurrentThread.ManagedThreadId
gives you the numeric ID of the thread that’s currently executing that line of code.
In the example, I run a simple thread test to see which threads the program we are using.
Now before the call of await on our task. All the code runs on the same thread. Once we hit the await, control is returned to the caller, and when the method resumes, it may continue on a different thread.
This is the key idea:await
is the only point where an async method can “hop” to another thread.
Asynchronous code is efficient because it doesn’t block the thread while waiting.
In our cooking analogy, instead of one chef standing idle while the pasta boils, they can move on to frying the chicken, making better use of time and resources.
Now this is the interesting part and most likely the highlight of this blog. What is really happening when we await a task?
This are the steps according to Jon Skeet’s C# in Depth 4th edition.
Let’s debunk it using IL code I use a Visual Studio extension ILSPY.
- It’s lower-level than C#, but still human-readable. (And this is also translated in C#)
Here I provided an example and screenshots on the C# code on Visual Studio and the IL code that is translated in C#.
So here is the awaited task, Let’s see what happens behind the code await.
Translates into:
Now If we are going to follow Jon Skeets 10 steps of zooming into an await expression then the first one will be like.
1.) You fetch the awaiter from the awaitable by calling GetAwaiter(), storing it on the stack.
Here we are storing the the object awaiter where it wraps the task providing properties and methods of the task that we need to invoke on the next steps.
2.) You check whether the awaiter has already completed. If it has, you can skip straight to fetching the result (step 9). This is the fast path
On the stored awaiter we invoke the isCompleted to check if the object that we are passing is already completed. On this context it checks if the task StartCooking() is done.
3.) It looks like you’re on the slow path. Oh well. Remember where you reached via the state field
To be able to pause and later resume from the exact same point, the C# compiler generates a state machine. The field
<>1__state
stores which point in the async method the code should continue from once the awaited task completes.(This is like a bookmark)
4.) Remember the awaiter in a field.
The compiler should know which task we are awaiting on.
(Then this is the book)
5.) Schedule a continuation with the awaiter, making sure that when the continuation is executed, you’ll be back to the right state (doing the boxing dance, if necessary).
<Main>d__0 stateMachine = this;
(Is the object we save for us to know which object to resume)
stateMachine = this;
assigns the current state machine instance to a local variable named stateMachine
, so that it can be passed by reference to AwaitUnsafeOnCompleted
, which will register the continuation to resume on that instance by calling MoveNext()
on it when the awaited task completes.
(this contains the object, all the fields, variables)
Then as you can see here we are having the awaiter and the stateMachine arguments that we passed earlier.
Here we are saying that if the reference2 which is the awaiter. “When you finish, please execute this Action (moveNextAction
).”
Callbacks (Actions) are the foundation of async/await.
They allow code to be resumed at the right time without blocking the thread.
6.) Return from the MoveNext() method either to the original caller, if this is the first time you’ve paused, or to whatever scheduled the continuation otherwise.
7.) When the continuation fires, set your state back to running (value of –1).
So here we are entering the else block {}
we are setting the value of the state to -1 means it’s running again.
8.) Copy the awaiter out of the field and back onto the stack, clearing the field in order to potentially help the garbage collector. Now you’re ready to rejoin the fast path
why not just keep using <>u__1? Why are we still using the local variable awaiter?
Answer: Local variables are faster than fields.
9.) Fetch the result from the awaiter, which is on the stack at this point regardless of which path you took. You have to call GetResult() even if there isn’t a result value to let the awaiter propagate errors if necessary
10.) Continue on your merry way, executing the rest of the original code using the result value if there was one.
Here is the internal implementation of the getResult, the last safe guard of the task.
Here are what my sources for now:
https://devblogs.microsoft.com/dotnet/how-async-await-really-works/
https://www.youtube.com/watch?v=il9gl8MH17s&t=1217s
C# in depth 4th edition
Subscribe to my newsletter
Read articles from Juan Miguel Nieto directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Juan Miguel Nieto
Juan Miguel Nieto
A software developer trying to write organic blogs.