Revisiting Task.ConfigureAwait(continueOnCapturedContext: false)
Asked Answered
D

3

24

Too long to read. Using Task.ConfigureAwait(continueOnCapturedContext: false) may be introducing redundant thread switching. I'm looking for a consistent solution to that.

Long version. The major design goal behind ConfigureAwait(false) is to reduce redundant SynchronizationContext.Post continuation callbacks for await, where possible. This usually means less thread switching and less work on the UI threads. However, it isn't always how it works.

For example, there is a 3rd party library implementing SomeAsyncApi API. Note that ConfigureAwait(false) is not used anywhere in this library, for some reason:

// some library, SomeClass class
public static async Task<int> SomeAsyncApi()
{
    TaskExt.Log("X1");

    // await Task.Delay(1000) without ConfigureAwait(false);
    // WithCompletionLog only shows the actual Task.Delay completion thread
    // and doesn't change the awaiter behavior

    await Task.Delay(1000).WithCompletionLog(step: "X1.5");

    TaskExt.Log("X2");

    return 42;
}

// logging helpers
public static partial class TaskExt
{
    public static void Log(string step)
    {
        Debug.WriteLine(new { step, thread = Environment.CurrentManagedThreadId });
    }

    public static Task WithCompletionLog(this Task anteTask, string step)
    {
        return anteTask.ContinueWith(
            _ => Log(step),
            CancellationToken.None,
            TaskContinuationOptions.ExecuteSynchronously,
            TaskScheduler.Default);
    }
}

Now, let's say there's some client code running on a WinForms UI thread and using SomeAsyncApi:

// another library, AnotherClass class
public static async Task MethodAsync()
{
    TaskExt.Log("B1");
    await SomeClass.SomeAsyncApi().ConfigureAwait(false);
    TaskExt.Log("B2");
}

// ... 
// a WinFroms app
private async void Form1_Load(object sender, EventArgs e)
{
    TaskExt.Log("A1");
    await AnotherClass.MethodAsync();
    TaskExt.Log("A2");
}

The output:

{ step = A1, thread = 9 }
{ step = B1, thread = 9 }
{ step = X1, thread = 9 }
{ step = X1.5, thread = 11 }
{ step = X2, thread = 9 }
{ step = B2, thread = 11 }
{ step = A2, thread = 9 }

Here, the logical execution flow goes through 4 thread switches. 2 of them are redundant and caused by SomeAsyncApi().ConfigureAwait(false). It happens because ConfigureAwait(false) pushes the continuation to ThreadPool from a thread with synchronization context (in this case, the UI thread).

In this particular case, MethodAsync is better off without ConfigureAwait(false). Then it only takes 2 thread switches vs 4:

{ step = A1, thread = 9 }
{ step = B1, thread = 9 }
{ step = X1, thread = 9 }
{ step = X1.5, thread = 11 }
{ step = X2, thread = 9 }
{ step = B2, thread = 9 }
{ step = A2, thread = 9 }

However, the author of MethodAsync uses ConfigureAwait(false) with all good intentions and following the best practices, and she knows nothing about internal implementation of SomeAsyncApi. It wouldn't be a problem if ConfigureAwait(false) was used "all the way" (i.e., inside SomeAsyncApi too), but that's beyond her control.

That's how it goes with WindowsFormsSynchronizationContext (or DispatcherSynchronizationContext), where we might be not caring about extra thread switches at all. However, a similar situation could happen in ASP.NET, where AspNetSynchronizationContext.Post essentially does this:

Task newTask = _lastScheduledTask.ContinueWith(_ => SafeWrapCallback(action));
_lastScheduledTask = newTask;

The whole thing may look as a contrived issue, but I did see a lot of production code like this, both client-side and server-side. Another questionable pattern I came across: await TaskCompletionSource.Task.ConfigureAwait(false) with SetResult being called on the same synchronization context as that captured for the former await. Again, the continuation was redundantly pushed to ThreadPool. The reasoning behind this pattern was that "it helps to avoid deadlocks".

The question: In the light of the described behavior of ConfigureAwait(false), I'm looking for an elegant way of using async/await while still minimizing redundant thread/context switching. Ideally, something that would work existing 3rd party libraries.

What I've looked at, so far:

  • Offloading an async lambda with Task.Run is not ideal as it introduces at least one extra thread switch (although it can potentially save many others):

    await Task.Run(() => SomeAsyncApi()).ConfigureAwait(false);
    
  • One other hackish solution might be to temporarily remove synchronization context from the current thread, so it won't be captured by any subsequent awaits in the inner chain of calls (I previously mentioned it here):

    async Task MethodAsync()
    {
        TaskExt.Log("B1");
        await TaskExt.WithNoContext(() => SomeAsyncApi()).ConfigureAwait(false);
        TaskExt.Log("B2");
    }
    
    { step = A1, thread = 8 }
    { step = B1, thread = 8 }
    { step = X1, thread = 8 }
    { step = X1.5, thread = 10 }
    { step = X2, thread = 10 }
    { step = B2, thread = 10 }
    { step = A2, thread = 8 }
    
    public static Task<TResult> WithNoContext<TResult>(Func<Task<TResult>> func)
    {
        Task<TResult> task;
        var sc = SynchronizationContext.Current;
        try
        {
            SynchronizationContext.SetSynchronizationContext(null);
            // do not await the task here, so the SC is restored right after
            // the execution point hits the first await inside func
            task = func();
        }
        finally
        {
            SynchronizationContext.SetSynchronizationContext(sc);
        }
        return task;
    }
    

    This works, but I don't like the fact that it tampers with the thread's current synchronization context, albeit for a very short scope. Moreover, there's another implication here: in the absence of SynchronizationContext on the current thread, an ambient TaskScheduler.Current will be used for await continuations. To account for this, WithNoContext could possibly be altered like below, which would make this hack even more exotic:

    // task = func();
    var task2 = new Task<Task<TResult>>(() => func());
    task2.RunSynchronously(TaskScheduler.Default); 
    task = task2.Unwrap();
    

I'd appreciate any other ideas.

Updated, to address @i3arnon's comment:

I would say that it's the other way around because as Stephen said in his answer "The purpose of ConfigureAwait(false) is not to induce a thread switch (if necessary), but rather to prevent too much code running on a particular special context." which you disagree with and is the root of your compliant.

As your answer has been edited, here is your statement I disagreed with, for clarity:

ConfigureAwait(false) goal is to reduce, as much as possible, the work the "special" (e.g. UI) threads need to process in spite of the thread switches it requires.

I also disagree with your current version of that statement. I'll refer you to the primary source, Stephen Toub's blog post:

Avoid Unnecessary Marshaling

If at all possible, make sure the async implementation you’re calling doesn’t need the blocked thread in order to complete the operation (that way, you can just use normal blocking mechanisms to wait synchronously for the asynchronous work to complete elsewhere). In the case of async/await, this typically means making sure that any awaits inside of the asynchronous implementation you’re calling are using ConfigureAwait(false) on all await points; this will prevent the await from trying to marshal back to the current SynchronizationContext. As a library implementer, it’s a best practice to always use ConfigureAwait(false) on all of your awaits, unless you have a specific reason not to; this is good not only to help avoid these kinds of deadlock problems, but also for performance, as it avoids unnecessary marshaling costs.

It does says that the goal is to avoid unnecessary marshaling costs, for performance. A thread switch (which flows the ExecutionContext, among other things) is a big marshaling cost.

Now, it doesn't say anywhere that the goal is to reduce the amount of work which is done on "special" threads or contexts.

While this may make certain sense for UI threads, I still don't think it is the major goal behind ConfigureAwait. There are other - more structured - ways to minimize work on UI threads, like using chunks of await Task.Run(work).

Moreover, it doesn't make sense at all to minimize work on AspNetSynchronizationContext - which itself flows from thread to thread, unlike with a UI thread. Quite opposite, once you're on AspNetSynchronizationContext, you want to make as much work as possible, to avoid unnecessary switching in the middle of handling the HTTP request. Nevertheless, it still makes perfect sense to use ConfigureAwait(false) in ASP.NET: if used correctly, it again reduces the server-side thread switching.

Dhaulagiri answered 9/2, 2015 at 12:39 Comment(25)
How is the amount of thread switches relevant? The amount is exactly the same in all cases (unless a continuation is synchronous which isn't affected by ConfigureAwait). The difference is which threads are involved.Henryhenryetta
@i3arnon, no, it's not the same amount of switches. Remove ConfigureAwait(false), and all continuations are posted asynchronously to WindowsFormsSynchronizationContext, from the UI thread to the same UI thread (via the message loop). Besides the one posted from ThreadPool thread (where Task.Delay ended) to the UI thread, that's a thread switch. Now, add ConfigureAwait(false) back and see one more ThreadPool involved, totally redundant. I.e., the difference is how many additional threads gets involved in the async workflow.Dhaulagiri
The points where a thread switch is possible are the same, the fact that there are some internal optimizations about running continuations synchronously is nothing more than an implementation detail. And checking the thread number while the continuation is already running doesn't tell you whether there was a switch or not. All it tells you is, as you said, how many threads were involved.Henryhenryetta
@i3arnon, how many threads were involved - it's not quite like that. The same pool thread can be involved twice, as happens above for thread 11. Once it servers the completion of Task.Delay, then it returns to the pool, and then again it happens to serve the continuation of ConfigureAwait(false). Because SomeAsyncApi() task completes on the UI thread 9 which has an SC and that's where ConfiguredTaskAwaitable pushes the continuation to a now-available pool thread 11. That's what I mean under redundant switch, and I think I'm failing to explain it any better.Dhaulagiri
That's exactly my point. Every continuation is a potential thread switch since they aren't guaranteed to execute synchronously (it's just an optimization). So while you can have a single ThreadPool thread throughout without switching you can also have many of those. What you should be wary about is marshalling continuations back to the UI thread when this isn't needed.Henryhenryetta
The TPL team must have had a very hard decision to make when they needed to define the default behavior for await. Only bad choices. Either await fails by default in GUI apps, or all libraries do the wrong thing by default. This is probably the nastiest aspect of await.Headman
Is it possible to provide a summary including question and answer?Portraiture
@Pingpong, I did my best on the summary while answering your question. TLTR, my take on this: don't use ConfigureAwait(false) and - if absolutely necessary - use TaskRun(() => SomethingAsync()) to hope off the synchronization context.Dhaulagiri
@Dhaulagiri " if absolutely necessary", Can you elaborate under what circumstances it is necessary? So I know I need to use TaskRun().Portraiture
@Pingpong, e.g., if you're consuming a 3rd party Task-based API, possibly in a closed-source form, and it is causing you problems like dead-locks or UI stuttering. By not relying unconsciously upon using ConfigureAwait(false) throughout your own code, you could be in a better position to find and isolate those "offending" APIs.Dhaulagiri
Very interesting! I am trying now to prove the opposite case, by creating a scenario where omitting the ConfigureAwait(false) could cause more thread switching. :-)Gallager
@TheodorZoulias, it certainly could :) But I think the library code (like that small extension) shouldn't be making assumptions. IMO, that should be a concern and the responsibility of the client code that calls the library, and I prefer using await TaskScheduler.Default.SwitchTo() explicitly for that.Dhaulagiri
Yeap, I like the idea of the TaskScheduler.SwitchTo() concept too. Btw I gave up at trying to create the counterexample. It's not easy, and may not be even possible. :-)Gallager
Relevant discussion on GitHub: Developers can have access to more options when configuring async awaitablesGallager
@TheodorZoulias, that discussion has turned to be quite engaging, tks :) I've just posted there a quick-n-dirty proof of concept of chaining custom waiters with a fluent API, e.g. await Task.Delay(5000).RestoreContext().ForceAsync(), the comment link.Dhaulagiri
@Dhaulagiri nice! TBH I was satisfied with Microsoft's initial proposal of await t.ConfigureAwait(AwaitBehavior.NoThrow | AwaitBehavior.ForceAsync);, since this functionality will probably not be needed too often. It's sad that they decided not to implement any of this, except from the WaitAsync(token) functionality.Gallager
@TheodorZoulias, I feel sad about that too. I wish there was a poll or something to convince them :)Dhaulagiri
From what I've seen recently in this and other threads, it's hard to convince them about anything that they don't already like. And if you manage to convince them, the approval/implementation/commit phase will go at a snail's pace. Maybe they'll revisit the whole idea at 2025 or 2030, and decide to implement it then. 😃Gallager
@TheodorZoulias, indeed! That issue's PR comes from a .NET team member, gets a lot of support in that thread but still is about to get grounded. So I wouldn't even think about investing spare time into a full-featured PR with good unit tests. Maybe, a separate Nuget package, one day. BTW, if you a Twitter handle, it'd be nice to connect there. A lot of great tech content on Twitter.Dhaulagiri
I am not a fan of Twitter honestly. Or of any other social network owned by the high tech giants. :-)Gallager
@TheodorZoulias, I respect your choice 👍 Similar thinking, but I've made an exception for Twitter, for the tech content coming directly from the industry experts. I'm glad I did, it's an integral part of my learning process now, very useful.Dhaulagiri
With ConfigureAwait(false), could B2 have been on a thread other than 11? Or does it have to run on the same thread as whatever thread X1.5 is run on?Converge
@DavidKlempfner, with ConfigureAwait(false) and because of TaskContinuationOptions.ExecuteSynchronously, it'd be the same thread nearly always (#11 in that case). I say "nearly" because there're very rare edge cases were it might not, at least for .NET 4.x: devblogs.microsoft.com/pfxteam/… Not sure if anything has changed in .NET Core/5+.Dhaulagiri
@DavidKlempfner, here's where I currently sit with ConfigureAwait: dev.to/noseratio/why-i-no-longer-use-configureawait-false-3pne. Edited: oh, I think we talked there before 🙂Dhaulagiri
a console app: dotnetfiddle.net/gg2qYnDhaulagiri
R
22

When you're dealing with asynchronous operations, the overhead of a thread switch is way too small to care about (generally speaking). The purpose of ConfigureAwait(false) is not to induce a thread switch (if necessary), but rather to prevent too much code running on a particular special context.

The reasoning behind this pattern was that "it helps to avoid deadlocks".

And stack dives.

But I do think this is a non-problem in the general case. When I encounter code that doesn't properly use ConfigureAwait, I just wrap it in a Task.Run and move on. The overhead of thread switches isn't worth worrying about.

Richel answered 9/2, 2015 at 13:33 Comment(11)
Stephen, I agree a thread switch might be neglect-able for a desktop app, but would you extend this to ASP.NET code, too? Could you provide your opinion on my comments to @i3arnon's answer, tks.Dhaulagiri
It would be negligible in the overall scope of asynchronous operations, but not ideal. Ideally the ConfigureAwait(false) would act as you describe: doing as much work as possible before entering the context.Richel
I didn't mean "induce"; rather "reduce". I added an update to the question with why I still don't think "it's about preventing too much code running on a particular special context". I do agree that Task.Run might be a reasonable compromise here between its cost and simplicity.Dhaulagiri
@Noseratio: Whether the initial intent was to reduce code on a context or not, that's how it actually behaves. I.e., there's no way to ask a SyncCtx to run something sync like a TaskSched does, so ConfigureAwait(false) does end up treating "SyncCtx" specially (it actively avoids any SyncCtx). Regarding ASP.NET, avoiding the SyncCtx as long as possible can enable a small amount of parallelism for that request.Richel
Stephen, a good point. That's how it works indeed if the ante task completes on a thread with SC. And then there is marshaling from that thread to a pool thread. Which ironically is something Stephen Toub suggests in his blog ConfigureAwait should help avoiding :) They removed SwitchTo but left this. I regret that instead of continueOnCapturedContext: false they didn't go with something simple like continueSynchronously: true. It's possible to implement a custom awaiter like that, but that wouldn't change any existing code already using ConfigureAwait, correctly or incorrectly.Dhaulagiri
What does "stack dives" mean? I'm guessing it is the result of one thread inundated with work while others are free. Or is it the opposite of "stack overflow"?? Tried googling and didn't find anything on it :\Serrulation
@Zoomzoom: A stack dive puts a lot of entries on the stack of a single thread. Does not necessarily cause a stack overflow but can make it much more likely.Richel
That you "encountered code that doesn't properly use ConfigureAwait" might only become clear to you when the code is already in production. You cannot know without having the sources and studying them carefully.Brant
@TheodorZoulias: Thanks; I wasn't aware of that!Richel
"When I encounter code that doesn't properly use ConfigureAwait, I just wrap it in a Task.Run and move on." - True, but isn't the issue potentially not knowing if the lib actually uses ConfigureAwait(false)? Even if its open source, it would be cumbersome to review all the async methods. And if the source is not available one would have to choose: Use Task.Run() everywhere or wait until something seems to go wrong. Or do you carefully monitor threads when using third party async libs for the first time?Tiruchirapalli
@Rev: I wrote that > 7.5 years ago. But in this case (UI app), a lack of CA(f) is usually obvious when the app deadlocks. But I don't block on asynchronous code in the first place, so I almost never get in this situation anymore; it only comes up in legacy code.Richel
H
8

The major design goal behind ConfigureAwait(false) is to reduce redundant SynchronizationContext.Post continuation callbacks for await, where possible. This usually means less thread switching and less work on the UI threads.

I disagree with your premise. ConfigureAwait(false) goal is to reduce, as much as possible, the work that needs to be marshalled back to "special" (e.g. UI) contexts in spite of the thread switches it may require off of that context.

If the goal was to reduce thread switches you could just remain in the same special context throughout all the work, and then no other threads are required.

To achieve that you should be using ConfigureAwait everywhere you don't care about the thread executing the continuation. If you take your example and use ConfigureAwait appropriately you would only get a single switch (instead of 2 without it):

private async void Button_Click(object sender, RoutedEventArgs e)
{
    TaskExt.Log("A1");
    await AnotherClass.MethodAsync().ConfigureAwait(false);
    TaskExt.Log("A2");
}

public class AnotherClass
{
    public static async Task MethodAsync()
    {
        TaskExt.Log("B1");
        await SomeClass.SomeAsyncApi().ConfigureAwait(false);
        TaskExt.Log("B2");
    }
}

public class SomeClass
{
    public static async Task<int> SomeAsyncApi()
    {
        TaskExt.Log("X1");
        await Task.Delay(1000).WithCompletionLog(step: "X1.5").ConfigureAwait(false);
        TaskExt.Log("X2");
        return 42;
    }
}

Output:

{ step = A1, thread = 9 }
{ step = B1, thread = 9 }
{ step = X1, thread = 9 }
{ step = X1.5, thread = 11 }
{ step = X2, thread = 11 }
{ step = B2, thread = 11 }
{ step = A2, thread = 11 }

Now, where you do care about the continuation's thread (e.g. when you use UI controls) you "pay" by switching to that thread, by posting the relevant work to that thread. You've still gained from all the work that didn't require that thread.

If you want to take it even further and remove the synchronous work of these async methods from the UI thread you only need to use Task.Run once, and add another switch:

private async void Button_Click(object sender, RoutedEventArgs e)
{
    TaskExt.Log("A1");
    await Task.Run(() => AnotherClass.MethodAsync()).ConfigureAwait(false);
    TaskExt.Log("A2");
}

Output:

{ step = A1, thread = 9 }
{ step = B1, thread = 10 }
{ step = X1, thread = 10 }
{ step = X1.5, thread = 11 }
{ step = X2, thread = 11 }
{ step = B2, thread = 11 }
{ step = A2, thread = 11 }

This guideline to use ConfigureAwait(false) is directed at library developers because that's where it actually matters, but the point is to use it whenever you can and in that case you reduce the work on these special contexts while keeping thread switching at a minimum.


Using WithNoContext has exactly the same outcome as using ConfigureAwait(false) everywhere. The cons however is that it messes with the thread's SynchronizationContext and that you aren't aware of that inside the async method. ConfigureAwait directly affects the current await so you have the cause and effect together.

Using Task.Run too, as I've pointed out, has exactly the same outcome of using ConfigureAwait(false) everywhere with the added value of offloading the synchronous parts of the async method to the ThreadPool. If this is needed, then Task.Run is appropriate, otherwise ConfigureAwait(false) is enough.


Now, If you're dealing with a buggy library when ConfigureAwait(false) isn't used appropriately, you can hack around it by removing the SynchronizationContext but using Thread.Run is much simpler and clearer and offloading work to the ThreadPool has a very negligible overhead.

Henryhenryetta answered 9/2, 2015 at 13:26 Comment(21)
I disagree with your premise. ConfigureAwait(false) goal is to reduce, as much as possible, the work the "special" (e.g. UI) threads need to process in spite of the thread switches it requires. - I disagree about that :)Dhaulagiri
There is no "special" thread in ASP.NET, but using ConfigureAwait(false) still improves the performance there, because await continuations are executed immediately on the same thread where the antecedent task has ended. Rather than get queued with ContinueWith to yet another pool thread. Refer to AspNetSynchronizationContext.Post implementation, there's a link in my answer.Dhaulagiri
@Noseratio Yes, there are. They are special because of the context they have for example HttpContext.Current (I updated to make that clear). Also, continuations used with ContinueWith may also run on the same thread synchronously.Henryhenryetta
So, if already I don't care about HttpContext.Current etc (because I use ConfigureAwait(false) in my library, in the first place), the question remains. How do I use ConfigureAwait(false) in my library correctly, so it doesn't cause redundant thread switches in ASP.NET, which might hurt server-side performance (and avoid the scenario I described)?Dhaulagiri
@Noseratio As I explained in my answer. Use ConfigureAwait(false) all the way (just as you use async all the way) and so you would only have a single switch at the most per root async operation (assuming all continuations run synchronously`)Henryhenryetta
I did mention in the question why "all the way" is not an option, albeit it's the right thing. I'm inclined to use await Task.Run(() => SomeAsyncApi()).ConfigureAwait(false) though. It predictably adds a thread switch, but that would still give me advantage if SomeAsyncApi has a lot of await without ConfigureAwait(false). As opposed, await WithNoContext(() => SomeAsyncApi()).ConfigureAwait(false) doesn't add a thread switch, that's how it's different, but it's indeed a hack.Dhaulagiri
Also sorry for being picky about this: also, continuations used with ContinueWith may also run on the same thread synchronously. While this is true - they may indeed - but it's never the case for the current AspNetSynchronizationContext.Post implementation. They just don't use ExecuteSynchronously, so it's always queued to ThreadPool. It was a surprise to me, and I questioned it here.Dhaulagiri
@Noseratio I was talking about ContinueWith in general. About resuming on specific SynchronizationContexts, they are never guaranteed to completely synchronous. Even those that are could be changed in the future. Synchronous continuations in this case are an implementation detail optimization.Henryhenryetta
Right, nothing is ever guaranteed. It's just sad that effective use of ConfigureAwait(false) - as current implementation detail - is at the mercy of any 3rd Task-based API I might be using in my library, and there is no perfect remedy. And the fact that such API doesn't use ConfigureAwait(false) "all the way" doesn't make it buggy, IMO.Dhaulagiri
@Noseratio The effective use of ConfigureAwait(false) is not to resume on a captured context and in that it isn't in the mercy of anyone other than you writing your code. If some library does capture the context unnecessarily and in that degrade performance I would call that a bug, but it doesn't negate your use of ConfigureAwait(false)Henryhenryetta
i3arnon, I almost feel like I'm talking about the forest, while you're talking about the trees, pardon the pun :)Dhaulagiri
@Noseratio I would say that it's the other way around because as Stephen said in his answer "The purpose of ConfigureAwait(false) is not to induce a thread switch (if necessary), but rather to prevent too much code running on a particular special context." which you disagree with and is the root of your compliant.Henryhenryetta
I added a special update to the question, just to address your last comment :)Dhaulagiri
@Noseratio and still. This isn't the goal of ConfigureAwait(false) just as "avoiding deadlocks" isn't (while both are good side effects). The important part of that quote is "using ConfigureAwait(false) on all await points; this will prevent the await from trying to marshal back to the current SynchronizationContext. As a library implementer, it’s a best practice to always use ConfigureAwait(false) on all of your awaits, unless you have a specific reason not to". The performance point is to not marshal back to the UI thread when it's unnecessary.Henryhenryetta
Nothing guarantees no "thread switching" between ThreadPool threads (which of course are reduced as much as possible as an optimization)Henryhenryetta
@Henryhenryetta - note that await task.ConfigureAwait(false) does not guarantee the code that follows is not run on the original context. See the paragraph containing "awaits on already-completed awaitables just keep running past the await synchronously". I conclude this isn't about "avoiding doing work on special contexts" - that work will sometimes happen anyway. This implies it is about reducing unnecessary context switches (when any context will work fine). Though its also possible Microsoft has muddied their thinking here...Krueger
... OTOH, what you quote from Microsoft re library implementers is indeed about keeping code off of special contexts. I suspect the original idea was to avoid unnecessary context switches, and then it was discovered that there were more important benefits than that minor optimization - library code that doesn't cause UI lag; avoid deadlocks.Krueger
@Krueger There are 2 different mechanisms here. The reason to make awaiting on completed tasks synchronous is indeed to avoid an unnecessary context switch (and ThreadPool queueing). That's not however the reason for ConfigureAwait..Henryhenryetta
OK, I understand that ConfigureAwait only affects the await it is "coupled with". What if I have three async methods MethodA->MethodB->MethodC that are called in this order. MethodA is called from a WinForms app, MethodB uses ConfigureAwait(false) to await MethodC. But the library author of MethodC neglected to use ConfigureAwait(false). Will the "continuation" of MethodC run on a different SC than the call from MethodA because MethodB already "changed" the SC or will it run on the original SC and potentially still cause the issues we are trying to avoid with ConfigureAwait(false)?Tiruchirapalli
@Tiruchirapalli You using ConfigureAwait(false) in MethodB does not affect MethodC. If the library author neglected their duties and you call their API while on the UI thread.. then the continuation will run on the UI thread as the await will capture it. To avoid that you can clear the SC before calling or just offload the call to the ThreadPool.Henryhenryetta
OK, that is what I assumed. So in the end we have to evaluate if async third party library calls work as expected if source code is not available. Tempts people to use Task.Run() as a preventive measure everywhere.Tiruchirapalli
G
2

Apparently the behavior of the built-in ConfigureAwait(false) is to invoke the continuation of the await on the ThreadPool. The reason for this, I assume, is to prevent a situation where multiple asynchronous workflows are awaiting the same incomplete task, and then their continuations are invoked on the same thread, in a serialized fashion. This scenario could potentially lead to deadlocks, in case the continuation of one workflow blocked, and waited for a signal from another workflow. The other workflow would never have a chance to send the signal, because its continuation would be sitting in the waiting queue of the same (blocked) thread.

If you don't anticipate this scenario to occur in your application (if you are sure than a task can never be awaited by two workflows), then you could try using the custom ConfigureAwait2 method below:

public static ConfiguredTaskAwaitable2 ConfigureAwait2(this Task task,
    bool continueOnCapturedContext)
    => new ConfiguredTaskAwaitable2(task, continueOnCapturedContext);

public struct ConfiguredTaskAwaitable2 : INotifyCompletion
{
    private readonly Task _task;
    private readonly bool _continueOnCapturedContext;

    public ConfiguredTaskAwaitable2(Task task, bool continueOnCapturedContext)
    {
        _task = task; _continueOnCapturedContext = continueOnCapturedContext;
    }
    public ConfiguredTaskAwaitable2 GetAwaiter() => this;
    public bool IsCompleted { get { return _task.IsCompleted; } }
    public void GetResult() { _task.GetAwaiter().GetResult(); }
    public void OnCompleted(Action continuation)
    {
        var capturedContext = _continueOnCapturedContext ?
            SynchronizationContext.Current : null;
        _ = _task.ContinueWith(_ =>
        {
            if (capturedContext != null)
                capturedContext.Post(_ => continuation(), null);
            else
                continuation();
        }, default, TaskContinuationOptions.ExecuteSynchronously,
            TaskScheduler.Default);
    }
}

I substituted the .ConfigureAwait(false) with .ConfigureAwait2(false) in your example (inside the method MethodAsync), and I got this output:

{ step = A1, thread = 1 }
{ step = B1, thread = 1 }
{ step = X1, thread = 1 }
{ step = X1.5, thread = 4 }
{ step = X2, thread = 1 }
{ step = B2, thread = 1 }
{ step = A2, thread = 1 }
Gallager answered 15/2, 2021 at 11:30 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.