Exploring the async/await State Machine – Nested Async Calls and ConfigureAwait(false)

Intro

In the last article, you’ve explored the concept of Synchronization Context and its’ behavior.

You’ve also seen that you can set ConfigureAwait(false) on the Task to prevent posting continuations to the captured context. This is especially important when building libraries.

Even if you know you have to apply ConfigureAwait(false) for your use case, some uncertainties remain when dealing with a chain of nested calls:

Should you set ConfigureAwait(false) on the first async call? Or the last one? Or maybe everywhere?

Here is one of the many SO questions on the topic.

The answer is simple – you need it everywhere.

What’s more important, though, is a precise understanding of the async/await workflow in nested async calls. Armed with this knowledge, the proper usage of ConfigureAwait will become quite intuitive.

In this article, we’ll dissect the rules of nested async methods execution by building a small program and examining the diagram below:

If this sounds exciting – let’s dive in!

This article’s content is influenced by the async/await material in the book C# in Depth by Jon Skeet and the blog posts ConfigureAwait FAQ (Stephen Toub) and Dissecting the async methods in C# (Sergey Tepliakov).

For additional in-depth materials on async/await in C#, you can check this Pluralsight course.

The Sample Code

First, I’d like to present a small piece of code with some nested async calls. This will help us experiment with setting ConfigureAwait(false) in different places and observing the behavior.

Note that I have built the diagram from Figure 1 based on the methods and interactions in this small program, so the discussion is as concrete as possible.

Please spend a moment to review the sample code below:

class Program
{
    static void Main(string[] args)
    {
        SynchronizationContext.SetSynchronizationContext(new MyContext());
        MethodA().Wait();
        Console.WriteLine("Completed");
    }
    
    private class MyContext : SynchronizationContext
    {
        public override void Post(SendOrPostCallback d, object state)
        {
            Console.WriteLine($"{nameof(MyContext)} invoked");
            d(state);
        }
    }

    public static async Task MethodA()
    {
        await MethodB();
        Console.WriteLine("MethodA Continuation");
    }
    
    public static async Task MethodB()
    {
        await Task.Delay(100);
        Console.WriteLine("MethodB Continuation");
    }
}

I’ve implemented and set a custom Synchronization Context (MySynchronizationContext). All it does is print a message to the console. This allows us to track if (when) the Synchronization Context is invoked when putting ConfigureAwait(false) at different positions.

I hope the code above is quite clear. If you have any uncertainties regarding Synchronization Context, when and how it’s created, set, and invoked, I encourage you to revisit my article on the topic.

The output of this program is:

MyContext invoked
MethodB Continuation
MyContext invoked
MethodA Continuation
Completed

The important thing to notice here is that MySynchronizationContext is invoked twice – once for MethodA’s continuation and once for MethodB’s continuation.

Now, it’s time to start throwing some ConfigureAwait(false) statements and see how that changes.

ConfigureAwait(false) On The First Async Call

Let’s try setting ConfigureAwait(false) in MethodA:

public static async Task MethodA()
{
    await MethodB().ConfigureAwait(false);
    Console.WriteLine("MethodA Continuation");
}

public static async Task MethodB()
{
    await Task.Delay(100);
    Console.WriteLine("MethodB Continuation");
}

The output is:

MyContext invoked
MethodB Continuation
MethodA Continuation
Completed

So, the Synchronization Context is called only once – for the continuation of MethodB. Although we set ConfigureAwait(false) in the first async call in the chain, it doesn’t just propagate through the rest of the execution.

Let’s see what happens if we set ConfigureAwait(false) only in MethodB.

ConfigureAwait(false) On The Last Async Call

What do you think the code below will output?

public static async Task MethodA()
{
    await MethodB();
    Console.WriteLine("MethodA Continuation");
}

public static async Task MethodB()
{
    await Task.Delay(100).ConfigureAwait(false);
    Console.WriteLine("MethodB Continuation");
}

The answer is:

MethodB Continuation
MyContext invoked
MethodA Continuation
Completed

So, the Synchronization Context is again invoked once – this time running the MethodA’s continuation.

This means setting ConfigureAwait(false) on the last async call in the chain doesn’t affect the context of the other continuations as well.

You already know that we need to set ConfigureAwait(false) in every async call. Let’s see it in action.

ConfigureAwait(false) Everywhere

Let’s set ConfigureAwait(false) both in MethodA and MethodB:

public static async Task MethodA()
{
    await MethodB().ConfigureAwait(false);
    Console.WriteLine("MethodA Continuation");
}

public static async Task MethodB()
{
    await Task.Delay(100).ConfigureAwait(false);
    Console.WriteLine("MethodB Continuation");
}

This outputs:

MethodB Continuation
MethodA Continuation
Completed

We already expected that no continuations would be posted to MySynchronizationContext.

The more important part is to understand the rules behind this behavior.

Let’s do that next.

Nested Async Calls – Workflow Diagram

It’s time to closely inspect the async/await workflow for our sample program.

Please bear in mind the diagram below is simplified and not 100% technically accurate.

If you’ve followed the series so far, especially the articles on the async/await State Machine and The Awaitable Pattern, you already know that MethodA and MethodB will be transformed to corresponding State Machines.

I find it valuable to understand how these State Machines behave and the essence of awaiters and continuations.

However, such in-depth knowledge is not required for the purpose of this post.

You should be able to understand this article in isolation, although you may need at least some familiarity with the async/await machinery.

Now, let me copy Figure 1 and explain each of the numbered steps of the workflow.

  1. The current SynchronizationContext is set to MySynchronizationContext.
  2. The Main method calls MethodA. MethodA runs until the await point.
  3. MethodA calls MethodB. MethodB also runs until the await.
  4. MethodB calls Task.Delay.
  5. Task.Delay returns (synchronously) an incomplete Task (TaskOne) to MethodB. TaskOne represents the completion state of Task.Delay. MethodB sets the continuation on the Task (or on the task awaiter to be precise).
  6. If we don’t want MethodB’s continuation to run in the current Synchronization Context (MySynchronizationContext), we set ConfigureAwait(false) on TaskOne. Otherwise, the context is captured as it’s needed for when the execution resumes.
  7. MethodB returns an incomplete Task (TaskTwo) to MethodA. This Task represents the completion state of MethodB. MethodA sets the continuation on the Task (or on the task awaiter to be precise).
  8. MethodA does the same gymnastics as MethodB described in point 6. It may not capture MySynchronizationContext depending on whether it sets ConfigureAwait(false) on TaskTwo.
  9. MethodA returns an incomplete Task to Main (TaskThree). TaskThree represents the completion state of MethodA.
  10. The Main method (Main Thread) blocks synchronously until TaskThree completes.
  11. Once Task.Delay resumes, TaskOne is completed, and a thread from the Thread Pool invokes MethodB’s continuation.
  12. If we haven’t used ConfigureAwait(false) on TaskOne, the continuation will be posted to the captured Synchronization Context – MySynchronizationContext. This is where the continuation can potentially be scheduled to another Thread, like the UI Thread. Of course, in our example, MySynchronizationContext doesn’t contain any special logic, so that the workflow will continue on the Thread Pool Thread.
  13. MethodA’s continuation is invoked. Similarly to MethodB, if we haven’t used ConfigureAwait(false) on TaskTwo, the continuation will be posted to the captured Synchronization Context – MySynchronizationContext. Note that this doesn’t depend in any way on the continuation context of MethodB e.g. whether we’ve used ConfigureAwait(false) in MethodB (on TaskOne) or not.
  14. Once MethodA’s continuation finishes, TaskThree is set as completed. The Main Thread unblocks and finishes the execution of the Main method.
  15. Program completes.

If you’re interested in the inner working of the steps above, you can explore that in the articles on the Conceptual and Concrete State Machine implementations.

Is The Default ConfigureAwait(true) A Design Flaw?

Many people claim that ConfigureAwait(false) should’ve been the default behavior. The main argument is that the whole need for a Synchronization Context is restricted to some specific areas like UI apps and the old ASP.NET HttpContext. In all other cases, we don’t care about Synchronization Contexts at all.

Due to the default behavior ConfigureAwait(true), library authors are essentially forced to throw ConfigureAwait(false) all over their code. This feels quite awkward.

There are, of course, opposite opinions. If ConfigureAwait(false) was the default, code like the following one wouldn’t work:

private async void btn1_Click(object sender, EventArgs e)
{
    await Task.Delay(1000);
    myControl.text = "After Await";
}

In this case, programmers would need to add a ConfigureAwait(true) statement to the Task, which would also add some frustration.

What seems to be sure is that we’ll need to accept and understand the current rules at least for a while. I hope this article helps with that.

Summary

This article was about unraveling the mysteries around nested asynchronous calls. You explored a very detailed workflow diagram with all the steps throughout the execution.

You also saw an in-depth explanation of how the Synchronization Context is captured and propagated throughout the async call chain.

This also helps to understand precisely why and when we need to use ConfigureAwait(false) in our libraries.

I hope this material was helpful. Stay tuned, and thanks for reading!

Resources

  1. C# in Depth: Fourth Edition
  2. Dissecting the async methods in C#
  3. ConfigureAwait FAQ

Site Footer

Subscribe To My Newsletter

Email address