Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Can exiting an async method yield control back to a different async method?

I understand that async which awaits a task yields execution back to the caller, allowing it to continue until it needs the result.

My interpretation of what I thought would come out of this was correct up until a certain point. It looks as though there's some sort of interleaving going on. I expected Do3() to complete and then back up the call stack to Do2(). See the results.

        await this.Go();

Which calls

        async Task Go()
        {
            await Do1(async () => await Do2("Foo"));

            Debug.WriteLine("Completed async work");
        }

        async Task Do1(Func<Task> doFunc)
        {
            Debug.WriteLine("Start Do1");

            var t = Do2("Bar");

            await doFunc();

            await t;
        }

        async Task Do2(string id)
        {
            Debug.WriteLine("Start Do2: " + id);

            await Task.Yield();

            await Do3(id);

            Debug.WriteLine("End Do2: " + id);
        }

        async Task Do3(string id)
        {
            Debug.WriteLine("Start Do3: " + id);

            await Task.Yield();

            Debug.WriteLine("End Do3: " + id); // I did not expect Do2 to execute here once the method call for Do3() ended
        }

The expected outcome:

// Start Do1
// Start Do2: Bar
// Start Do2: Foo
// Start Do3: Bar
// Start Do3: Foo
// End Do3: Bar
// End Do2: Bar
// End Do3: Foo
// End Do2: Foo
//Completed async work

Actual output:

//Start Do1
//Start Do2: Bar
//Start Do2: Foo
//Start Do3: Bar
//Start Do3: Foo
//End Do3: Bar
//End Do3: Foo
//End Do2: Bar
//End Do2: Foo
//Completed async work

What's going on here exactly?

I'm using .NET 4.5 and a simple WPF app to test my code.

like image 906
Razor Avatar asked Nov 01 '22 23:11

Razor


1 Answers

This is a WPF app, and all of this code is executed on the same UI thread. Each await continuation in your code is scheduled via DispatcherSynchronizationContext.Post, which posts a special Windows message to the UI thread's message queue. Each continuation happens in the order its message was posted (this is implementation-specific and you should not rely upon this, but that's how it works here).

So, the continuation for End Do3: Foo is indeed posted right after the one for End Do3: Bar. The output is correct.

Now, a bit more details. When I asked about WinForms vs WPF, I expected your "expected" output to match the actual output. I just tested it under WinForms, and it does match:

// Start Do1
// Start Do2: Bar
// Start Do2: Foo
// Start Do3: Bar
// Start Do3: Foo
// End Do3: Bar
// End Do2: Bar
// End Do3: Foo
// End Do2: Foo
//Completed async work

So, why the discrepancy between WPF and WinForms, while both run a message loop, and we only deal with single-threaded code here? The answer can be found here:

Why a unique synchronization context for each Dispatcher.BeginInvoke callback?

WPF's DispatcherSynchronizationContext.Post just calls Dispatcher.BeginInvoke, and one substantial WPF's implementation detail is that each Dispatcher.BeginInvoke callback is executed on its own unique synchronization context, as explained in the linked question.

This affects the await continuations for Task objects (like await doFunc()). In WinForms, such continuations are inlined (executed synchronously), because SynchronizationContext.Current stays the same. In WPF, they're not inlined but rather posted via SynchronizationContext.Post, because SynchronizationContext.Current before await task and after completion of the task is not the same (it gets compared inside task.GetAwaiter().OnCompleted by the await runtime infrastructure code).

Thus, in WPF, it's often the same UI thread, but different synchronization context associated with this thread, so the continuation may incur one more asynchronous PostMessage callback to be posted to, pumped and executed by the message loop. Besides YieldAwaitable (returned by Task.Yield), you'd also experience this behavior for TaskCompletionSource.SetResult-style continuations triggered from a WPF UI thread.

This is rather complex but implementation-specific stuff. If you want to have precise control over the order of asynchronous await continuations, you may want to roll out your own synchronization context, similar to Stephen Toub's AsyncPump. Although usually you don't need that, especially for a UI thread.

like image 88
noseratio Avatar answered Nov 12 '22 22:11

noseratio