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?
The asynchronous entry point is just a compiler trick. Behind the scenes, the compiler generates this real entry point:
If you change your code to be like this:
You'll get this:
As expected, the main thread is waiting for the work to be done.
This is a result of application type you chose. Console apps and GUI apps behave differently regarding the
SynchronizationContext
. When you useawait
, then the currentSynchronizationContext
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 capturedSynchronizationContext
so that the enqueued remaining code can resume execution. You can get the current context by accessingSynchronizationContext.Current
property. The code that is waiting forawait
to finish (the remaining code afterawait
) will be enqueued as a continuation and executed on the capturedSynchronizationContext
.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 aSynchronizationContext
, so to be able to useasync
, the framwork uses theThreadPool
SynchronizationContext
. The rules for theSynchronizationContext
behaviour is thatSynchronizationContext.Current
returns NULL, the continuation thread will default to a thread pool threadSynchronizationContext.Current
is not NULL, the continuation will be executed on the captured context.await
is used on a background thread (hence a new background thread is started from a background thread), then theSynchronizationContext
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 theThreadPool
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 theThreadPool
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 UISynchronizationContext
).await
will use the background thread thread 3 from theThreadPool
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 UISynchronizationContext
).await
will use the background thread thread 3 from theThreadPool
to execute the asynchronous delegate. Once the delegate completes, the continuationTaskContinueWith
. Since we are still on the background thread, a newTreadPool
thread thread 4 is used with the capturedSynchronizationContext
of thread 3. Once the continuation completes the context returns to thread 3 which will execute the remaining code of the caller on the capturedSynchronizationContext
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 aThreadPool
background thread thread 3. But because the task was configured withTask.ConfigureAwait(false)
thread 3 doesn't capture theSynchronizationContext
of the caller (UISynchronizationContext
). TheSynchronizationContext.Current
property of thread 3 will therefore be NULL and the defaultSynchronizationContext
applies: the context will be aThreadPool
thread. Because of performance optimizations (context switching is expensive) the context will be the currentSynchronizationContext
of thread 3. This means that once thread 3 completes, the remaining code of hte caller will be executed on the defaultSynchronizationContext
thread 3. The defaultTask.ConfigureAwait
value istrue
, which enables capturing of the callerSynchronizationContext
.Scenario 5, a GUI application and
Task.Wait
,Task.Result
orTask.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
orTask.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 theSynchronizationContext
). Thread 3 will wait infinitely for thread 1 to release the lock onSynchronizationContext
, which in turn makes thread 1 wait infinitely for thread 3 to return --> deadlock.Scenario 6, console application and
Task.Wait
,Task.Result
orTask.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 defaultSynchronizationContext
applies. The asynchronous delegate is executed synchronously (Task.Wait
,Task.Result
orTask.GetAwaiter.GetResult
will turn the asynchronous operation into a synchronous operation) on aThreadPool
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 theSynchronizationContext
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 theSynchronizationContext
of console applications is always NULL. When the capturedSynchronizationContext
is NULL,Task
uses the default context which is a thread of theThreadPool
. Since the asynchronous delegate is already executed on aThreadPool
thread theTaskScheduler
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 theSynchronizationContext
of the caller. This will prevent accidental deadlocks in you application.In your code your main thread ends once it calls
await
here: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 callingawait DoWorkAsync();
so it will be done.