In this post, we’ll see how does async-await
work in .NET platform. The keyword pair Async-Await was introduced in C# 5
as a way of writing cleaner (synchronous-like) code for asynchronous operations. With use of async-await
, a method can be written which looks very similar in structure to standard synchronous code, but can work asynchronously without keeping the calling thread busy.
Here, we’ll look at a typical async
method, and see how they work practically, without going too deep in the implementation details.
A typical example of Async-Await
First let’s look a typical asynchronous code written with async-await. This sample method, on a high level, makes a heavy/long-running call to get a message string and then returns the length of the message.
The method GetMessageLengthAsync()
calls a long-running (takes considerable amount of time) method GetTimeTakingMessageAsync()
to get a message string. While that is being processed, it does some other work DoIndependentWork()
which is independent of the message value. Then it suspends the flow and asynchronously waits for the message work to complete. Once completed, it does some processing that is dependent on the previous result (calculate the message length), and finally returns the result.
public async Task<int> GetMessageLengthAsync()
{
Task<string> stringTask = GetTimeTakingMessageAsync(); //calls a long running method
DoIndependentWork(); //does some independent work meanwhile
string message = await stringTask; //suspends & awaits the result
int length = message.Length; //once done, does some work on awaited result
return length; //then returns the final int result
}
Note that we are taking the GetMessageLengthAsync()
as the sample async
method. The GetTimeTakingMessageAsync()
that is called from inside, is just another async
method, but that is not target of the discussion.
an async
method basically means, it can run in background, leaving the calling thread free to do other stuffs. The single most important thing to understand here is, the method suspends the current work-flow at await
and waits for the long-running task to complete asynchronously, without blocking the calling thread. That means, the thread that originally had called GetMessageLengthAsync()
, gets free at this point, leaves the method and takes control back to the original calling location. Later, when the long-running task completes, the method resumes and executes the rest of the code. Read along for more details.
Well, what happens after that? Before the original calling thread leaves at await
, the runtime captures the current SynchronizationContext
and the TaskScheduler
and internally creates a continuation workflow. Now the calling thread leaves the method, and is free to do other tasks. Meanwhile the long-running work that was started, keeps running in the background. Note that the GetTimeTakingMessageAsync
must also be an async
method for our code to work. But the actual implementation does not matter.
Once that long-running (get the message) work is complete, the runtime uses the captures context and/or scheduler to schedule the remaining work of the method, as appropriate. And eventually the rest of the method completes and returns the final int
value.
Basic characteristics
The method looks pretty similar to a synchronous method in structure but there are few significant differences. The main things to notice about the method are
- The method signature is marked with
async
- The method uses
await
on aTask
to get the result - The return type is
Task<int>
rather thanint
- The method name has
Async
as a suffix (GetMessageLengthAsync)
The points 1-3 makes the method asynchronous. The async
enables the await
functionality in the method. You CANNOT use await
without using the async
declaration on the method signature. On the other hand, a method can be declared as async
without using await
in the method body. It does work, but the just runs synchronously.
The await
is the part which actually turns the method asynchronous! Roughly it means, now it wants the work to be done and get the result, but asynchronously! When the execution hits the line with await
- it first checks if the work has already completed. If yes, it simply continues as normal synchronous method. Else the current executing thread suspends the work-flow of the method, captures the current context, and returns from the method to the calling method. So, the main thread can continue executing other works in the calling method, and the awaited task continues in the background. Once that task completes, the method resumes from the await
point, on the captured context.
The return type of a async
method can only be Task
, Task<T>
or void
. As a general suggestion, avoid using void
return type, as that makes the method non-awaitable, i.e. the method cannot be used with await
from another method in a non-blocking way (unless we just want to fire-and-forget). Also exception handling becomes complicated. If there is a result to return, use Task<TResult>
or just use Task
.
Now, interestingly, notice our sample method for once. The method actually returns just an integer (message.Length), but the return type is Task<int>
! How does that work? This piece of magic is done by the async
keyword. In simple terms, it just means that, the method returns an integer, but does that asynchronously after the control was already returned from the method. See the next section.
The “Async” suffix to the method is totally optional, but it is the general convention to name async
methods that way so that it’s easier to read and notice.
The control flow
Before we explore the control flow in a async
method, let’s first understand what happens to the code when it is compiled. In compilation, the compiler basically (talking it in an overly-simplified way) creates a continuation for the part of code, that is after await
. This continuation is implemented as an auto-generated class with a state-machine. The state-machine has 2 states - incomplete, complete (just giving some names for easy understanding). Initially, when the work is started, it goes to incomplete state. Later, when the awaited work is complete, the state-machine goes to complete state, and the next part of the code is kicked off.
So, basically the compiler breaks the method at await
and puts the rest of the code as a continuation with a state machine to control the continuation.
We’ll take the above simple code as an example for the control flow. But the working principle stays same for any async
method.
- First an external method, say
CallingMethod()
calls ourGetMessageLengthAsync()
method. The thread that makes this call, let’s call it the main thread (the green lines). - It makes a call to another
async
method on the same thread, just like any normal code execution. - Now, this method
GetTimeTakingMessageAsync()
being anasync
method, returns control immediately, leaving the work of getting message to be done in background. - Now it makes a call to an method that does not depend on message. This is called synchronously on the main thread.
- After completion, it comes back to
GetMessageLengthAsync()
. - Now the execution hits the
await
, which says it’ll wait asynchronously for the message. Atawait
, the main thread suspends the work on this method, and returns toCallingMethod()
where it is free to do other stuffs. Just before this, CLR captures the synchronization context*. - At a later point of time, when the message is fully received, the continuation of the rest of the code is invoked. Now depending on the *captured context, the rest of the code might run on the same main thread, or another thread. See next section.
- It then executes the rest of the code, and upon completion, returns the final result to
CallingMethod()
.
Threads in Async-Await
A very common question around async-await
is, are there any new thread created for ths asynchronous process to execute?
The answer is, NO. There might or might not be additional threads involved. If additional threads are required, generally threads are taken from the managed ThreadPool
.
So, when is thread pool thread used? If not, how does the work complete without a thread!
Before answering this, first lets understand that generally there are two types of work that are done asynchronously - CPU bound, and I/O bound. A CPU bound work is something that does some heavy computation, like running some formula along a huge set of data. This needs continuous CPU involvement. This will need a dedicated thread, and will generally use a ThreadPool
thread. On the other hand, I/O bound work is something that depends on something outside the CPU system. For example, time to get response from a web service, or to write data to a disk. This type of work may not need any dedicated threads (the network or disk driver may handle it by themselves). Only few time-slices of thread(s) are used just for start/stop/progress notification.
Now that we have understood (at least on a high level), what happens during compilation & run-time, we know the framework is actually doing bunch of additional works to make it work. It is creating task continuation and scheduling them. It is also managing the threads etc. Because of all these, additional time and resource are used. So, it is not advisable to add async
to very lightweight methods, as the overhead might outweigh the benefits.
Captured Context*
So, we talked about “captured context” multiple times. What does it mean actually?
In simple words, it means that the CLR remembers the execution context before suspending the work at await
, and tries to re-apply it on the continuation. So, for example, if the main thread was the UI thread in a WPF
application, the continuation runs on the same UI thread. For ASP.NET
, it might not be the same thread, but it’ll get back the same HttpContext
. On the other hand, say for a Console
application, there is no such context. Then the continuation runs on any thread taken from the ThreadPool
.
Note: Things to remember
[1] It’s the return type that makes it awaitable. Because the method returns Task
or Task<T>
, the method can be await-ed from another method. Not because it has async
in signature, or it uses await
inside it’s body.
[2] Async all the way - if a method towards the end of a calling hierarchy is async
, it’s better to make the whole hierarchy async
, up till the entry point method. This helps in keeping the whole process un-blocked, also the whole code follows same standard. For some discussion, see this and this.
[3] Do not make all methods async - do NOT make all your existing code async
, just becaue you can. As discussed above, that might degrade performance in some scenarios. Can also lead to deadlocks if done incorrectly.
Note: The standard classes provided by the framework for I/O works, all supports async
operations. These are well designed, and should be used in place of the synchronous counterparts whenever they make sense. For example, File.WriteAllBytesAsync()
or HttpClient().GetStreamAsync()
.
Note: To easily share data throughout an asynchronous workflow, you can declare your variables as AsyncLoacal
. These data will be shared across the async flow irrespective of the threads. Also, take a look at ThreadLocal
, which can be used to attach some data with the current thread.
This concludes our discussion on “how does async-await work”. In the Part II of this post, we’ll look at some general usage scenarios and see how to turn a synchronous method to asynchronous step-by-step.
All posts in the series - Tasks, Threads, Asynchronous
- Synchronous to asynchronous in .NET
- Basic task cancellation demo in C#
- How does Async-Await work - Part I
- How does Async-Await work - Part II
- Multithreading - lock, Monitor & Mutex | Thread synchronization Part I
- Multithreading with non-exclusive locks | Thread synchronization Part II
- Multithreading with signals | Thread synchronization Part III
- Non-blocking multithreading & concurrent collections | Thread synchronization Part IV
References
- Async-Await documentation - MSDN, Control flow, TAP - Task-based Asynchronous Pattern, FAQs
- Dissecting the async methods in C#
- How does it work? From StackOverflow QA - CLR implementation, int to Task<int>, The wrong implementation
- Async-Await & threads
- More detailed, in depth discussions by experts - Stephen Cleary, Eric Lippert, Jon Skeet