Async/ Await: why does the code that follows await

2019-09-19 09:36发布

问题:

Below is my code:

class Program
{
    static async Task Main(string[] args)
    {
        Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
        string message = await DoWorkAsync();
        Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
        Console.WriteLine(message);
    }

    static async Task<string> DoWorkAsync()
    {
        return await Task.Run(() =>
        {
            Thread.Sleep(3_000);
            return "Done with work!";
        });
    }
}

and the output is

1

// after 3 secs

3

Done with work!

so you can see the main thread(id is 1) changed to worker thread(id is 3), so how come the main thread just disappear?

回答1:

This is a result of application type you chose. Console apps and GUI apps behave differently regarding the SynchronizationContext. When you use await, then the current SynchronizationContext is captured and passed to the background thread.
The idea is not to block the main thread by just waiting for the background thread to complete. The remaining code is enqueued and the current context stored in the SynchronizationContext which the backgroung thread wil capture. When the background thread completes, it returns the captured SynchronizationContext so that the enqueued remaining code can resume execution. You can get the current context by accessing SynchronizationContext.Current property. The code that is waiting for await to finish (the remaining code after await) will be enqueued as a continuation and executed on the captured SynchronizationContext.

The default value of SynchronizationContext.Current is the UI thread for GUI applications like WPF or NULL for console applications. Console applications don't have a SynchronizationContext, so to be able to use async, the framwork uses the ThreadPool SynchronizationContext. The rules for the SynchronizationContext behaviour is that

  1. If the SynchronizationContext.Current returns NULL, the continuation thread will default to a thread pool thread
  2. If SynchronizationContext.Current is not NULL, the continuation will be executed on the captured context.
  3. And: if the await is used on a background thread (hence a new background thread is started from a background thread), then the SynchronizationContext will always be a thread pool thread.

Scenario 1, a console application:
rule 1) applies: thread 1 calls await which will try to capture the current context. await will use the background thread thread 3 from the ThreadPool to execute the asynchronous delegate.
Once the delegate completes, the remaining code of the calling thread will execute on the captured context. Since this context is NULL in console applications, the default SynchronizationContext will take effect (first rule). Therefore the scheduler decides to continue execution on the ThreadPool thread thread 3 (for efficiency. Context switches are expensive).

Scenario 2, a GUI application:
rule 2) applies: thread 1 calls await which will try to capture the current context (the UI SynchronizationContext). await will use the background thread thread 3 from the ThreadPool to execute the asynchronous delegate.
Once the delegate completes, the remaining code of the calling thread will execute on the captured context, the UI SynchronizationContext thread 1.

Scenario 3, a GUI application and Task.ContinueWith:
rule 2) and rule 3) applies: thread 1 calls await which will try to capture the current context (the UI SynchronizationContext). await will use the background thread thread 3 from the ThreadPool to execute the asynchronous delegate. Once the delegate completes, the continuation TaskContinueWith. Since we are still on the background thread, a new TreadPool thread thread 4 is used with the captured SynchronizationContext of thread 3. Once the continuation completes the context returns to thread 3 which will execute the remaining code of the caller on the captured SynchronizationContext which is the UI thread thread 1.

Scenario 4, a GUI application and Task.ConfigureAwait(false) (await DoWorkAsync().ConfigureAwait(false);):
rule 1) applies: thread 1 calls await and executes the asynchronous delegate on a ThreadPool background thread thread 3. But because the task was configured with Task.ConfigureAwait(false) thread 3 doesn't capture the SynchronizationContext of the caller (UI SynchronizationContext). The SynchronizationContext.Current property of thread 3 will therefore be NULL and the default SynchronizationContext applies: the context will be a ThreadPool thread. Because of performance optimizations (context switching is expensive) the context will be the current SynchronizationContext of thread 3. This means that once thread 3 completes, the remaining code of hte caller will be executed on the default SynchronizationContext thread 3. The default Task.ConfigureAwait value is true, which enables capturing of the caller SynchronizationContext.

Scenario 5, a GUI application and Task.Wait, Task.Result or Task.GetAwaiter.GetResult:
rule 2 applies but the application will deadlock. The current SynchronizationContext of thread 1 is captured. But because the asynchronous delegate is executed synchronously (Task.Wait, Task.Result or Task.GetAwaiter.GetResult will turn the asynchronous operation into a synchronous execution of the delegate), thread 1 will block until the now synchronous delegate completes.
Since the code is executed synchronously the remaining code of thread 1 was not enqueued as continuation of thread 3 and will therefore execute on thread 1 once the delegate completes. Now that the delegate on thread 3 completes it cannot return the SynchronizationContext of thread 1 to thread 1, because thread 1 is still blocking (and thus locking the SynchronizationContext). Thread 3 will wait infinitely for thread 1 to release the lock on SynchronizationContext, which in turn makes thread 1 wait infinitely for thread 3 to return --> deadlock.

Scenario 6, console application and Task.Wait, Task.Result or Task.GetAwaiter.GetResult:
rule 1 applies. The current SynchronizationContext of thread 1 is captured. But because this is a console application the context is NULL and the default SynchronizationContext applies. The asynchronous delegate is executed synchronously (Task.Wait, Task.Result or Task.GetAwaiter.GetResult will turn the asynchronous operation into a synchronous operation) on a ThreadPool background thread thread 3 and thread 1 will block until the delegate on thread 3 completes. Since the code is executed synchronously the remaining code was not enqueued as continuation of thread 3 and will therefore execute on thread 1 once the delegate completes. No deadlock situation in case of a console aplication, since the SynchronizationContext of thread 1 was NULL and thread 3 has to use the default context.

The code of your example matches scenario 1. It#s because you are running a console application and the default SynchronizationContext that applies because the SynchronizationContext of console applications is always NULL. When the captured SynchronizationContext is NULL, Task uses the default context which is a thread of the ThreadPool. Since the asynchronous delegate is already executed on a ThreadPool thread the TaskScheduler decides to stay on this thread and therefore to execute the enqueued remaining code of the caller thread thread 1 in thread 3.

In GUI applications it's best practice to always use Task.ConfigureAwait(false) everywhere except you explicitly want to capture the SynchronizationContext of the caller. This will prevent accidental deadlocks in you application.



回答2:

The asynchronous entry point is just a compiler trick. Behind the scenes, the compiler generates this real entry point:

private static void <Main>(string[] args)
{
    _Main(args).GetAwaiter().GetResult();
}

If you change your code to be like this:

class Program
{
    private static void Main(string[] args)
    {
        MainAsync(args).GetAwaiter().GetResult();
        Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
    }


    static async Task MainAsync(string[] args)
    {
        Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
        string message = await DoWorkAsync();
        Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
        Console.WriteLine(message);
    }

    static async Task<string> DoWorkAsync()
    {
        await Task.Delay(3_000);
        return "Done with work!";
    }
}

You'll get this:

1
4
Done with work!
1

As expected, the main thread is waiting for the work to be done.



回答3:

In your code your main thread ends once it calls await here:

string message = await DoWorkAsync();

Where the execution will diverge since DoWorkAsync() creates a task and all the code after that call will be executed within the new created task, then the main thread has nothing to do after calling await DoWorkAsync(); so it will be done.