Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

Internals of How the await Keyword Works

5.00/5 (30 votes)
15 Mar 2022CPOL18 min read 16.7K   114  
Internal working of the await keyword
In this article, you will see an analysis of what the C# compiler creates when it compiles a program, and then translate the compiled code back to C#.

Introduction

This essay will examine the internals of how the await keyword works. We will do this by analyzing what the C# compiler creates when it compiles the following five line program:

C#
public static async Task Main()
{
    DoWork();                        // ┐ Part 1
    await Task.Delay(2000);          //

    MoreWork();                      // ┐ Part 2
    Console.Write("Press Enter: ");  //
    Console.ReadLine();              //
}

The program does some work by calling DoWork(), then performs an await, and then does some more work by calling MoreWork(). The program ends by waiting for the user to press the Enter key.

We will analyze what the C# compiler creates when it compiles this program, and then translate the compiled code back to C#.

Table of Contents

  1. Full C# Program to Disassemble
  2. ildasm — C# Disassembler
  3. Class Program
    1. Constructor
    2. <Main> : void()
    3. Main : class [mscorlib]System.Threading.Tasks.Task() → SubMain()
    4. Display()
    5. DoWork()
    6. MoreWork()
    7. Class StateMachine
      1. Constructor
      2. SetStateMachine()
      3. MoveNext()
  4. Faithful C# Representation of Compiled Program
  5. Simplifying
    1. Simplifying Main()
    2. Simplifying MoveNext)()
    3. Simplifying SubMain()
  6. Simplified C# Representation
  7. Tracing What Happens During an Await
    1. Part 1
    2. Part 2
  8. Await in a Button_Click Event Handler
    1. Tracing AwawitUnsafeOnCompleted
  9. Timers
  10. Summary
  11. Related Article
  12. History

Full C# Program to Disassemble

Here is the full C# code of the program we will test:

C#
namespace ConsoleAppTCSa
{
    class Program
    {
        public static async Task Main()
        {
            DoWork();                        // ┐ Part 1
            await Task.Delay(2000);          //

            MoreWork();                      // ┐ Part 2
            Console.Write("Press Enter: ");  //
            Console.ReadLine();              //
        }

        private static void DoWork()
        {
            Display("Enter DoWork()");
            Thread.Sleep(2000);
            Display("Exit DoWork()");
        }

        private static void MoreWork()
        {
            Display("Enter MoreWork()");
            Thread.Sleep(2000);
            Display("Exit MoreWork()");
        }

        private static void Display(string s)
        {
            Console.WriteLine("{0:hh\\:mm\\:ss\\:fff} {1}", DateTime.Now, s);
        }
    }
}

We will compile this C# program to a *.exe file, then disassemble the *.exe file to see the CIL code the C# compiler created. (For programs compiled using .NET Core instead of .NET Framework, disassemble the *.dll file instead.)

CIL is "Common Intermediate Language", sometimes called MSIL – Microsoft Intermediate Language, or just IL – Intermediate Language. This is the assembly-like language the C# compiler outputs. If you're not familiar with this language, don't worry, I'll be explaining all the CIL code line by line. We're going to examine the CIL code and translate it back into a new C# program.

ildasm — C# Disassembler

I will be using Microsoft's ildasm.exe (Intermediate Language Disassembler) to examine the executable created by the C# compiler. (ildasm.exe is probably already on your computer somewhere. Search under C:\Program Files (x86)\Microsoft SDKs\Windows\... There are also other programs which can disassemble C# executables, such as ILSpy, dotPeek, and .NET Reflector.)

Here's what ildasm.exe shows when we load the compiled executable for the above program. I have added some C# annotations on the right:

5327239/msil3.png

The above is a list of classes and methods. The general format for methods is: Name : ReturnType(paramters). With great foresight, I will change some of the cryptic names given by ildasm.exe to something more user friendly and meaningful:

ildasm name:   Rename to:
<Main>d__0 Class StateMachine
<Main> static void Main()
Main private static Task SubMain()

<Main>d__0 is the name of a class the C# compiler created for us. I will rename that class to StateMachine.

Also, we have two methods with similar names:

  • <Main> (the angle brackets are part of the method name)
  • Main

When we examine the details of these two methods, we will find that method <Main> is the program's entry point, while method Main is where the five lines of code in our above sample C# program method Main() actually end up.

Class Program

We can now start to build a C# outline of our compiled program based on the IL DASM disassembler information above:

C#
namespace ConsoleAppTCSa
{
    class Program
    {
        class StateMachine  // <Main>d__0
        {
        }

        public Program()    // .ctor : void()   // the constructor
                                                // (which does nothing special
                                                // so we'll skip this)
        {
        }

        static void Main()  // <Main> : void()  // the main entrypoint
        {
        }

        private static void Display(string s)   // Display : void(string)
        {
        }

        private static void DoWork()            // DoWork : void()
        {
        }

        private static Task SubMain() // Main : class[mscorlib]System.Threading.Tasks.Task()
        {
        }

        private static void MoreWork()          // MoreWork : void()
        {
        }
    }
}

Let's go through the methods one by one.

.ctor : void()

Let's start by looking at the Constructor for class Program.

5327239/msilctor.png

Double-clicking on the .ctor : void() line in the IL DASM window above opens a new window showing us the CIL code:

5327239/ctor.png

We can see this constructor is just the default constructor which doesn't do anything other than call the constructor for System.Object, so we'll leave this out of our C# code and let the C# compiler generate this default constructor for us.

<Main> : void()

Let's now have a look at the code for method <Main> : void().

5327239/msilmain.png

Double-clicking on <Main> : void() in the IL DASM gives us:

5327239/main2a.png

At the top, we see this is a static method which returns void. The .entrypoint declares this is where the program starts. In C#, this would be called method Main():

C#
static void Main()
{
}

At the beginning, we see .locals where it defines V_0 to be of type TaskAwaiter:

C#
static void Main()
{
    TaskAwaiter V_0;
}

Breaking down the fist line of CIL code, we have:

MSIL
         command      +-------------- return type --------------+ +---- called method ------+
            ↓         │                                         │ │                         │
IL_0000:  call        class [mscorlib]System.Threading.Tasks.Task ConsoleAppTCSa.Program.Main

I will rename the method this line calls to SubMain(), since we already have a Main() method. This first line thus calls what I am renaming to SubMain() and returns a Task. We're going to end up with a couple of tasks so to help keep them straight, I will, with great foresight, name the task which gets returned here as statemachineTask:

C#
static void Main()
{
    TaskAwaiter V_0;

    Task statemachineTask = SubMain();
}

The second line takes the result of that call, which is statemachineTask, and calls statemachineTask.GetAwaiter(). We see the return type from this call is a TaskAwaiter:

Image 6

The instance keyword indicates this is a class instance rather than a static method. callvirt is similar to call with one difference being it also checks the validity of the class instance before using it. Since the TaskAwaiter returned comes from statemachineTask, I will name this statemachineTaskAwaiter:

C#
static void Main()
{
    TaskAwaiter V_0;

    Task statemachineTask = SubMain();
    TaskAwaiter statemachineTaskAwaiter = statemachineTask.GetAwaiter();
}

(Note that internally statemachineTaskAwaiter stores statemachineTask in a private field named m_task. Thus, a TaskAwaiter has access to the Task it is performing the await on.)

The third line:

MSIL
IL_0005:  stloc.0  // store result in local V_0

stores the resulting statemachineTaskAwaiter in local variable V_0:

C#
static void Main()
{
    TaskAwaiter V_0;

    Task statemachineTask = SubMain();
    TaskAwaiter statemachineTaskAwaiter = statemachineTask.GetAwaiter();
    V_0 = statemachineTaskAwaiter;
}

The 4th line, ldloca.s V_0 is "Load the address of local variable V_0". Combining this with the 5th line:

MSIL
             not    return
command     static   type +-------------------------- called method -----------------------+
 ↓            ↓       ↓   │                                                                │
call        instance void [mscorlib]System.Runtime.CompilerServices.TaskAwaiter::GetResult()

Translates to calling V_0.GetResult();

C#
static void Main()
{
    TaskAwaiter V_0;

    Task statemachineTask = SubMain();
    TaskAwaiter statemachineTaskAwaiter = statemachineTask.GetAwaiter();
    V_0 = statemachineTaskAwaiter;
    V_0.GetResult();
}

The final line, ret is of course a return:

C#
static void Main()
{
    TaskAwaiter V_0;

    Task statemachineTask = SubMain();
    TaskAwaiter statemachineTaskAwaiter = statemachineTask.GetAwaiter();
    V_0 = statemachineTaskAwaiter;
    V_0.GetResult();
    return;
}

Eliminating unnecessary local variable V_0 gives:

C#
static void Main()
{
    Task statemachineTask = SubMain();
    TaskAwaiter statemachineTaskAwaiter = statemachineTask.GetAwaiter();
    statemachineTaskAwaiter.GetResult();
    return;
}

Main : class [mscorlib]System.Threading.Tasks.Task()

Let's move on to the other Main CIL method:

5327239/msilsubmain.png

Double-clicking on Main : class [mscorlib]System.Threading.Tasks.Task() in the IL DASM gives us:

5327239/submain4.png

At the top, we see this is a private static method which returns a Task.

I will rename this CIL Main method to SubMain(), since we already have a Main() method.

C#
private static Task SubMain()
{
}

Line .locals defines local variable V_0 to be of type <Main>d__0, which is a class name. With great foresight, I have renamed this class to StateMachine:

C#
private static Task SubMain()
{
    StateMachine V_0;
}

Translating the rest of this method from CIL to C# gives us:

C#
private static Task SubMain()
{
    // .locals init ([0] class ConsoleAppAsync0.Program/'<Main>d__0' V_0)
    StateMachine V_0;

    // IL_0000:  newobj  instance void
    //           ConsoleAppAsync0.Program/'<Main>d__0'::.ctor() ? new StateMachine()
    // IL_0005:  stloc.0 ? store result in V_0
    V_0 = new StateMachine();

    // IL_0007:  call
    // valuetype [mscorlib]System.Runtime.CompilerServices.AsyncTaskMethodBuilder
    // [mscorlib]System.Runtime.CompilerServices.AsyncTaskMethodBuilder::Create()
    // IL_000c:  stfld
    // valuetype [mscorlib]System.Runtime.CompilerServices.AsyncTaskMethodBuilder
    // ConsoleAppAsync0.Program/'<Main>d__0'::'<>t__builder'
    V_0.Builder = new AsyncTaskMethodBuilder();

    // IL_0011:  ldloc.0   ? load local V_0
    // IL_0012:  ldc.i4.m1 ? load -1
    // IL_0013:  stfld      int32 ConsoleAppAsync0.Program/'<Main>d__0'::'<>1__state' ?
    //           store result in field V_0.State
    V_0.State = -1;

    // IL_0019:  ldflda
    // valuetype [mscorlib]System.Runtime.CompilerServices.AsyncTaskMethodBuilder
    // ConsoleAppAsync0.Program/'<Main>d__0'::'<>t__builder' ?
    //                           load address of field: V_0.t__builder
    // IL_001e:  ldloca.s  V_0 ? load address of local V_0 (V_0 will be argument passed by ref)
    // IL_0020:  call      instance void
    //           [mscorlib]System.Runtime.CompilerServices.AsyncTaskMethodBuilder::Start
    //           <class ConsoleAppAsync0.Program/'<Main>d__0'>(!!0&)
    V_0.Builder.Start(ref V_0);

    // IL_0025:  ldloc.0   ? load V_0
    // IL_0026:  ldflda    valuetype
    //                     [mscorlib]System.Runtime.CompilerServices.AsyncTaskMethodBuilder
    //                     ConsoleAppAsync0.Program/'<Main>d__0'::'<>t__builder' ?
    //                     load address of field: V_0.t__builder
    // IL_002b:  call      instance class [mscorlib]System.Threading.Tasks.Task
    //                     [mscorlib]System.Runtime.CompilerServices.AsyncTaskMethodBuilder::
    //                     get_Task() ? V_0.t__builder.Task;
    // IL_0030:  ret       ? return V_0.t__builder.Task;
    return V_0.Builder.Task;
}

Removing the CIL code comments leaves us with:

C#
private static Task SubMain()
{
    StateMachine V_0;

    V_0 = new StateMachine();
    V_0.Builder = new AsyncTaskMethodBuilder();
    V_0.State = -1;
    V_0.Builder.Start(V_0);
    return V_0.Builder.Task;
}

Renaming V_0 to statemachine gives us:

C#
private static Task SubMain()
{
    StateMachine statemachine = new StateMachine();
    statemachine.Builder = new AsyncTaskMethodBuilder();
    statemachine.State = -1;
    statemachine.Builder.Start(ref statemachine);
    return statemachine.Builder.Task;
}

Display : void(string)

5327239/msildisplay.png

Double-clicking on Display : void(string) in the IL DASM gives us:

5327239/display1.png

This translates to:

C#
private static void Display(string s)
{
    Console.WriteLine("{0:hh\\:mm\\:ss\\:fff} {1}", DateTime.Now, s);
}

This outputs string s preceded by a timestamp.

(Note: ldarg.0 here is not the 'this' pointer because this is a static method. If this were an instance method (something created with the new keyword), then ldarg.0 would be the 'this' pointer which all instance methods implicitly have as arg 0. (You'll also see the word instance in the CIL code near the top when it's an instance method instead of a static method.))

DoWork : void()

5327239/msildowork.png

Double-clicking on DoWork : void() in the IL DASM gives us:

5327239/dowork1.png

This translates to:

C#
private static void DoWork()
{
    Display("Enter DoWork()");
    Thread.Sleep(2000);
    Display("Exit DoWork()");
}

MoreWork : void()

5327239/msilmorework.png

Double-clicking on MoreWork : void() in the IL DASM gives us:

5327239/morework1.png

This is similar to DoWork() above and translates to:

C#
private static void MoreWork()
{
    Display("Enter MoreWork()");
    Thread.Sleep(2000);
    Display("Exit MoreWork()");
}

<Main>d__0 → class StateMachine

Let's open up class <Main>d__0 and see what's inside.

5327239/msilstatemachine.png

Again with great foresight, I will change some of the names here to ones which are more meaningful:

ildasm name:   Rename to:
<Main>d__0 Class StateMachine
<>1__state State
<>t__builder Builder
<>u__1 DelayTaskAwaiter (because this gets created from delayTask returned from Task.Delay(2000))

We see the general outline of this class is:

C#
class StateMachine : IAsyncStateMachine
{
    public int State;                       // 1__state
    public AsyncTaskMethodBuilder Builder;  // t__builder
    private TaskAwaiter DelayTaskAwaiter;   // u__1;

    public StateMachine() // .ctor : void() // the constructor
                          // (which does nothing special so we'll skip this)
    {
    }

    public void MoveNext()
    {
    }

    public void SetStateMachine(IAsyncStateMachine stateMachine)
    {
    }
}

.ctor : void()

Looking at the constructor for Class StateMachine:

5327239/msilstatemachinector.png

The code is:

5327239/statemachinector.png

We see this constructor is just the default constructor which doesn't do anything other than call the constructor for System.Object, so we'll leave this out of our C# code and let the C# compiler generate a default constructor for us.

SetStateMachine

5327239/msilsetstatemachine.png

Skipping over the MoveNext() method for a moment, let's have a look at the code for SetStateMachine:

5327239/setstatemachine.png

We can see this code does absolutely nothing. The only instruction is IL_0000: ret, which is a return statement. The only reason this method is here is because this class implements interface IAsyncStateMachine which requires it. This translates to:

C#
public void SetStateMachine(IAsyncStateMachine stateMachine)
{
    return;
}

(Our C# version needs to be public because it implements an interface requirement.)

MoveNext : void()

We now dive into the most complicated method of the whole program, the MoveNext() method. This is a long one so bear with me, we'll get through it.

5327239/msilmovenext.png

The CIL code for this method is:

5327239/movenext16.png

Here is a line by line C# translation of the CIL code for MoveNext():

C#
class StateMachine : IAsyncStateMachine
{
    public int State;                       // 1__state
    public AsyncTaskMethodBuilder Builder;  // t__builder
    private TaskAwaiter DelayTaskAwaiter;   // u__1; (with foresight I'm naming
                                            // this DelayTaskAwaiter,
                                            // since it's created from delayTask.)

    public void MoveNext()
    {
        //  .locals init ([0] int32 V_0,
        //           [1] valuetype [mscorlib]System.Runtime.CompilerServices.TaskAwaiter V_1,
        //           [2] class ConsoleAppTCSa.Program/'<Main>d__0' V_2,
        //           [3] class [mscorlib]System.Exception V_3)
        // Local variables
        Int32        V_0;  // state
        TaskAwaiter  V_1;  // delayTaskAwaiter
        StateMachine V_2;  // statemachine
        Exception    V_3;  // exception

        // IL_0000:  ldarg.0
        // IL_0001:  ldfld      int32 ConsoleAppTCSa.Program/'<Main>d__0'::'<>1__state'
        // IL_0006:  stloc.0
        V_0 = this.State;

        try  //  .try
        {
            // IL_0007:  ldloc.0
            // IL_0008:  brfalse.s  IL_000c
            if (V_0 == 0) goto IL_000c;

            // IL_000a:  br.s       IL_000e
            goto IL_000e;

            // IL_000c:  br.s       IL_0052
IL_000c:    goto IL_0052;

// ---------------------------------------------------------------------------------------------
IL_000e: // PART 1:
            // IL_000f:  call       void ConsoleAppTCSa.Program::DoWork()
            DoWork();

            // IL_0014:  nop        // no operation (do nothing,
            //                         just continue on to the next instruction)
            // IL_0015:  ldc.i4     0x7d0  // 0x7d0 = decimal 2000
            // IL_001a:  call       class [mscorlib]System.Threading.Tasks.Task
            //                      [mscorlib]System.Threading.Tasks.Task::Delay(int32)
            Task delayTask = Task.Delay(2000);

            // IL_001f:  callvirt   instance valuetype
            //           [mscorlib]System.Runtime.CompilerServices.TaskAwaiter
            //                      [mscorlib]System.Threading.Tasks.Task::GetAwaiter()
            // IL_0024:  stloc.1
            V_1 = delayTask.GetAwaiter();

            // IL_0025:  ldloca.s   V_1
            // IL_0027:  call       instance bool
            //      [mscorlib]System.Runtime.CompilerServices.TaskAwaiter::get_IsCompleted()
            // IL_002c:  brtrue.s   IL_006e
            if (V_1.IsCompleted) goto IL_006e;

            // IL_002e:  ldarg.0    // this
            // IL_002f:  ldc.i4.0   // load a zero (0)
            // IL_0030:  dup        // duplicate that 0
            // IL_0031:  stloc.0    // store 0 in V_0
            // IL_0032:  stfld      int32 ConsoleAppTCSa.Program/'<Main>d__0'::'<>1__state'
            // and store the other 0 in this.State;
            V_0 = 0;
            this.State = 0;

            // IL_0037:  ldarg.0    // this
            // IL_0038:  ldloc.1    // load local V_1
            // IL_0039:  stfld
            //           valuetype [mscorlib]System.Runtime.CompilerServices.TaskAwaiter
            //                      ConsoleAppTCSa.Program/'<Main>d__0'::'<>u__1'
            this.DelayTaskAwaiter = V_1;

            // IL_003e:  ldarg.0    // this
            // IL_003f:  stloc.2    // store in local V_2
            V_2 = this;

            // IL_0040:  ldarg.0    // this
            // IL_0041:  ldflda     valuetype
            //           [mscorlib]System.Runtime.CompilerServices.AsyncTaskMethodBuilder
            // ConsoleAppTCSa.Program/'<Main>d__0'::'<>t__builder'
            // IL_0046:  ldloca.s   V_1  // load address of local V_1
            // (will be argument passed by ref)
            // IL_0048:  ldloca.s   V_2  // load address of local V_2
            //           (will be argument passed by ref)
            // IL_004a:  call       instance void
            // [mscorlib]System.Runtime.CompilerServices.AsyncTaskMethodBuilder::
            // AwaitUnsafeOnCompleted<valuetype
            //                        ?[mscorlib]System.Runtime.CompilerServices.TaskAwaiter,
            // class ConsoleAppTCSa.Program/'<Main>d__0'>(!!0&, !!1&)
            this.Builder.AwaitUnsafeOnCompleted(ref V_1, ref V_2);

            // IL_004f:  nop
            // IL_0050:  leave.s    IL_00bb  // leave try/catch block. Goto IL_00bb
            goto IL_00bb;

// ---------------------------------------------------------------------------------------------
IL_0052:  // PART 2:
            // IL_0052:  ldarg.0    // this
            // IL_0053:  ldfld
            // valuetype [mscorlib]System.Runtime.CompilerServices.TaskAwaiter
            // ConsoleAppTCSa.Program/'<Main>d__0'::'<>u__1'
            // IL_0058:  stloc.1    // store in local V_1
            V_1 = this.DelayTaskAwaiter;

            // IL_0059:  ldarg.0    // this
            // IL_005a:  ldflda     valuetype
            // [mscorlib]System.Runtime.CompilerServices.TaskAwaiter ConsoleAppTCSa.Program/
            // '<Main>d__0'::'<>u__1'
            // IL_005f:  initobj    [mscorlib]System.Runtime.CompilerServices.TaskAwaiter
            this.DelayTaskAwaiter = default;  // (release previous DelayTaskAwaiter
                                              // for garbage collection)

            // IL_0065:  ldarg.0    // this
            // IL_0066:  ldc.i4.m1  // -1
            // IL_0067:  dup        // -1
            // IL_0068:  stloc.0    // V_0 = -1;
            // IL_0069:  stfld      int32 ConsoleAppTCSa.Program/
            //                      '<Main>d__0'::'<>1__state' // this.State = -1;
            V_0 = -1;
            this.State = -1;
IL_006e:
            // IL_006e:  ldloca.s   V_1
            // IL_0070:  call       instance void [mscorlib]System.Runtime.
            //                      CompilerServices.TaskAwaiter::GetResult()
            V_1.GetResult();

            // IL_0075:  nop        // no operation
            // IL_0076:  call       void ConsoleAppTCSa.Program::MoreWork()
            MoreWork();

            // IL_007b:  nop        // no operation
            // IL_007c:  ldstr      "Press Enter: "  // load string
            // IL_0081:  call       void [mscorlib]System.Console::Write(string)
            Console.Write("Press Enter: ");

            // IL_0086:  nop        // no operation
            // IL_0087:  call       string [mscorlib]System.Console::ReadLine()
            // IL_008c:  pop        // discard result of ReadLine()
            Console.ReadLine();

            // IL_008d:  leave.s    IL_00a7  // leave try/catch block. Goto IL_00a7
            goto IL_00a7;
        } // end .try
// ---------------------------------------------------------------------------------------------
        catch (Exception exception) // catch [mscorlib]System.Exception
        {
            // IL_008f:  stloc.3    // store in local V_3
            V_3 = exception;

            // IL_0090:  ldarg.0    // this
            // IL_0091:  ldc.i4.s   -2  // load -2
            // IL_0093:  stfld
            // int32 ConsoleAppTCSa.Program/'<Main>d__0'::'<>1__state'  // store in field
            this.State = -1;

            // IL_0098:  ldarg.0    // this
            // IL_0099:  ldflda     valuetype [mscorlib]System.Runtime.CompilerServices.
            // AsyncTaskMethodBuilder ConsoleAppTCSa.Program/'<Main>d__0'::'<>t__builder'
            // IL_009e:  ldloc.3    // load local V_3 (will be passed as argument)
            // IL_009f:  call       instance void [mscorlib]System.Runtime.CompilerServices.
            // AsyncTaskMethodBuilder::SetException(class [mscorlib]System.Exception)
            this.Builder.SetException(V_3);

            // IL_00a4:  nop        // no operation
            // IL_00a5:  leave.s    IL_00bb
            goto IL_00bb:
        } // end handler
// ---------------------------------------------------------------------------------------------
IL_00a7:
        // IL_00a7:  ldarg.0        // this
        // IL_00a8:  ldc.i4.s   -2  // load -2
        // IL_00aa:  stfld      int32 ConsoleAppTCSa.Program/'<Main>d__0'::'<>1__state'
        this.State = -2;

        // IL_00af:  ldarg.0        // this
        // IL_00b0:  ldflda     valuetype [mscorlib]System.Runtime.CompilerServices.
        // AsyncTaskMethodBuilder ConsoleAppTCSa.Program/'<Main>d__0'::'<>t__builder'
        // IL_00b5:  call       instance void [mscorlib]System.Runtime.
        // CompilerServices.AsyncTaskMethodBuilder::SetResult()
        this.Builder.SetResult();

        // IL_00ba:  nop            // no operation

IL_00bb:
        // IL_00bb:  ret
        return;
    }

    public void SetStateMachine(IAsyncStateMachine stateMachine)
    {
        return;
    }
}

Removing all the CIL code comments leaves us with:

C#
class StateMachine : IAsyncStateMachine
{
    public int State;                       // 1__state
    public AsyncTaskMethodBuilder Builder;  // t__builder
    private TaskAwaiter DelayTaskAwaiter;   // u__1;

    public void MoveNext()
    {
        // Local variables
        Int32        V_0;  // state
        TaskAwaiter  V_1;  // delayTaskAwaiter
        StateMachine V_2;  // statemachine
        Exception    V_3;  // exception

        V_0 = this.State;

        try
        {
            if (V_0 == 0) goto IL_000c;
            goto IL_000e;
IL_000c:    goto IL_0052;

// ---------------------------------------------------------------------------------------------
IL_000e: // PART 1:
            DoWork();
            Task delayTask = Task.Delay(2000);
            V_1 = delayTask.GetAwaiter();
            if (V_1.IsCompleted) goto IL_006e;
            V_0 = 0;
            this.State = 0;
            this.DelayTaskAwaiter = V_1;
            V_2 = this;
            this.Builder.AwaitUnsafeOnCompleted(ref V_1, ref V_2);
            goto IL_00bb;

// ---------------------------------------------------------------------------------------------
IL_0052:  // PART 2:
            V_1 = this.DelayTaskAwaiter;
            this.DelayTaskAwaiter = default;  // (release previous DelayTaskAwaiter
                                              // for garbage collection)
            V_0 = -1;
            this.State = -1;
IL_006e:    V_1.GetResult();
            MoreWork();
            Console.Write("Press Enter: ");
            Console.ReadLine();
            goto IL_00a7;
        }
// ---------------------------------------------------------------------------------------------
        catch (Exception ex)
        {
            V_3 = ex;
            this.State = -1;
            this.Builder.SetException(V_3);
            goto IL_00bb;
        }
// ---------------------------------------------------------------------------------------------
IL_00a7:
        this.State = -2;
        this.Builder.SetResult();
IL_00bb:
        return;
    }

    public void SetStateMachine(IAsyncStateMachine stateMachine)
    {
        return;
    }
}

We can simplify this a bit by examining these three lines of code at the top of the try block:

MSIL
            if (V_0 == 0) goto IL_000c;
            goto IL_000e;
IL_000c:    goto IL_0052;

If V_0 is zero, then we go to IL_000c which in turn jumps to IL_0052; otherwise we go to IL_000e. This is a switch statement, so let's simplify it now:

C#
class StateMachine : IAsyncStateMachine
{
    public int State;                       // 1__state
    public AsyncTaskMethodBuilder Builder;  // t__builder
    private TaskAwaiter DelayTaskAwaiter;   // u__1;

    public void MoveNext()
    {
        // Local variables
        Int32        V_0;  // state
        TaskAwaiter  V_1;  // delayTaskAwaiter
        StateMachine V_2;  // statemachine
        Exception    V_3;  // exception

        V_0 = this.State;

        try
        {
            switch (V_0)
            {
                default:  // PART 1:
                    DoWork();
                    Task delayTask = Task.Delay(2000);
                    V_1 = delayTask.GetAwaiter();
                    if (V_1.IsComplete) goto IL_006e;
                    V_0 = 0;
                    this.State = 0;
                    this.DelayTaskAwaiter = V_1;
                    V_2 = this;
                    this.Builder.AwaitUnsafeOnCompleted(ref V_1, ref V_2);
                    return;

                case 0:  // PART 2:
                    V_1 = this.DelayTaskAwaiter;
                    this.DelayTaskAwaiter = default;  // (release previous DelayTaskAwaiter
                                                      // for garbage collection)
                    V_0 = -1;
                    this.State = -1;
IL_006e:            V_1.GetResult();
                    MoreWork();
                    Console.Write("Press Enter: ");
                    Console.ReadLine();
                    break;
            }
        }
        catch (Exception ex)
        {
            V_3 = ex;
            this.State = -1;
            this.Builder.SetException(V_3);
            return;
        }

        this.State = -2;
        this.Builder.SetResult();
        return;
    }

    public void SetStateMachine(IAsyncStateMachine stateMachine)
    {
        return;
    }
}

Rename local variables V_0, V_1, V_2, V_3 with better names, and change label IL_006e: to IsCompleted:

C#
class StateMachine : IAsyncStateMachine
{
    public int State;                       // 1__state
    public AsyncTaskMethodBuilder Builder;  // t__builder
    private TaskAwaiter DelayTaskAwaiter;   // u__1;

    public void MoveNext()
    {
        // Local variables
        Int32        state;            // V_0
        TaskAwaiter  delayTaskAwaiter; // V_1
        StateMachine statemachine;     // V_2
        Exception    exception;        // V_3

        state = this.State;

        try
        {
            switch (state)
            {
                default:  // PART 1:
                    DoWork();
                    Task delayTask = Task.Delay(2000);
                    delayTaskAwaiter = delayTask.GetAwaiter();
                    if (delayTaskAwaiter.IsCompleted) goto IsCompleted;
                    state = 0;
                    this.State = 0;
                    this.DelayTaskAwaiter = delayTaskAwaiter;
                    statemachine = this;  // V_2
                    this.Builder.AwaitUnsafeOnCompleted(ref delayTaskAwaiter, ref statemachine);
                    return;

                case 0:  // PART 2:
                    delayTaskAwaiter = this.DelayTaskAwaiter;
                    this.DelayTaskAwaiter = default;  // (release previous DelayTaskAwaiter
                                                      // for garbage collection)
                    state = -1;
                    this.State = -1;
IsCompleted:        delayTaskAwaiter.GetResult();
                    MoreWork();
                    Console.Write("Press Enter: ");
                    Console.ReadLine();
                    break;
            }
        }
        catch (Exception ex)
        {
            exception = ex;
            this.State = -1;
            this.Builder.SetException(exception);
            return;
        }

        this.State = -2;
        this.Builder.SetResult();
        return;
    }

    public void SetStateMachine(IAsyncStateMachine stateMachine)
    {
        return;
    }
}

We can simplify this a little bit more by localizing the definition of variable statemachine and eliminating the unnecessary local variable named exception:

C#
class StateMachine : IAsyncStateMachine
{
    public int State;                       // 1__state
    public AsyncTaskMethodBuilder Builder;  // t__builder
    private TaskAwaiter DelayTaskAwaiter;   // u__1;

    public void MoveNext()
    {
        // Local variables
        Int32           state;            // V_0
        TaskAwaiter     DelayTaskAwaiter; // V_1
        StateMachine statemachine;     // V_2
        Exception    exception;        // V_3

        state = this.State;

        try
        {
            switch (state)
            {
                default:  // PART 1:
                    DoWork();
                    Task delayTask = Task.Delay(2000);
                    delayTaskAwaiter = delayTask.GetAwaiter();
                    if (delayTaskAwaiter.IsCompleted) goto IsCompleted;
                    state = 0;
                    this.State = 0;
                    this.DelayTaskAwaiter = delayTaskAwaiter;
                    StateMachine statemachine = this;  // V_2
                    this.Builder.AwaitUnsafeOnCompleted(ref awaiter, ref statemachine);
                    return;

                case 0:  // PART 2:
                    delayTaskAwaiter = this.DelayTaskAwaiter;
                    this.DelayTaskAwaiter = default;  // (release previous DelayTaskAwaiter
                                                      // for garbage collection)
                    state = -1;
                    this.State = -1;
IsCompleted:        delayTaskAwaiter.GetResult();
                    MoreWork();
                    Console.Write("Press Enter: ");
                    Console.ReadLine();
                    break;
            }
        }
        catch (Exception ex)
        {
            exception = ex;
            this.State = -1;
            this.Builder.SetException(ex);
            return;
        }

        this.State = -2;
        this.Builder.SetResult();
        return;
    }

    public void SetStateMachine(IAsyncStateMachine stateMachine)
    {
        return;
    }
}

Faithful C# Representation of Compiled Program

Putting this all together and arranging the methods in a logical order gives us our complete C# program:

C#
using System;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleAppTCSa
{
    class Program
    {
        static void Main()                      // <Main> : void  // the main entrypoint
        {
            Task statemachineTask = SubMain();
            TaskAwaiter statemachineTaskAwaiter = statemachineTask.GetAwaiter();
            statemachineTaskAwaiter.GetResult();
            return;
        }

        private static Task SubMain()          // Main : class
                                               // [mscorlib]System.Threading.Tasks.Task()
        {
            StateMachine statemachine = new StateMachine();
            statemachine.Builder = new AsyncTaskMethodBuilder();
            statemachine.State = -1;
            statemachine.Builder.Start(ref statemachine);
            return statemachine.Builder.Task;
        }

        class StateMachine : IAsyncStateMachine     // <Main>d__0
        {
            public int State;                       // 1__state
            public AsyncTaskMethodBuilder Builder;  // t__builder
            private TaskAwaiter DelayTaskAwaiter;   // u__1;

            public void MoveNext()
            {
                // Local variables
                Int32       state;                  // V_0
                TaskAwaiter delayTaskAwaiter;       // V_1

                state = this.State;

                try
                {
                    switch (state)
                    {
                        default:  // case -1
                            DoWork();                                            // ┐ Part 1
                            Task delayTask = Task.Delay(2000);                   //
                            delayTaskAwaiter = delayTask.GetAwaiter();           //
                            if (delayTaskAwaiter.IsCompleted) goto IsCompleted;  //
                            state = 0;                                           //
                            this.State = 0;                                      //
                            this.DelayTaskAwaiter = delayTaskAwaiter;            //
                            StateMachine statemachine = this;                    //
                            this.Builder.AwaitUnsafeOnCompleted                  //
                                 (ref delayTaskAwaiter, ref statemachine);       //
                            return;                                              //

                        case 0:
                            delayTaskAwaiter = this.DelayTaskAwaiter;            // ┐ Part 2
                            this.DelayTaskAwaiter = default;                     //
                            state = -1;                                          //
                            this.State = -1;                                     //
               IsCompleted: delayTaskAwaiter.GetResult();                        //
                            MoreWork();                                          //
                            Console.Write("Press Enter: ");                      //
                            Console.ReadLine();                                  //
                            break;
                    }
                }
                catch (Exception ex)
                {
                    this.State = -1;
                    this.Builder.SetException(ex);
                    return;
                }

                this.State = -2;
                this.Builder.SetResult();
                return;
            }

            public void SetStateMachine(IAsyncStateMachine stateMachine)
            {
                return;
            }
        }

        private static void Display(string s)  // Display : void()
        {
            Console.WriteLine("{0:hh\\:mm\\:ss\\:fff} {1}", DateTime.Now, s);
        }

        private static void DoWork()           // DoWork : void()
        {
            Display("Enter DoWork()");
            Thread.Sleep(2000);
            Display("Exit DoWork()");
        }

        private static void MoreWork()         // MoreWork : void()
        {
            Display("Enter MoreWork()");
            Thread.Sleep(2000);
            Display("Exit MoreWork()");
        }
    }
}

This is a faithful C# representation of what the compiler created when it compiled our original program. This code compiles and runs just fine, and it performs the same actions as the original code we started with. The main difference is this version does not use the async/await keywords.

Simplifying

Let's simplify our faithful C# representation of our compiled program in order to unveil the basic concepts of what's going on.

Simplifying Main()

Starting with method Main():

C#
static void Main()              // <Main> : void  // the main entrypoint
{
    Task statemachineTask = SubMain();
    TaskAwaiter statemachineTaskAwaiter = statemachineTask.GetAwaiter();
    statemachineTaskAwaiter.GetResult();
    return;
}

In this method, the second line:

C#
TaskAwaiter statemachineTaskAwaiter = statemachineTask.GetAwaiter()

returns a TaskAwaiter. If we look at the code for Task.GetAwaiter() in https://referencesource.microsoft.com, we will find that internally the returned TaskAwaiter holds a pointer to task statemachineTask in a private field named m_task.

The third line:

C#
statemachineTaskAwaiter.GetResult()

internally calls statemachineTask.Wait() and then checks the ending status of the task. We can simplify these two lines of code and eliminate the TaskAwaiter:

C#
static void Main()                      // <Main> : void  // the main entrypoint
{
    Task statemachineTask = SubMain();
    TaskAwaiter statemachineTaskAwaiter = statemachineTask.GetAwaiter();
    statemachineTaskAwaiter.GetResult();
    statemachineTask.Wait();
    if (!statemachineTask.IsRanToCompletion) ThrowForNonSuccess(statemachineTask);
    return;
}

The statemachineTask.Wait() is accomplished by using Monitor.Wait(m_lock) and Monitor.PulseAll(m_lock). The call to Monitor.Wait(m_lock) does not return until another thread calls Monitor.PulseAll(m_lock).

First, we set the continuation action to run Monitor.PulseAll(m_lock), then we call Monitor.Wait(m_lock) and wait.

C#
static void Main()                      // <Main> : void  // the main entrypoint
{
    private volatile object m_lock = new Object();

    Task statemachineTask = SubMain();
    statemachineTask.Wait();
    statemachineTask.continuationObject = new Action(() =>
        lock (m_lock)
        {
            Monitor.PulseAll(m_lock));
        });

    lock (m_lock)
    {
        Monitor.Wait(m_lock);
    }

    if (!statemachineTask.IsRanToCompletion) ThrowForNonSuccess(statemachineTask);
    return;
}

When statemachineTask transitions to the completed state, it runs the continuation action which calls Monitor.PulseAll(m_lock), which releases us from the wait.

Simplifying MoveNext()

Moving on to class StateMachine method MoveNext(), the call to if (delayTaskAwaiter.IsCompleted) goto IsCompleted; is just an optimization to skip waiting for delayTask to complete if it's already completed. Let's remove this optimization and the unnecessary optimizing local variables:

C#
class StateMachine : IAsyncStateMachine     // <Main>d__0
{
    public int State;                       // 1__state
    public AsyncTaskMethodBuilder Builder;  // t__builder
    private TaskAwaiter DelayTaskAwaiter;   // u__1;

    public void MoveNext()
    {
        // Local variables
        Int32       state;                  // V_0
        TaskAwaiter delayTaskAwaiter;       // V_1

        state = this.State;

        try
        {
            switch (this.State)
            {
                default:  // case -1
                    DoWork();                                                     // ┐ Part 1
                    Task delayTask = Task.Delay(2000);                            //
                    TaskAwaiter delayTaskAwaiter = delayTask.GetAwaiter();        //
                    if (delayTaskAwaiter.IsCompleted) goto IsCompleted;           //
                    state = 0;                                                    //
                    this.State = 0;                                               //
                    this.DelayTaskAwaiter = delayTaskAwaiter;                     //
                    StateMachine statemachine = this;                             //
                    this.Builder.AwaitUnsafeOnCompleted                           //
                         (ref delayTaskAwaiter, ref statemachine);                //
                    return;                                                       //

                case 0:
                    delayTaskAwaiter = this.DelayTaskAwaiter;                     // ┐ Part 2
                    this.DelayTaskAwaiter = default;                              //
                    state = -1;                                                   //
                    this.State = -1; // unnecessary                               // │
       IsCompleted: this.DelayTaskAwaiter.GetResult();                            //
                    MoreWork();                                                   //
                    Console.Write("Press Enter: ");                               //
                    Console.ReadLine();                                           //
                    break;
            }
        }
        catch (Exception ex)
        {
            this.State = -1;  // unnecessary
            this.Builder.SetException(ex);
            return;
        }

        this.State = -2;  // unnecessary
        this.Builder.SetResult();
        return;
    }

    public void SetStateMachine(IAsyncStateMachine stateMachine)
    {
        return;
    }
}

This leaves us with:

C#
class StateMachine : IAsyncStateMachine     // <Main>d__0
{
    public int State;                       // 1__state
    public AsyncTaskMethodBuilder Builder;  // t__builder

    public void MoveNext()
    {
        try
        {
            switch (this.State)
            {
                default:  // case -1
                    DoWork();                                               // ┐ Part 1
                    Task delayTask = Task.Delay(2000);                      //
                    TaskAwaiter delayTaskAwaiter = delayTask.GetAwaiter();  //
                    this.State = 0;                                         //
                    StateMachine statemachine = this;                       //
                    this.Builder.AwaitUnsafeOnCompleted                     //
                         (ref delayTaskAwaiter, ref statemachine);          //
                    return;                                                 //

                case 0:
                    MoreWork();                                             // ┐ Part 2
                    Console.Write("Press Enter: ");                         //
                    Console.ReadLine();                                     //
                    break;
            }
        }
        catch (Exception ex)
        {
            this.Builder.SetException(ex);
            return;
        }

        this.Builder.SetResult();
        return;
    }

    public void SetStateMachine(IAsyncStateMachine stateMachine)
    {
        return;
    }
}

Examining the MoveNext() switch statement case default: which runs Part 1:

C#
default:  // case -1
    DoWork();                                                                   // ┐ Part 1
    Task delayTask = Task.Delay(2000);                                          //
    TaskAwaiter delayTaskAwaiter = delayTask.GetAwaiter();                      //
    this.State = 0;                                                             //
    StateMachine statemachine = this;                                           //
    this.Builder.AwaitUnsafeOnCompleted(ref delayTaskAwaiter, ref statemachine);//
    return;                                                                     //

The line:

C#
this.Builder.AwaitUnsafeOnCompleted(ref delayTaskAwaiter, ref statemachine);

sets the continuation to be run when task delayTask completes. (Recall that delayTaskAwaiter holds delayTask in a private field named m_task.) The continuation to run when delayTask completes is statemachine.MoveNext(). We can conceptually replace this line with:

C#
delayTask.continuationObject = new Action(() => this.MoveNext());
C#
default:  // case -1
    DoWork();                                                                   // ┐ Part 1
    Task delayTask = Task.Delay(2000);                                          //
    TaskAwaiter delayTaskAwaiter = delayTask.GetAwaiter();                      //
    this.State = 0;                                                             //
    StateMachine statemachine = this;                                           //
    this.Builder.AwaitUnsafeOnCompleted(ref delayTaskAwaiter, ref statemachine);//
    delayTask.continuationObject = new Action(() => this.MoveNext());           //
    return;                                                                     //

Simplifying SubMain()

In SubMain(), the line statemachine.Builder.Start(ref statemachine) basically calls statemachine.MoveNext().

C#
private static Task SubMain()          // Main : class [mscorlib]System.Threading.Tasks.Task()
{
    StateMachine statemachine = new StateMachine();
    statemachine.Builder = new AsyncTaskMethodBuilder();
    statemachine.State = -1;
    statemachine.Builder.Start(ref statemachine);
    statemachine.MoveNext()
    return statemachine.Builder.Task;
}

The line return statemachine.Builder.Task; returns a new task which Builder creates. I'll call this task statemachineTask. This task doesn't actually run anything, the task is only used as a flag to signal when the state machine has completed, and as a place to store a result value if there is one. This task is manipulated in two places by MoveNext():

  • this.Builder.SetResult();
    This sets the statemachineTask state to "RanToCompletion" and calls the statemachineTask.continuationObject action.
  • this.Builder.SetException(ex);
    This gets called if an exception is thrown while running Part 1 or Part 2 of the state machine. This adds the exception ex to statemachineTask.m_exceptionsHolder which is a list of exceptions, sets the statemachineTask state to "Faulted", and then calls the statemachineTask.continuationObject action.

I'll pull statemachineTask out of Builder and place it in MoveNext(). Then, we won't need Builder anymore.

C#
private static Task SubMain()          // Main : class [mscorlib]System.Threading.Tasks.Task()
{
    StateMachine statemachine = new StateMachine();
    statemachine.Builder = new AsyncTaskMethodBuilder();
    statemachine.State = -1;
    statemachine.MoveNext();
    return statemachine.Builder.Task;
    return statemachine.statemachineTask;
}

class StateMachine : IAsyncStateMachine         // <Main>d__0
{
    public int State;                           // 1__state
    public AsyncTaskMethodBuilder Builder;      // t__builder
    public Task statemachineTask = new Task();  // was Builder.Task

    public void MoveNext()
    {
        try
        {
            switch (this.State)
            {
                default:  // case -1
                    DoWork();                                           // ┐ Part 1
                    Task delayTask = Task.Delay(2000);                  //
                    this.State = 0;                                     //
                    delayTask.continuationObject =                      //
                           new Action(() => this.MoveNext());           //
                    return;                                             //

                case 0:
                    MoreWork();                                         // ┐ Part 2
                    Console.Write("Press Enter: ");                     //
                    Console.ReadLine();                                 //
                    break;
            }
        }
        catch (Exception ex)
        {
            this.Builder.SetException(ex);
            this.statemachineTask.m_stateFlags = m_stateFlags |
                      TASK_STATE_FAULTED; // set task state to "Faulted"
            Action action = this.statemachineTask.continuationObject as Action;
            action();
            return;
        }

        this.Builder.SetResult();
        this.statemachineTask.m_stateFlags = m_stateFlags |
             TASK_STATE_RAN_TO_COMPLETION; // set task state to "RanToCompletion"
        Action action = this.statemachineTask.continuationObject as Action;
        action();
        return;
    }

    public void SetStateMachine(IAsyncStateMachine stateMachine)
    {
        return;
    }
}

Simplified C# Representation

Putting this all together gives us a complete simplified version of our program which better shows what happens under the hood of an await statement:

C#
using System;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleAppTCSa
{
    class Program
    {
        static void Main()                      // <Main> : void  // the main entrypoint
        {
            private volatile object m_lock = new Object();

            Task statemachineTask = SubMain();

            statemachineTask.continuationObject = new Action(() =>
                lock (m_lock)
                {
                    Monitor.PulseAll(m_lock));
                });

            lock (m_lock)
            {
                Monitor.Wait(m_lock);
            }

            if (!statemachineTask.IsRanToCompletion) ThrowForNonSuccess(statemachineTask);
            return;
        }

        private static Task SubMain()          // Main : class
                                               // [mscorlib]System.Threading.Tasks.Task()
        {
            StateMachine statemachine = new StateMachine();
            statemachine.State = -1;
            statemachine.MoveNext();
            return statemachine.statemachineTask;
        }

        class StateMachine : IAsyncStateMachine         // <Main>d__0
        {
            public int State;                           // 1__state
            public Task statemachineTask = new Task();  // was Builder.Task

            public void MoveNext()
            {
                try
                {
                    switch (this.State)
                    {
                        default:  // case -1
                            DoWork();                                             // ┐ Part 1
                            Task delayTask = Task.Delay(2000);                    //
                            this.State = 0;                                       //
                            delayTask.continuationObject =                        //
                                       new Action(() => this.MoveNext());         //
                            return;                                               //

                        case 0:
                            MoreWork();                                           // ┐ Part 2
                            Console.Write("Press Enter: ");                       //
                            Console.ReadLine();                                   //
                            break;
                    }
                }
                catch (Exception ex)
                {
                    this.statemachineTask.m_stateFlags = m_stateFlags |
                            TASK_STATE_FAULTED; // set task state to "Faulted"
                    Action action = this.statemachineTask.continuationObject as Action;
                    action();
                    return;
                }

                this.statemachineTask.m_stateFlags = m_stateFlags |
                     TASK_STATE_RAN_TO_COMPLETION; // set task state to "RanToCompletion"
                Action action = this.statemachineTask.continuationObject as Action;
                action();
                return;
            }

            public void SetStateMachine(IAsyncStateMachine stateMachine)
            {
                return;
            }
        }

        private static void Display(string s)  // Display : void()
        {
            Console.WriteLine("{0:hh\\:mm\\:ss\\:fff} {1}", DateTime.Now, s);
        }

        private static void DoWork()           // DoWork : void()
        {
            Display("Enter DoWork()");
            Thread.Sleep(2000);
            Display("Exit DoWork()");
        }

        private static void MoreWork()         // MoreWork : void()
        {
            Display("Enter MoreWork()");
            Thread.Sleep(2000);
            Display("Exit MoreWork()");
        }
    }
}

While this program will not compile because it directly references some private fields as if they were public, we can still trace what happens if the above program were to run. This will give us the insight we seek into how the await keyword works.

Tracing What Happens During an Await

Following the code above, we can now trace the flow of our program:

Part 1

  • Main() calls SubMain()
    • SubMain()
      1. creates statemachine
      2. initializes statemachine to State = -1
      3. calls statemachine.MoveNext()
        1. MoveNext() sees that this.State == -1 and jumps to case default: where it runs Part 1:
          1. DoWork() is called
          2. Task.Delay(2000) is called
            • Task.Delay(2000) sets up a timer to expire in 2 seconds and returns delayTask.
          3. this.State is set to 0 (so Part 2 will run next time MoveNext() is called)
          4. delayTask.continuationObject is set to this.MoveNext()
          5. returns to SubMain()
      4. SubMain() returns statemachine.statemachineTask to Main()
  • At this point, we're all set up. All we have to do now is sit back and do nothing from here on.
“You don't always need to be getting stuff done. Sometimes, it's perfectly okay, and absolutely necessary, to shut down, kick back, and do nothing.” — Lori Deschene, Tiny Buddha website

The rest of our program will be handled by delayTask when the timer expires. The continuation action for delayTask is set to run MoveNext(), which will run Part 2 of our code.

If this were a GUI program and Main() was instead a Button_Click() event handler, then we would ignore the returned statemachineTask and just return to the Message Loop, allowing the Message Loop to handle other messages in the Message Queue. Since I chose a Console program for this demonstration, Main() does not return at this point because the program would end. Instead, Main() first waits for the returned statemachineTask to complete.

  • Main() waits for stateMachine to complete:
    1. Sets statemachineTask.continuationObject to run Monitor.PulseAll(m_lock) when statemachineTask transitions to a completed state.
    2. Calls Monitor.Wait(m_lock)
      This places the Main thread in a waiting state. The Main thread status is changed to "SleepJoinWait", and the Main thread is removed from the "running" queue, where threads receive CPU time, and placed in a "waiting" queue. The thread receives no further CPU time because it is no longer in the running queue.

Part 2

“Sitting quietly, doing nothing, spring comes, and grass grows by itself.” — Zen Proverb
  • Sometime later, the 2 second timer expires and a ThreadPool thread runs the code which was set up to handle the timer expiration. This code calls the delayTask.continuationObject action, which is MoveNext() (this was set by MoveNext() Part 1 after it called Task.Delay(2000) and delayTask was returned to it).

    (If this were a GUI program, instead of directly calling MoveNext(), the continuation action would be slightly more complex and first check to see if we needed to run the continuation action on the GUI thread, and if so, it would queue MoveNext() to the Message Queue so MoveNext() would be run on the GUI thread.)
    1. MoveNext() checks this.State, sees that this.State == 0, and jumps to case 0: where it runs Part 2.
      1. MoreWork() is called.
      2. Console.Write("Press Enter: ") is called.
      3. Console.ReadLine() is called.
        1. The thread waits for the user to press the Enter key.
        2. The user presses the Enter key.
        3. Console.ReadLine() returns.
      4. break;

      We have now finished running both Part 1 and Part 2. All that is left to do now is cleanup.

    2. statemachineTask state is set to "RanToCompletion".
    3. The statemachineTask.continuationAction is called, which is Monitor.PulseAll() (this was set earlier by Main() when SubMain() returned statemachineTask to it).
      1. Monitor.PulseAll(m_lock) takes the thread that was placed in limbo via Monitor.Wait(m_lock), changes its state from "WaitSleepJoin" back to "Running", and returns the thread back to the "running" queue where it will start receiving CPU time slices again.
      2. Monitor.PulseAll(m_lock) returns to MoveNext() which called it.
    4. MoveNext() returns to its caller, which was the ThreadPool, freeing up the thread.
  • Meanwhile, the Main thread that was waiting via Monitor.Wait(m_lock) has now been returned to the "running" queue and starts running again. The Main thread checks the status of statemachineTask to see if it ran successfully to completion, which it did.
  • The Main thread's next (and last) statement is return, where it returns to the operating system which called Main() in the first place.
  • The operating system performs a cleanup and terminates the program.

You may notice a potential race condition here, as at this point, we don't know if the ThreadPool thread has completed its task and returned to the ThreadPool yet. It turns out that releasing the Main thread was the last thing the ThreadPool thread needed to do. Once it releases the Main thread, its job is done. It doesn't do anything useful after that. The Main thread can go ahead and perform cleanup, and we don't really care about that ThreadPool thread anymore or what state it's in.

And that's a detailed walkthrough of what happens when we run the seemingly simple 5 line program:

C#
public static async Task Main()
{
    DoWork();                        // ┐ Part 1
    await Task.Delay(2000);          //

    MoreWork();                      // ┐ Part 2
    Console.Write("Press Enter: ");  //
    Console.ReadLine();              //
}

Await in a Button_Click Event Handler

Let's now have a look at what happens when await is used in a WPF Button_Click() event handler method. Assume we have a method that looks like:

C#
private async void Button_Click(object sender, RoutedEventArgs e)
{
    DoWork();                        // ┐ Part 1
    await Task.Delay(2000);          //

    MoreWork();                      // Part 2
}

This is similar to the 5 line console program we started with above. One difference is here, we have two parameters passed to us: sender and e. Both of these parameters happen to be unused in our sample code; however, they are available to us if we wanted to use them.

Compiling this code and then disassembling using the IL DISM disassembler, and then translating the CIL code back into C#, we will find that the compiler generated the CIL equivalent of:

C#
private void Button_Click(object sender, RoutedEventArgs e)
{
    Statemachine stateMachine = new Statemachine();
    statemachine.Builder = new AsyncTaskMethodBuilder();
    statemachine.This = this;
    statemachine.Sender = sender;
    statemachine.E = e;
    statemachine.State = -1;
    statemachine.Builder.Start(ref statemachine);
    return;
}

This is nearly identical to method SubMain() in our previous example above, except the code now also sets three more fields in statemachine:

  1. this
  2. sender
  3. e

The other difference is the method returns void instead of returning statemachine.Builder.Task as the console version did. The task statemachine.Builder.Task is ignored in this scenario, and the thread returns to the Message Loop where it goes on to process other messages.

Everything else is the same as before. When we return from Button_Click(), delayTask has its continuationObject set to run MoveNext(), and statemachine.State == 0 so it's set to run the next part of the statemachine (Part 2).

There is one other additional difference: the continuationObject is now a special class which contains both the continuation Action and also the SynchronizationContext, which is what gets us back to the GUI thread if we were on the GUI thread to begin with and need to get back to it. If we don't need to jump back to the GUI thead, then MoveNext() is called directly; if we do need to jump back to the GUI thread, then MoveNext() is queued to the Message Queue. How this is accomplished, along with an explanation of the Message Queue and Message Loop, is explained in another essay of mine: Async/Await Explained with Diagrams and Examples: Getting Back to the UI Thread.

Tracing AwaitUnsafeOnCompleted

The following details are provided for anyone wishing to dig deeper into how the task continuation is set up for a WPF Button_Click() event handler.

The line this.Builder.AwaitUnsafeOnCompleted(ref delayTaskAwaiter, ref statemachine); creates an instance of class SynchronizationContextAwaitTaskContinuation and stores that as the task.continuationObject. The code in class Task which executes this task.continuationObject checks to see if the stored object is a TaskContinuation, which SynchronizationContextAwaitTaskContinuation derives from, and if so, it calls the taskContinuation.Run() method. The Run() method takes care of running the continuation action on the correct thread. The continuation action is still statemachine.MoveNext().

Tracing the call to AwaitUnsafeOnCompleted, which passes delayTaskAwaiter as awaiter, and recalling that delayTaskAwaiter holds delayTask in local field m_task, the internal calls are:

C#
this.Builder.AwaitUnsafeOnCompleted(ref awaiter, ref statemachine);
 awaiter.UnsafeOnCompleted(continuation);
  TaskAwaiter.OnCompletedInternal(m_task, continuation,
              continueOnCapturedContext:true, flowExecutionContext:false);
    task.SetContinuationForAwait(continuation, continueOnCapturedContext, flowExecutionContext,…
     if (continueOnCapturedContext)
      tc = new SynchronizationContextAwaitTaskContinuation(syncCtx, continuationAction,…

tc is our delayTask task continuation, which gets set to a SynchronizationContextAwaitTaskContinuation.

After calling AwaitUnsafeOnCompleted, Button_Click() returns to its caller, which is the Message Loop. The Message Loop then calls GetMessage() which grabs the next message from the Message Queue. The next message may be a user input such as a keystroke or mouse click; or, if there are no messages in the Message Queue, then GetMessage() checks to see if a timer has expired and if so it creates a WM_TIMER message and returns that.

Timers

This brings up an interesting side note about timers which use the Message Queue. For every timer that is currently set, Windows maintains a counter value that it decrements on every hardware timer tick. When this counter reaches 0, Windows sets a timer expired flag in the appropriate application’s Message Queue header. When the Message Loop calls GetMessage(), if the Message Queue is empty, GetMessage() then checks the timer expired flag, and if it is set, GetMessage() resets the timer expired flag, and generates and returns a WM_TIMER message.

Since a check to see if a timer has expired happens only when the Message Queue is empty, it's possible for the WM_TIMER message to be delayed if there are a lot of messages in the Message Queue, or if all the CPUs are currently busy doing other things, possibly things totally unrelated to the current running program.

It's even possible that multiple timer periods may expire before a WM_TIMER message is returned from the Message Queue. However, only one WM_TIMER message will be returned, and it will represent all expired periods.

It's also possible GetMessage() could return a delayed WM_TIMER message just before another time period expires, and, subsequently, return a second WM_TIMER message almost immediately after the first.

Summary

This completes our deep dive under the hood look at what happens with the await keyword. I hope this essay has been helpful.

History

  • 11th March, 2022: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)