async/await thread transition curiosity

2019-05-21 11:11发布

I have the following simple console application:

class Program
{
    private static int times = 0;

    static void Main(string[] args)
    {
        Console.WriteLine("Start {0}", Thread.CurrentThread.ManagedThreadId);

        var task = DoSomething();
        task.Wait();

        Console.WriteLine("End {0}", Thread.CurrentThread.ManagedThreadId);

        Console.ReadLine();
    }

    static async Task<bool> DoSomething()
    {
        times++;

        if (times >= 3)
        {
            return true;
        }

        Console.WriteLine("DoSomething-1 sleeping {0}", Thread.CurrentThread.ManagedThreadId);
        await Task.Run(() =>
        {
            Console.WriteLine("DoSomething-1 sleep {0}", Thread.CurrentThread.ManagedThreadId);
            Task.Yield();
        });
        Console.WriteLine("DoSomething-1 awake {0}", Thread.CurrentThread.ManagedThreadId);

        Console.WriteLine("DoSomething-2 sleeping {0}", Thread.CurrentThread.ManagedThreadId);
        await Task.Run(() =>
        {
            Console.WriteLine("DoSomething-2 sleep {0}", Thread.CurrentThread.ManagedThreadId);
            Task.Yield();
        });
        Console.WriteLine("DoSomething-2 awake {0}", Thread.CurrentThread.ManagedThreadId);

        bool b = await DoSomething();
        return b;
    }
}

with the output

Start 1
DoSomething-1 sleeping 1
DoSomething-1 sleep 3
DoSomething-1 awake 4
DoSomething-2 sleeping 4
DoSomething-2 sleep 4
DoSomething-2 awake 4
DoSomething-1 sleeping 4
DoSomething-1 sleep 3
DoSomething-1 awake 3
DoSomething-2 sleeping 3
DoSomething-2 sleep 3
DoSomething-2 awake 3
End 1

I'm aware that console apps don't provide a SynchronizationContext so Tasks run on the thread pool. But what surprises me is that when resuming execution from an await in DoSomething, we are on the same thread as we are on inside the await. I had assumed that we'd either return to the thread we awaited on or be on another thread entirely when we resume execution of the awaiting method.

Does anyone know why? Is my example flawed in some way?

2条回答
SAY GOODBYE
2楼-- · 2019-05-21 11:26

When you're not specifying which scheduler to use, you're at the whim of "the system" to decide where/how to run your tasks. All that await really does is to place all of the code following the awaited task into a continuation task that runs after the awaited task completes. In many cases, the scheduler will say "hey, I just finished a task on thread X, and there's a continuation task as well... since thread X is done, I'll just re-use it for the continuation!" This is exactly the behavior you're seeing. (See http://msdn.microsoft.com/en-US/library/vstudio/hh156528.aspx for more details.)

If you manually create your continuations (rather than letting await do so for you), you can have more control about exactly how and where the continuation runs. (See http://msdn.microsoft.com/en-us/library/system.threading.tasks.taskcontinuationoptions.aspx for the continuation options you can pass to Task.ContinueWith().)

查看更多
\"骚年 ilove
3楼-- · 2019-05-21 11:30

This behavior is due to an optimization (which is an implementation detail).

Specifically, the continuation scheduled by await uses the TaskContinuationOptions.ExecuteSynchronously flag. This is not officially documented anywhere but I did encounter this a few months ago and wrote it up on my blog.

Stephen Toub has a blog post that is the best documentation on how ExecuteSynchronously actually works. One important point is that ExecuteSynchronously will not actually execute synchronously if the task scheduler for that continuation is not compatible with the current thread.

As you pointed out, console apps don't have a SynchronizationContext, so task continuations scheduled by await will use TaskScheduler.Current (which in this case is TaskScheduler.Default, the thread pool task scheduler).

When you start another task via Task.Run, you're explicitly executing it on the thread pool. So when it reaches the end of its method, it completes its returned task, causing the continuation to execute (synchronously). Since the task scheduler captured by await was the thread pool scheduler (and therefore compatible with the continuation), it will just directly execute the next portion of DoSomething.

Note that there is a race condition here. The next portion of DoSomething will only execute synchronously if it is already attached as a continuation to the task returned by Task.Run. On my machine, the first Task.Run will resume DoSomething on another thread because the continuation is not attached by the time the Task.Run delegate completes; the second Task.Run does resume DoSomething on the same thread.

So I modified the code to be slightly more deterministic; this code:

static Task DoSomething()
{
    return Task.Run(async () =>
    {
        Console.WriteLine("DoSomething-1 sleeping {0}", Thread.CurrentThread.ManagedThreadId);
        await Task.Run(() =>
        {
            Console.WriteLine("DoSomething-1 sleep {0}", Thread.CurrentThread.ManagedThreadId);
            Thread.Sleep(100);
        });
        Console.WriteLine("DoSomething-1 awake {0}", Thread.CurrentThread.ManagedThreadId);

        Console.WriteLine("DoSomething-2 sleeping {0}", Thread.CurrentThread.ManagedThreadId);
        var task = Task.Run(() =>
        {
            Console.WriteLine("DoSomething-2 sleep {0}", Thread.CurrentThread.ManagedThreadId);
        });
        Thread.Sleep(100);
        await task;
        Console.WriteLine("DoSomething-2 awake {0}", Thread.CurrentThread.ManagedThreadId);
    });
}

(on my machine) shows both of the possibilities from the race condition:

Start 8
DoSomething-1 sleeping 9
DoSomething-1 sleep 10
DoSomething-1 awake 10
DoSomething-2 sleeping 10
DoSomething-2 sleep 11
DoSomething-2 awake 10
End 8

BTW, your use of Task.Yield is incorrect; you have to await the result to actually do anything.

Note that this behavior (await using ExecuteSynchronously) is an undocumented implementation detail and may change in the future.

查看更多
登录 后发表回答