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?
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
- If the
SynchronizationContext.Current
returns NULL, the
continuation thread will default to a thread pool thread
- If
SynchronizationContext.Current
is not NULL, the continuation
will be executed on the captured context.
- 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.
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.
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.