Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Confused at control flow of async/await of c#

Tags:

c#

async-await

I'm learning about async/await ,and get confused on the explaination of the await of MSDN:"The await operator suspends execution until the work of the GetByteArrayAsync method is complete. In the meantime, control is returned to the caller of GetPageSizeAsync"

What i don't understand is,what does it mean "returned"? At first,i believed when a thread(let's say, the UI thread) reaches the "await" keyword, system will create a new thread(or get a thread from threadPool) to execute the rest of codes,and UI thread can return to the caller method and execute the rest.

But now i know that "await" will never create thread.

I write a demo:

class Program
{
    static void Main(string[] args)
    {
        new Test().M1();
        Console.WriteLine("STEP:8");
        Console.Read();
    }
}

class Test
{
    public async void M1()
    {
        Console.WriteLine("STEP:1");
        var t = M2();
        Console.WriteLine("STEP:3");
        await t;
    }
    public async Task<string> M2()
    {
        Console.WriteLine("STEP:2");
        string rs = await M3();//when the thread reaches here,why don't it return to M1 and execute the STEP:3 ??
        Console.WriteLine("STEP:7");
        return rs;

    }

    public async Task<string> M3()
    {
        Console.WriteLine("STEP:4");
        var rs = Task.Run<string>(() => {
            Thread.Sleep(3000);//simulate some work that takes 3 seconds
            Console.WriteLine("STEP:6");
            return "foo";
        });
        Console.WriteLine("STEP:5");
        return await rs;
    }
}

this demo prints some labels representing the execute flow,which i thought it would be

STEP:1

STEP:2

STEP:3

STEP:4

STEP:5

STEP:6

STEP:7

STEP:8

(from 1 to 8 in order),

but the actual result is : Figure 1

STEP:1

STEP:2

STEP:4

STEP:5

STEP:3

STEP:8

STEP:6

STEP:7

If like MSDN's explaination, control is returned to M1 and print the "STEP 3", absolutely i'm wrong,So what's going on exactly?

Thanks in advance

like image 416
Wyman.W Avatar asked Dec 10 '18 13:12

Wyman.W


2 Answers

Just to get it out of the way, there's no guarantee that, at any particular await point we're going to do anything beyond carrying on with the rest of the code. However, in the sample in MSDN and your own example, we will always wait at each of the await points.

So, we reach some point where we have await z and we've decided that we're going to wait. This means a) That z hasn't Completed yet (whatever it means for z to be complete isn't something we care about at this moment in time) and b) That we have no useful work to do ourselves, at the moment.

Hopefully, from the above, you can see why "creating a new thread" or anything like it isn't necessary, because like I just said, there's no useful work to do.

Before we return control from our method though, we're going to enqueue a continuation. The async machinery is able to express "when z has completed, arrange for the rest of this method to continue from our await point".

Here, things like synchronization contexts and ConfigureAwait become relevant. The thread that we're running on (and that we're about to relinquish control of) may be "special" in some way. It may be the UI thread. It may currently have exclusive access to some resources (think ASP.Net pre-Core Request/Response/Session objects).

If that's so, hopefully the system providing the special-ness has installed a synchronization context, and it's via that that we also are able to obtain "when we resume execution of this method, we need to have the same special circumstances we previously had". So e.g. we're able to resume running our method back on the UI thread.

By default, without a synchronization context, a thread pool thread will be found to run our continuation.

Note that, if a method contains multiple awaits that require waiting, after that first wait, we'll be running as a chained continuation. Our original caller got their context back the first time we awaited.


In your sample, we have three await points and all three of them will wait and cause us to relinquish control back to our caller (and we don't have multiple awaits in a single method to worry about either). All of these awaits are, at the end of the day, waiting for that Thread.Sleep to finish (poor form in modern async code which should use TaskDelay instead). So any Console.WriteLines appearing after an await in that method will be delayed.

However, M1 is async void, something best avoided outside of event handlers. async void methods are problematic because they offer no means of determining when they've completed.

Main calls M1 which prints 1 and then calls M2. M2 prints 2 and then calls M3. M3 prints 4, creates a new Task, prints 5 and only then can it relinquish control. It's at this point that it creates a TaskCompletionSource that will represent its eventual completion, obtain the Task from it, and return that.

Notice that it's only when the call to M3 returns that we hit the await in M2. We also have to wait here so we do almost the same as M3 and return a Task.

Now M1 finally has a Task that it can await on. But before it does that, it prints 3. It returns control to Main which now prints 8.

Eons later, the Thread.Sleep in rs in M3 finishes running, and we print 6. M3 marks its returned Task as complete, has no more work to do, and so exits with no further continuations arranged.

M2 can now resume and its await and print 6. Having done that, its Task is complete and M1 can finally resume and print 7.

M1 doesn't have a Task to mark as complete because it was async void.

like image 132
Damien_The_Unbeliever Avatar answered Sep 29 '22 01:09

Damien_The_Unbeliever


It's easiest to see when you look at what this code is actually doing under the hood. Here's an approximate translation of what that code is doing:

public void M1()
{
    Console.WriteLine("STEP:1");
    var t = M2();
    Console.WriteLine("STEP:3");
    t.ContinueWith(_ =>
    {
        //do nothing
    });
}
public Task<string> M2()
{
    Console.WriteLine("STEP:2");
    Task<string> task = M3();
    return task.ContinueWith(t =>
    {
        Console.WriteLine("STEP:7");
        return t.Result;
    });
}
public Task<string> M3()
{
    Console.WriteLine("STEP:4");
    var rs = Task.Run<string>(() =>
    {
        Thread.Sleep(3000);//simulate some work that takes 3 seconds
        Console.WriteLine("STEP:6");
        return "foo";
    });
    Console.WriteLine("STEP:5");
    return rs.ContinueWith(t => t.Result);
}

Note that, for the sake of brevity, I've not done proper error handling here. One of the biggest benefits to using await is that it handles errors in the way you generally want them to, and using the tools I have here...doesn't. This code also doesn't schedule the continuations to use the current synchronization context, something that's irrelevant to this situation, but is a useful feature.

At this point it hopefully is more clear as to what's going on. For starters, M1 and M3 really have no business being async in the first place, as they never doing anything meaningful in their continuations. They should really just not be async and to return the tasks they get from calling other methods. M2 is the only method that actually does anything useful in any of the continuations it creates.

It also makes it clearer as to the order of operations. We call M1, which calls M2, which calls M3, which calls Task.Run, then M3 returns a task that's identical to what Task.Run returns and then returns itself, then M2 adds its continuation and returns, then M3 executes the next printline, then adds its continuation (which does nothing when it eventually runs), then returns. Then at some later point in time the Task.Run finishes, and all of the continuations run, bottom up (because each is a continuation of the other).

like image 33
Servy Avatar answered Sep 29 '22 01:09

Servy