I strongly recommend reading this blog post from 2012 when the await
keyword was introduced, but it explains how asynchronous code works in console programs: https://devblogs.microsoft.com/pfxteam/await-synchronizationcontext-and-console-apps/
The author then states
this keyword (await) will always modify a method that returns a Task object. When the flow of logic reaches the await
token, the calling thread is suspended in this method until the call completes. If you were to run this version of the application, you would find that the "Completed" message shows before the "Done with work!" message. If this were a graphical application, the user could continue to use the UI while the DoWorkAsync()
method executes".
The author is being imprecise.
I would change this:
When the flow of logic reaches the await
token, the calling thread is suspended in this method until the call completes
To this:
When the flow of logic reaches the await
token (which is after DoWorkAsync
returns a Task
object), the local state of the function is saved in-memory somewhere and the running thread executes a return
back to the Async Scheduler (i.e. the thread pool).
My point is that await
does not cause a thread to "suspend" (nor does it cause a thread to block either).
The next sentence is also a problem:
If you were to run this version of the application, you would find that the "Completed" message shows before the "Done with work!" message
(I assume by "this version" the author is referring to a version that's syntactically identical but omits the await
keyword).
The claim being made is incorrect. The called method DoWorkAsync
still returns a Task<String>
which cannot be meaningfully passed to Console.WriteLine
: the returned Task<String>
must be awaited
first.
Looking through various online documentation and articles regarding how async/await works, I thought "await" would work such as when the first "await" is encountered, the program checks if the method has already completed, and if not, it would immediately "return" to the calling method, and then come back once the awaitable task completes.
Your thinking is generally correct.
But if the calling method is Main() itself, who does it return to? Would it simply wait for the await to complete? Is that why the code is behaving as it is (waiting for 5 seconds before printing "Completed")?
It returns to the default Thread Pool maintained by the CLR. Every CLR program has a Thread Pool, which is why even the most trivial of .NET programs' processes will appear in Windows Task Manager with a thread-count between 4 and 10. The majority of those threads will be suspended, however (but the fact they're suspended is unrelated to the use of async
/await
.
But this leads to the next question: because DoWorkAsync()
itself here calls another await
ed method, when that await Task.Run()
line is encountered, which would obviously not complete till 5 seconds later, shouldn't DoWorkAsync()
immediately return to the calling method Main()
, and if that happens, shouldn't Main()
proceed to print "Completed", as the book author suggested?
Yes and no :)
It helps if you look at the raw CIL (MSIL) of your compiled program (await
is a purely syntactic feature that does not depend on any substantial changes to the .NET CLR, which is why the async
/await
keywords were introduced with .NET Framework 4.5 even though the .NET Framework 4.5 runs on the same .NET 4.0 CLR which predates it by 3-4 years.
To start, I need to syntactically rearrange your program to this (this code looks different, but it compiles to identical CIL (MSIL) as your original program):
static async Task Main(string[] args)
{
Console.WriteLine(" Fun With Async ===>");
Task<String> messageTask = DoWorkAsync();
String message = await messageTask;
Console.WriteLine( message );
Console.WriteLine( "Completed" );
Console.ReadLine();
}
static async Task<string> DoWorkAsync()
{
Task<String> threadTask = Task.Run( BlockingJob );
String value = await threadTask;
return value;
}
static String BlockingJob()
{
Thread.Sleep( 5000 );
return "Done with work!";
}
Here's what happens:
The CLR loads your assembly and locates the Main
entrypoint.
The CLR also populates the default thread-pool with threads it requests from the OS, it suspends those threads immediately (if the OS doesn't suspend them itself - I forget those details).
The CLR then chooses a thread to use as the Main thread and another thread as the GC thread (there's more details to this, I think it may even use the main OS-provided CLR entrypoint thread - I'm unsure of these details). We'll call this Thread0
.
Thread0
then runs Console.WriteLine(" Fun With Async ===>");
as a normal method-call.
Thread0
then calls DoWorkAsync()
also as a normal method-call.
Thread0
(inside DoWorkAsync
) then calls Task.Run
, passing a delegate (function-pointer) to BlockingJob
.
- Remember that
Task.Run
is shorthand for "schedule (not immediately-run) this delegate on a thread in the thread-pool as a conceptual "job", and immediately return a Task<T>
to represent the status of that job".
- For example, if the thread-pool is depleted or busy when
Task.Run
is called then BlockingJob
won't run at all until a thread returns to the pool - or if you manually increase the size of the pool.
Thread0
is then immediately given a Task<String>
that represents the lifetime and completion of BlockingJob
. Note that at this point the BlockingJob
method may or may not have run yet, as that's entirely up to your scheduler.
Thread0
then encounters the first await
for BlockingJob
's Job's Task<String>
.
- At this point actual CIL (MSIL) for
DoWorkAsync
contains an effective return
statement which causes real execution to return to Main
, where it then immediately returns to the thread-pool and lets the .NET async scheduler start worrying about scheduling.
- This is where it gets complicated :)
So when Thread0
returns to the thread-pool, BlockingJob
may or may not have been called depending on your computer setup and environment (things happen differently if your computer has only 1 CPU core, for example - but many other things too!).
- It's entirely possible that
Task.Run
put the BlockingJob
job into the scheduler and then not not actually run it until Thread0
itself returns to the thread-pool, and then the scheduler runs BlockingJob
on Thread0
and the entire program only uses a single-thread.
- But it's also possible that
Task.Run
will run BlockingJob
immediately on another pool thread (and this is the likely case in this trivial program).
Now, assuming that Thread0
has yielded to the pool and Task.Run
used a different thread in the thread-pool (Thread1
) for BlockingJob
, then Thread0
will be suspended because there are no other scheduled continuations (from await
or ContinueWith
) nor scheduled thread-pool jobs (from Task.Run
or manual use of ThreadPool.QueueUserWorkItem
).
- (Remember that a suspended thread is not the same thing as a blocked thread! - See footnote 1)
- So
Thread1
is running BlockingJob
and it sleeps (blocks) for those 5 seconds because Thread.Sleep
blocks which is why you should always prefer Task.Delay
in async
code because it doesn't block!).
- After those 5 seconds
Thread1
then unblocks and returns "Done with work!"
from that BlockingJob
call - and it returns that value to Task.Run
's internal scheduler's call-site and the scheduler marks the BlockingJob
job as complete with "Done with work!"
as the result value (this is represented by the Task<String>.Result
value).
Thread1
then returns to the thread-pool.
- The scheduler knows that there is an
await
that exists on that Task<String>
inside DoWorkAsync
that was used by Thread0
previously in step 8 when Thread0
returned to the pool.
- So because that
Task<String>
is now completed, it picks out another thread from the thread-pool (which may or may not be Thread0
- it could be Thread1
or another different thread Thread2
- again, it depends on your program, your computer, etc - but most importantly it depends on the synchronization-context and if you used ConfigureAwait(true)
or ConfigureAwait(false)
).
- In trivial console programs without a synchronization context (i.e. not WinForms, WPF, or ASP.NET (but not ASP.NET Core)) then the scheduler will use any thread in the pool (i.e. there's no thread affinity). Let's call this
Thread2
.
(I need to digress here to explain that while your async Task<String> DoWorkAsync
method is a single method in your C# source code but internally the DoWorkAsync
method is split-up into "sub-methods" at each await
statement, and each "sub-method" can be entered into directly).
- (They're not "sub-methods" but actually the entire method is rewritten into a hidden state-machine
struct
that captures local function state. See footnote 2).
So now the scheduler tells Thread2
to call into the DoWorkAsync
"sub- method" that corresponds to the logic immediately after that await
. In this case it's the String value = await threadTask;
line.
- Remember that the scheduler knows that the
Task<String>.Result
is "Done with work!"
, so it sets String value
to that string.
The DoWorkAsync
sub-method that Thread2
called-into then also returns that String value
- but not to Main
, but right back to the scheduler - and the scheduler then passes that string value back to the Task<String>
for the await messageTask
in Main
and then picks another thread (or the same thread) to enter-into Main
's sub-method that represents the code after await messageTask
, and that thread then calls Console.WriteLine( message );
and the rest of the code in a normal fashion.
Footnotes
Footnote 1
Remember that a suspended thread is not the same thing as a blocked thread: This is an oversimplification, but for the purposes of this answer, a "suspended thread" has an empty call-stack and can be immediately put to work by the scheduler to do something useful, whereas a "blocked thread" has a populated call-stack and the scheduler cannot touch it or repurpose it unless-and-until it returns to the thread-pool - note that a thread can be "blocked" because it's busy running normal code (e.g. a while
loop or spinlock), because it's blocked by a synchronization primitive such as a Semaphore.WaitOne
, because it's sleeping by Thread.Sleep
, or because a debugger instructed the OS to freeze the thread).
Footnote 2
In my answer, I said that the C# compiler would actually compile code around each await
statement into "sub-methods" (actually a state-machine) and this is what allows a thread (any thread, regardless of its call-stack state) to "resume" a method where its thread returned to the thread-pool. This is how that works:
Supposing you have this async
method:
async Task<String> FoobarAsync()
{
Task<Int32> task1 = GetInt32Async();
Int32 value1 = await task1;
Task<Double> task2 = GetDoubleAsync();
Double value2 = await task2;
String result = String.Format( "{0} {1}", value1, value2 );
return result;
}
The compiler will generate CIL (MSIL) that would conceptually correspond to this C# (i.e. if it were written without async
and await
keywords).
(This code omits lots of details like exception handling, the real values of state
, it inlines AsyncTaskMethodBuilder
, the capture of this
, and so on - but those details aren't important right now)
Task<String> FoobarAsync()
{
FoobarAsyncState state = new FoobarAsyncState();
state.state = 1;
state.task = new Task<String>();
state.MoveNext();
return state.task;
}
struct FoobarAsyncState
{
// Async state:
public Int32 state;
public Task<String> task;
// Locals:
Task<Int32> task1;
Int32 value1
Task<Double> task2;
Double value2;
String result;
//
public void MoveNext()
{
switch( this.state )
{
case 1:
this.task1 = GetInt32Async();
this.state = 2;
// This call below is a method in the `AsyncTaskMethodBuilder` which essentially instructs the scheduler to call this `FoobarAsyncState.MoveNext()` when `this.task1` completes.
// When `FoobarAsyncState.MoveNext()` is next called, the `case 2:` block will be executed because `this.state = 2` was assigned above.
AwaitUnsafeOnCompleted( this.task1.GetAwaiter(), this );
// Then immediately return to the caller (which will always be `FoobarAsync`).
return;
case 2:
this.value1 = this.task1.Result; // This doesn't block because `this.task1` will be completed.
this.task2 = GetDoubleAsync();
this.state = 3;
AwaitUnsafeOnCompleted( this.task2.GetAwaiter(), this );
// Then immediately return to the caller, which is most likely the thread-pool scheduler.
return;
case 3:
this.value2 = this.task2.Result; // This doesn't block because `this.task2` will be completed.
this.result = String.Format( "{0} {1}", value1, value2 );
// Set the .Result of this async method's Task<String>:
this.task.TrySetResult( this.result );
// `Task.TrySetResult` is an `internal` method that's actually called by `AsyncTaskMethodBuilder.SetResult`
// ...and it also causes any continuations on `this.task` to be executed as well...
// ...so this `return` statement below might not be called until a very long time after `TrySetResult` is called, depending on the contination chain for `this.task`!
return;
}
}
}
Note that FoobarAsyncState
is a struct
rather than a class
for performance reasons that I won't get into.