ConfigureAwait pushes the continuation to a pool thread
Asked Answered
G

3

11

Here is some WinForms code:

async void Form1_Load(object sender, EventArgs e)
{
    // on the UI thread
    Debug.WriteLine(new { where = "before", 
        Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.IsThreadPoolThread });

    var tcs = new TaskCompletionSource<bool>();

    this.BeginInvoke(new MethodInvoker(() => tcs.SetResult(true)));

    await tcs.Task.ContinueWith(t => { 
        // still on the UI thread
        Debug.WriteLine(new { where = "ContinueWith", 
            Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.IsThreadPoolThread });
    }, TaskContinuationOptions.ExecuteSynchronously).ConfigureAwait(false);

    // on a pool thread
    Debug.WriteLine(new { where = "after", 
        Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.IsThreadPoolThread });
}

The output:

{ where = before, ManagedThreadId = 10, IsThreadPoolThread = False }
{ where = ContinueWith, ManagedThreadId = 10, IsThreadPoolThread = False }
{ where = after, ManagedThreadId = 11, IsThreadPoolThread = True }

Why does ConfigureAwait pro-actively push the await continuation to a pool thread here?

I use "pushing to a pool thread" here to describe the case when the primary continuation callback (the action parameter to TaskAwaiter.UnsafeOnCompleted has been invoked on one thread, but the secondary callback (the one passed to ConfiguredTaskAwaiter.UnsafeOnCompleted) is queued to a pool thread.

The docs say:

continueOnCapturedContext ... true to attempt to marshal the continuation back to the original context captured; otherwise, false.

I understand there's WinFormsSynchronizationContext installed on the current thread. Still, there is no attempt to marshal to be made, the execution point is already there.

Thus, it's more like "never continue on the original context captured"...

As expected, there's no thread switch if the execution point is already on a pool thread without a synchronization context:

await Task.Delay(100).ContinueWith(t => 
{ 
    // on a pool thread
    Debug.WriteLine(new { where = "ContinueWith", 
        Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.IsThreadPoolThread });
}, TaskContinuationOptions.ExecuteSynchronously).ConfigureAwait(false);
{ where = before, ManagedThreadId = 10, IsThreadPoolThread = False }
{ where = ContinueWith, ManagedThreadId = 6, IsThreadPoolThread = True }
{ where = after, ManagedThreadId = 6, IsThreadPoolThread = True }

Updated, one more test to see if any sync. context is not good enough for continuation (rather than the original one). This is indeed the case:

class DumbSyncContext: SynchronizationContext
{
}

// ...

Debug.WriteLine(new { where = "before", 
    Thread.CurrentThread.ManagedThreadId, 
    Thread.CurrentThread.IsThreadPoolThread });

var tcs = new TaskCompletionSource<bool>();

var thread = new Thread(() =>
{
    Debug.WriteLine(new { where = "new Thread",                 
        Thread.CurrentThread.ManagedThreadId,
        Thread.CurrentThread.IsThreadPoolThread});
    SynchronizationContext.SetSynchronizationContext(new DumbSyncContext());
    tcs.SetResult(true);
    Thread.Sleep(1000);
});
thread.Start();

await tcs.Task.ContinueWith(t => {
    Debug.WriteLine(new { where = "ContinueWith",
        Thread.CurrentThread.ManagedThreadId,
        Thread.CurrentThread.IsThreadPoolThread});
}, TaskContinuationOptions.ExecuteSynchronously).ConfigureAwait(false);

Debug.WriteLine(new { where = "after", 
    Thread.CurrentThread.ManagedThreadId, 
    Thread.CurrentThread.IsThreadPoolThread });
{ where = before, ManagedThreadId = 9, IsThreadPoolThread = False }
{ where = new Thread, ManagedThreadId = 10, IsThreadPoolThread = False }
{ where = ContinueWith, ManagedThreadId = 10, IsThreadPoolThread = False }
{ where = after, ManagedThreadId = 6, IsThreadPoolThread = True }
Grison answered 26/3, 2014 at 21:2 Comment(1)
Related: Revisiting Task.ConfigureAwait(continueOnCapturedContext: false).Grison
P
18

Why ConfigureAwait pro-actively pushes the await continuation to a pool thread here?

It doesn't "push it to a thread pool thread" as much as say "don't force myself to come back to the previous SynchronizationContext".

If you don't capture the existing context, then the continuation which handles the code after that await will just run on a thread pool thread instead, since there is no context to marshal back into.

Now, this is subtly different than "push to a thread pool", since there isn't a guarantee that it will run on a thread pool when you do ConfigureAwait(false). If you call:

await FooAsync().ConfigureAwait(false);

It is possible that FooAsync() will execute synchronously, in which case, you will never leave the current context. In that case, ConfigureAwait(false) has no real effect, since the state machine created by the await feature will short circuit and just run directly.

If you want to see this in action, make an async method like so:

static Task FooAsync(bool runSync)
{
   if (!runSync)
       await Task.Delay(100);
}

If you call this like:

await FooAsync(true).ConfigureAwait(false);

You'll see that you stay on the main thread (provided that was the current context prior to the await), since there is no actual async code executing in the code path. The same call with FooAsync(false).ConfigureAwait(false); will cause it to jump to thread pool thread after execution, however.

Perusse answered 26/3, 2014 at 21:8 Comment(6)
Do you think the reuse of the threadpool thread in the final example was just luck or is there some kind of optimization happening there too?Anthill
I actually tried await Task.FromResult(true).ConfigureAwait(false) and the sync. behavior does make sense. What I don't understand why an existing thread with some context is not good enough for plain continuation...Grison
@ScottChamberlain That's just luck - it'll be on "some" thread pool thread.Perusse
@Noseratio Any SynchronizationContext is good enough to marshal back to - it's not a matter of "threads" as much as the sync context. For example, in an ASP.Net app, it will be the same sync context, but a different thread (potentially) [when not using ConfigureAwait]Perusse
@ReedCopsey, any context is good enough to marshal back if I do ConfigureAwait(true), that's understood. But conversely, any context is not good enough to continue on, if I do ConfigureAwait(false), correct? If I happen to be on a thread with any s.context upon resuming, I'm going to be pushed to a pool thread. That's the rule I'm trying to clarify for myself.Grison
@Noseratio You need to stop thinking in terms of "threads" - a SynchronizationContext may be a thread (if there's a message pump on it), or not. A ThreadPool thread has no synchronization context in place, so there's nothing to capture.Perusse
G
13

Here is the explanation of this behavior based on digging the .NET Reference Source.

If ConfigureAwait(true) is used, the continuation is done via TaskSchedulerAwaitTaskContinuation which uses SynchronizationContextTaskScheduler, everything is clear with this case.

If ConfigureAwait(false) is used (or if there's no sync. context to capture), it is done via AwaitTaskContinuation, which tries to inline the continuation task first, then uses ThreadPool to queue it if inlining is not possible.

Inlining is determined by IsValidLocationForInlining, which never inlines the task on a thread with a custom synchronization context. It however does the best to inline it on the current pool thread. That explains why we're pushed on a pool thread in the first case, and stay on the same pool thread in the second case (with Task.Delay(100)).

Grison answered 26/3, 2014 at 22:52 Comment(1)
Great answer. It wasn't immediately obvious to me (it probably should have been), so let me expand on why we can't just inline unconditionally, and on what is a "valid" location. Reading AwaitTaskContinuation.Run, one can see a comment that suggests an explanation. As it turns out, any thread not "in" the default sync context is implicitly assumed to have possible availability requirements. The example given is a thread that might be used as a UI thread in certain contexts, which must run short-lived actions only. Because there's no way to know, the heuristic doesn't take any chance.Hillier
T
12

I think it's easiest to think of this in a slightly different way.

Let's say you have:

await task.ConfigureAwait(false);

First, if task is already completed, then as Reed pointed out, the ConfigureAwait is actually ignored and the execution continues (synchronously, on the same thread).

Otherwise, await will pause the method. In that case, when await resumes and sees that ConfigureAwait is false, there is special logic to check whether the code has a SynchronizationContext and to resume on a thread pool if that is the case. This is undocumented but not improper behavior. Because it's undocumented, I recommend that you not depend on the behavior; if you want to run something on the thread pool, use Task.Run. ConfigureAwait(false) quite literally means "I don't care what context this method resumes in."

Note that ConfigureAwait(true) (the default) will continue the method on the current SynchronizationContext or TaskScheduler. While ConfigureAwait(false) will continue the method on any thread except for one with a SynchronizationContext. They're not quite the opposite of each other.

Tomkins answered 26/3, 2014 at 23:48 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.