6

I'm learning C# with Andrew Troelsen's book "Pro C# 7 With .NET and .NET Core". On chapter 19 (async programming) the author used these sample codes:

        static async Task Main(string[] args)
        {
            Console.WriteLine(" Fun With Async ===>");             
            string message = await DoWorkAsync();
            Console.WriteLine(message);
            Console.WriteLine("Completed");
            Console.ReadLine();
        }
     
        static async Task<string> DoWorkAsync()
        {
            return await Task.Run(() =>
            {
                Thread.Sleep(5_000);
                return "Done with work!";
            });
        }

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".

But when I ran this code in VS I did not get this behavior. The Main thread actually gets blocked for 5 seconds and "Completed" doesn't show until after "Done with work!".

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.

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")?

But this leads to the next question: because DoWorkAsync() itself here calls another await 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?

BTW, the book is for C# 7 but I'm running VS 2019 with C# 8, if that makes any difference.

thankyoussd
  • 1,875
  • 1
  • 18
  • 39
  • 1
    Hi, as the task is awaited, following lines of instructions will execute after awaited task is completed. – Noymul Islam Chowdhury Jul 04 '20 at 00:03
  • I can see your confusion, however this is exactly what you would expect to happen. `await` (just as the name implies) awaits for the task to finish, then will create a continuation *potentially* on the same thread (depending on the synchronization context), to continue sequential execution of the block you are within . – TheGeneral Jul 04 '20 at 00:03
  • Also `main` is a special case as its the entry point of the application – TheGeneral Jul 04 '20 at 00:06
  • 1
    You may ask "*well whats the use of all this if it blocks*" firstly it actually doesn't block (though the execution of your current code block awaits until the work is done, which is different. , The use is in scalability, there is no point blocking a thread when work can queued and called back from the chip on a device (IO work). Also with UI frameworks they have a main thread (message pump / Dispatcher), why block the UI when you can do workloads asynchronously? then when you are done, it comes back to the main thread (or context your are in) to continue there. – TheGeneral Jul 04 '20 at 00:10

2 Answers2

10

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 awaited 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:

  1. The CLR loads your assembly and locates the Main entrypoint.

  2. 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).

  3. 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.

  4. Thread0 then runs Console.WriteLine(" Fun With Async ===>"); as a normal method-call.

  5. Thread0 then calls DoWorkAsync() also as a normal method-call.

  6. 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.
  7. 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.

  8. 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 :)
  9. 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).
  10. 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.
  11. (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).
  12. 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.
  13. 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.

Dai
  • 141,631
  • 28
  • 261
  • 374
  • 2
    WOW. That is a lot of stuff for a new comer to digest. Thanks so much for such a detailed write up. So for my last question (for which you provided the most comprehensive answer), can I understand it as: 1. The await DoWorkAsync() call did cause the Main() thread to return to the CRL thread pool, that's why at this point the codes after them did not get immediately executed. 2. It's after all the sub methods go through their cycle and a concrete string is obtained by the await DoWorkAsync() call, the remaining codes in Main() finally gets to execute (possibly by a new thread). – thankyoussd Jul 04 '20 at 01:55
  • @user683202 Pretty much! All I can recommend is writing some sample programs in Linqpad and looking at the generated CIL and then disassembling it with ILSpy (or Red Gate Reflector) to see how it **really** works. Note that `await`/`async` in C# does not actually use `TaskCompletionSource` nor `ContinueWith` - but they're still useful tools) – Dai Jul 04 '20 at 02:06
1

When you use the static async Task Main(string[] args) signature, the C# compiler generates behind the scenes a MainAsync method, and the actual Main method is rewritten like this:

public static void Main()
{
    MainAsync().GetAwaiter().GetResult();
}

private static async Task MainAsync()
{
    // Main body here
}

This means that the main thread of the console application, the thread having ManagedThreadId equal to 1, becomes blocked immediately after the first await of a non-completed task is hit, and remains blocked for the entire lifetime of the application! After that point the application runs exclusively on ThreadPool threads (unless your code starts threads explicitly).

This is a waste of a thread, but the alternative is to install a SynchronizationContext to the Console application, which has other drawbacks:

  1. The application becomes susceptible to the same deadlock scenarios that plague the UI applications (Windows Forms, WPF etc).
  2. There is nothing built-in available, so you must search for third-party solutions. Like Stephen Cleary's AsyncContext from the Nito.AsyncEx.Context package.

So the price of 1 MB of wasted RAM becomes a bargain, when you consider the complexity of the alternative!

There is another alternative though, which makes better use of the main thread. And this is to avoid the async Task Main signature. Just use the .GetAwaiter().GetResult(); after every major asynchronous method of your app. This way after the method completes you'll be back in the main thread!

static void Main(string[] args)
{
    Console.WriteLine(" Fun With Async ===>");             
    string message = DoWorkAsync().GetAwaiter().GetResult();
    Console.WriteLine(message);
    Console.WriteLine($"Completed, Thread: {Thread.CurrentThread.ManagedThreadId}");
    Console.ReadLine();
}
Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104