Understanding async/await State Machine in .NET
Intro
I'm used to be using async/await keywords in .NET. It increases the throughput of the services I'm working on dramatically. At some point, using async gained me 10x performance improvements in terms of users throughput, by simply applying asynchronous overload to the critical part of a software. But if it's so simple to use and at the same time so powerful - how does it work behind the scenes? Well, not that simple. The keywords I love so much for their simplicity are just syntactic sugar and translated on a compiler level to something called Async State Machine.
Example
Let's dive a bit deeper and rely on an example. I will use simple program that tries to get the data from
Dictionary
if the key is found, if not I await
with
Task.Delay
and after that initializing it for later use.
That's how I see the code in IDE:
And that's what the code looks like on a compiler level:
Wow! No more async await keywords in our code, but does it mean we're running synchronous code? As always in software engineering it depends on a context.
By default .NET runtime will try to execute the Task straight away if it could - synchronously. The reason for that is to not overhead with queuing and scheduling management if the code is actually can perform synchronously. But even on synchronous method marked with async (without await inside) state machine will be generated anyway and the code which could potentially be inlined and executed much faster will be generating additional complexity for state machine management. That's why it's so important to mark methods as async if and only if there is await inside.
So let's take a closer look at State Machine compiler generated contract:
The main method state machine operates on is
void MoveNext();
An interesting part that it returns nothing because mutates the state by reference. By design it's relying on awaiters completion and what it actually does is well described in docs:
Moves the state machine to its next state.
StateMachine
itself may have three different states to track Task status:
Once AsyncTaskMethodBuilder
hits Start command state machine's MoveNext
method is being executed.
What MoveNext
does is state management, it checks if awaiter is completed or not and changes the state of state machine and/or Task. Once state machine is finally able to mark the task as completed it calls
Builder.SetResult();
and it's done.
Here is a basic flow for this example:
GetValueFromCacheAsync
is now initializing state machine (2) and starts Task processing/scheduling (3) for the first time.
Once task processing's being started the runtime checks if task can be executed synchronously and if so Task.ExecuteEntry is called, Task is marked as completed, and we got the task fulfilled with result straight away.
If the task can't run synchronously - that's where by default ThreadPool queuing comes in play.
ThreadPool queuing operates on events and callbacks as well as asynchronous operations. There are two types of processing - CPU bound and I/O bound. On truly asynchronous operations such as writing to Disk or making Network requests .NET waits to be notified that native process is completed and assigns a thread to continue processing the result of a native service. That's what called I/O bound operations. For CPU bound there could be a thread to process the result in a background. For a better understanding of how this is possible I recommend to read article by Stephen Cleary - there is no thread.
So once ThreadPool is notified about completion of asynchronous operation it notifies Async State Machine again and MoveNext()
is executed second time.
MoveNext
checks if awaiter is completed and since it is - sets the result of the task and it's done. The state machine exits and .NET continue processing.
Conclusion
- Do not mark methods async if there is no await inside.
- The code we write so tidy and clean could be a mess behind the scenes - it's always good to strive for knowledge and understand the underlying complexity of a software.
I haven't touched in this article error handling parts as well as I didn't want to dive too deeply inside. I may touch it in my future posts, though.
Please feel free to join the discussion or give me a heads up on any part you're interested in.
References:
- TaskScheduler
QueueTask
GitHub link - Task
ExecuteEntry
GitHub link - There is no thread