Async methods and awaiting in detail

It is important to understand that async methods like SumPageSizesAsync do not run on their own thread. In fact if you write an async method without any await’s in it, it will be completely synchronous:

public async Task<int> TenToSevenAsync() {
    Thread.Sleep(10000);
    return 7;
}
Public Async Function TenToSevenAsync() As Task(Of Integer)
    Thread.Sleep(10000)
    Return 7
End Function

If you call this method you will be blocked for ten seconds and then get an already completed Task<int> back with a result of 7. This is probably not what you expect, and async methods without await’s do yield a warning because they are almost never what you want.

Only when an async method gets to the first await will it return to its original caller. Even then, in fact, it won’t return until it await’s a task that is not yet complete. This means that you should write your async methods so that they do not do a lot of work, or perform blocking calls, before their first await – or indeed between await’s. Async methods are for doing things that don’t need a lot of thread time. If you want to get intensive work or unavoidable blocking calls off your thread – e.g. off the UI thread – you should explicitly put them on the thread pool using Task.Run.

Instead the right way to write the above method uses await:

public async Task<int> TenToSevenAsync() {
    await Task.Delay(10000);
    return 7;
}
Public Async Function TenToSevenAsync() As Task(Of Integer)
    Await Task.Delay(10000)
    Return 7
End Function

Task.Delay is essentially the asynchronous version of Thread.Sleep: it returns a Task which completes after the specified amount of time. Given that, let us look in detail at how this very simple async method runs.

When the async method is first invoked, a Task<int> is immediately created. The method then executes up to the first (and in this case only) await. This means evaluating the “operand” to await, i.e. the call to Task.Delay, which quickly returns us an as-yet uncompleted Task.

Now we await that Task. This involves a number of steps, which are undertaken in an intricate collaboration between the compiler-generated code and the Task type:

  1. We check to see if the Task is already completed. If so, there’s nothing to wait for: we can just keep going. In this example, however, it is reasonable to assume that it is not yet completed.
  2. We capture the context we are running in. To a loose approximation this means a representation of where we are running. (Typically this is represented either as a SynchronizationContext or as a TaskScheduler, but you probably didn’t want to know that). Let us say that we were called on the UI thread: the context then represents that fact.
  3. We now sign up a callback to the Task, saying “when you complete, please do ‘this’.” We’ll see in a bit what ‘this’ actually does.
  4. Finally we return to the caller. Since this is the first await, we are returning to the original caller of TenToSevenAsync, and we make sure to return them the Task<int> that we started out by creating.
  5. About ten seconds later the Task we got from Task.Delay completes. It checks to see if it had any callbacks registered in the meantime. Indeed it has ours, which it calls.
  6. This probably happens on some kind of system thread that has to do with timers – a thread we don’t care about and definitely don’t want to be resumed on. However, all the callback does here is to ask the context that we captured before to resume the method execution there. So we quickly get off of the “random” thread that resumed us.
  7. The context schedules the resumption of the method according to its specific scheduling semantics. For the UI context this means queuing up the work in its message queue, from where it soon gets pumped onto the UI thread to be executed there.
  8. Back on the UI thread, the compiler-generated resumption code – often known as the continuation – knows how to jump into the middle of the execution of our method, right where we left off ten seconds before. In fact the compiler turned the async method into a state machine object just so that it would be able to do this. (This transformation is similar to what is done to implement iterators in C#.)
  9. The last action of the await expression is to consume the await’ed task’s result. In this case the task doesn’t produce a result, but we still check to see if the task was faulted – i.e. ended up completing with an exception. If that is the case, the exception is thrown again from the await expression.

We are now back in our method’s code following the await expression, and next up is the return statement, which also has special meaning in an async method. While it does return (in our case to the message pump that put us on the UI thread), it doesn’t return to our original caller: we already did that previously! Instead the return statement completes the already-returned Task<int> with a final result: the value seven.

The execution of the method is now finally complete. If the calling code signed up on the Task<int> in the meantime – e.g. by await’ing it – these callbacks in turn will now get invoked, and the cycle continues.

The execution of await looks – and is – somewhat complex. While the observable result is simple (you “wait” for something without occupying a thread), there is quite a bit of work going on under the hood to make it work. It is worth remembering that async methods are an alternative to having long-blocking calls. In comparison to the cost that such blocking incurs, the overhead of the bookkeeping involved in executing async methods is typically insignificant.

One Comment