Await, Async, Task in depth
Max Bazhenov
Senior Full Stack Developer | 8+ Years in Software Engineering | .NET (C#), Angular, TypeScript, Azure | Enterprise Web Applications | Real-time UI, Performance Optimization
In the previous post, we explored how asynchronous methods free up threads in the thread pool and improve WebApp performance, but it still has opened questions:
1. How does the thread pool know when an async operation from external resources has finished its work?
2. How does the thread pool know where to continue execution after an async operation reaches the await point?
To answer this questions we will dive deeper into the Task-based Asynchronous Pattern (TAP), async/await, Task and state machine.
1. How does the thread pool know when an async operation from external resources has finished its work?
TAP and Task object
When we call ToListAsync() (or another async operation), it returns a Task object. This object represents the ongoing asynchronous operation. The thread pool doesn't need to continuously monitor this operation. Instead, the Task object manages the state of the async operation (whether it's running, completed, or faulted). The I/O operation happens outside the thread pool, managed by the OS or database. Once the database returns the result:
- The Task object marked as complete;
- The thread pool gets notified that the asynchronous operation has finished through the internal event system built into the .NET runtime. This doesn't require constant polling, it's more like a notification system where the task signals its completion
- The thread pool then schedules the continuation (the code that follows await query.ToListAsync()), assigning a thread to complete the remaining execution
So, the thread pool doesn’t directly monitor async operations. Instead, it relies on the Task object to track the state and completion of these operations.
2. How does the thread pool know where to continue execution after an async operation reaches the await point?
Async/Await and State Machine
Writing an async method with await (e.g., await query.ToListAsync()) causes the C# compiler to transform the method into a state machine. The next small async method
public async Task<string> ReadFileAsync(string path)
{
Console.WriteLine("Start reading the file...");
var fileContent = await File.ReadAllTextAsync(path);
Console.WriteLine("The file has been successfully read.");
return fileContent;
}
will be transformed by compiler to the state machine:
This state machine keeps track of:
- The current state of the async method.
- The point of suspension (where the await occurs).
- The continuation point (where the method should resume after the awaited operation completes).
When the async method reaches the await keyword, the following happens:
- The method saves its current state (all local variables, the current execution point, etc.) and exits the method
- A Task is returned to the caller, representing the ongoing operation
At this point, the method is paused, and control is given back to the caller. The continuation (what comes after the await in the code) is saved as part of the state machine and won’t run until the async operation completes.
When the Task completes, it triggers a continuation:
- The continuation is the piece of code that comes after the await point, and the state machine knows exactly where to pick up because it has stored the state of the method.
- The thread pool schedules a thread to execute the continuation. It doesn’t have to be the same thread that initially started the operation; any available thread from the pool can resume the operation.
So, the result of the Task will be returned by any available thread from the pool, allowing the application to handle more requests concurrently.
Summary