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
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:
AsyncTaskMethodBuilder hits Start command state machine's
MoveNext method is being executed.
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.
- 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.