Search code examples
c#asynchronousasync-awaittasksynchronizationcontext

Is a continuation created even if there is nothing after an await?


I was reading this article and found this example:

public static class DeadlockDemo
{
  private static async Task DelayAsync()
  {
    await Task.Delay(1000);
  }
  // This method causes a deadlock when called in a GUI or ASP.NET context.
  public static void Test()
  {
    // Start the delay.
    var delayTask = DelayAsync();
    // Wait for the delay to complete.
    delayTask.Wait();
  }
}

The root cause of this deadlock is due to the way await handles contexts. By default, when an incomplete Task is awaited, the current “context” is captured and used to resume the method when the Task completes. This “context” is the current SynchronizationContext unless it’s null, in which case it’s the current TaskScheduler. GUI and ASP.NET applications have a SynchronizationContext that permits only one chunk of code to run at a time. When the await completes, it attempts to execute the remainder of the async method within the captured context. But that context already has a thread in it, which is (synchronously) waiting for the async method to complete. They’re each waiting for the other, causing a deadlock.

I understand that if there was code after await Task.Delay(1000);, it would be part of the continuation, and that continuation would be running in the same context as Test(), and that's how the deadlock would happen.

However, there is no continuation, so how does the deadlock actually happen?

Or, is there an empty continuation that is created? What would be the point in that?

This is the part that is confusing me:

When the await completes, it attempts to execute the remainder of the async method within the captured context.

What is the "remainder of the async method"?


Solution

  • If we're an async Task or async Task<T> method, then there's always work to do after the await - we need to ensure that any exception produced by the awaited task is properly propagated to our own Task, or that we pass the appropriate normal return value through.

    If we're any kind of async method using structures such as using which insert compiler-generated code at the end of an otherwise appearing empty epilogue to our method, then we'll be inserting code at the end of the method, even if it doesn't appear in the source.

    If we're any normal async method that liberally uses awaits then we'll already have a state machine built and running to execute our method and there's no point in optimizing the "no code after the last await" possibility.

    In the narrow case that we're an async void1 method that contains a single await at the end, then other than some minutiae about where an unhandled exception from a task might be reported, we already had the opportunity to avoid excess code generation by not making the method async at all and just ignoring the awaitable.

    So there's no reason to try to optimize this situation.


    1We're already in a state of sin at that point anyway.