Table of Contents
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.
- The current
SynchronizationContext
is set toMySynchronizationContext
. - The
Main
method callsMethodA
.MethodA
runs until the await point. MethodA
callsMethodB
.MethodB
also runs until the await.MethodB
callsTask.Delay
.Task.Delay
returns (synchronously) an incomplete Task (TaskOne
) toMethodB
.TaskOne
represents the completion state ofTask.Delay
.MethodB
sets the continuation on the Task (or on the task awaiter to be precise).- If we don’t want
MethodB
’s continuation to run in the current Synchronization Context (MySynchronizationContext
), we setConfigureAwait(false)
onTaskOne
. Otherwise, the context is captured as it’s needed for when the execution resumes. MethodB
returns an incomplete Task (TaskTwo
) toMethodA
. This Task represents the completion state ofMethodB
.MethodA
sets the continuation on the Task (or on the task awaiter to be precise).MethodA
does the same gymnastics asMethodB
described in point 6. It may not captureMySynchronizationContext
depending on whether it setsConfigureAwait(false)
onTaskTwo
.MethodA
returns an incomplete Task toMain
(TaskThree
).TaskThree
represents the completion state ofMethodA
.- The
Main
method (Main Thread) blocks synchronously untilTaskThree
completes. - Once
Task.Delay
resumes,TaskOne
is completed, and a thread from the Thread Pool invokesMethodB
’s continuation. - If we haven’t used
ConfigureAwait(false)
onTaskOne
, 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. MethodA
’s continuation is invoked. Similarly toMethodB
, if we haven’t usedConfigureAwait(false)
onTaskTwo
, the continuation will be posted to the captured Synchronization Context –MySynchronizationContext
. Note that this doesn’t depend in any way on the continuation context ofMethodB
e.g. whether we’ve usedConfigureAwait(false)
inMethodB
(onTaskOne
) or not.- Once
MethodA
’s continuation finishes,TaskThree
is set as completed. The Main Thread unblocks and finishes the execution of the Main method. - 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!