Last week’s article tested the performance of the async and await keywords plus the C# Task system against Unity’s new C# jobs system. This week we’ll go in depth with async and await to learn how they work, how they relate to the Task system, and how we can customize them for our own uses.

An “await expression” consists of two parts: the await keyword and then a “task.” The task is not necessarily the Task or Task<T> type. Instead, it’s a general concept described in §12.8.8 of the C# language specification as follows:

It represents an asynchronous operation that may or may not be complete at the time the await-expression is evaluated. The purpose of the await operator is to suspend execution of the enclosing async function until the awaited task is complete, and then obtain its outcome.

That’s a bit abstract, but the language spec goes into detail in §12.8.8.2 about what is required of a task. Specifically, it’s either a dynamic type or a non-dynamic type that has a A GetAwaiter() instance or extension method. The A return type is the “awaiter” type and it must have the following:

The spec goes on to describe the purpose of each of these elements:

  • GetAwaiter is self-explanatory: it returns the awaiter object.
  • IsCompleted is called by the system to check if the task is already complete and therefore there’s no reason to suspend the async function and call OnCompleted.
  • OnCompleted is called to schedule a “continuation” to the task. This is an Action delegate that’s invoked when the task is done.
  • GetResult is called to get the outcome of the task when it’s done. This can be any type or even void if there is no outcome. If the task failed, this method throws an exception to indicate that.

The awaiter type can also optionally implement System.Runtime.CompilerServices.ICriticalNotifyCompletion. If it does, its void UnsafeOnCompleted(Action) method will be called instead of OnCompleted.

The following pseudo-code shows what the system does when we write var outcome = await task:

var awaiter = task.GetAwaiter();
if (awaiter.IsCompleted)
{
    // Remove 'outcome =' if `GetResult` returns void
    outcome = awaiter.GetResult();
}
else
{
    SuspendTheFunction();
 
    Action continuation = () => {
        ResumeTheFunction();
        // Remove 'outcome =' if `GetResult` returns void
        outcome = awaiter.GetResult();
    };
    var cnc = awaiter as ICriticalNotifyCompletion;
    if (cnc != null)
    {
        cnc.UnsafeOnCompleted(continuation);
    }
    else
    {
        awaiter.OnCompleted(continuation);
    }
}

SuspendTheFunction and ResumeTheFunction are magic functions for now, but we’ll come back to them later on.

Now we can try our hand at creating custom task, awaiter, and outcome types. The following example takes in an int Value and produces a string Message output:

using System;
using System.Runtime.CompilerServices;
using UnityEngine;
 
public class TestScript : MonoBehaviour
{
    public struct MyOutcome
    {
        public string Message;
    }
 
    public struct MyAwaiter : INotifyCompletion
    {
        public int Value;
 
        public void OnCompleted(Action continuation)
        {
            Debug.Log("OnCompleted. Value = " + Value);
            continuation();
        }
 
        public bool IsCompleted
        {
            get
            {
                Debug.Log("IsCompleted returning false");
                return false;
            }
        }
 
        public MyOutcome GetResult()
        {
            Debug.Log("GetResult. Value = " + Value);
            return new MyOutcome { Message = "You gave me: " + Value };
        }
    }
 
    public struct MyTask
    {
        public int Value;
 
        public MyAwaiter GetAwaiter()
        {
            Debug.Log("GetAwaiter. Value = " + Value);
            return new MyAwaiter { Value = Value };
        }
    }
 
    async void Awake()
    {
        Debug.Log("Calling await...");
        MyOutcome outcome = await new MyTask { Value = 5 };
        Debug.Log("Outcome: " + outcome.Message);
    }
}

Running this prints these log messages:

Calling await...
GetAwaiter. Value = 5
IsCompleted returning false
OnCompleted. Value = 5
GetResult. Value = 5
Outcome: You gave me: 5

If we change IsCompleted to return true instead of false, we see that OnCompleted isn’t called anymore:

Calling await...
GetAwaiter. Value = 5
IsCompleted returning false
GetResult. Value = 5
Outcome: You gave me: 5

Now let’s revisit the “magic” SuspendTheFunction and ResumeTheFunction functions from the pseudo-code that the system runs. To see how the system is able to suspend and resume the function, let’s look at the IL2CPP output for iOS. Here’s TestScript.Awake, which I’ve cleaned up, annotated, and removed irrelevant code from:

extern "C" IL2CPP_METHOD_ATTR void TestScript_Awake_m78176435 (TestScript_t3771403385 * __this, const RuntimeMethod* method)
{
    // Create and initialize a compiler-generated "state machine" object
    U3CAwakeU3Ec__async0_t822019144  V_0;
    memset(&V_0, 0, sizeof(V_0));
 
    // Create a AsyncVoidMethodBuilder and set it on the state machine
    AsyncVoidMethodBuilder_t3819840891  L_0 = AsyncVoidMethodBuilder_Create_m1976941025(NULL /*static, unused*/, /*hidden argument*/NULL);
    (&V_0)->set_U24builder_1(L_0);
 
    // Call AsyncVoidMethodBuilder.Start with the state machine
    AsyncVoidMethodBuilder_t3819840891 * L_1 = (&V_0)->get_address_of_U24builder_1();
    AsyncVoidMethodBuilder_Start_TisU3CAwakeU3Ec__async0_t822019144_m817856767((AsyncVoidMethodBuilder_t3819840891 *)L_1, (U3CAwakeU3Ec__async0_t822019144 *)(&V_0), /*hidden argument*/AsyncVoidMethodBuilder_Start_TisU3CAwakeU3Ec__async0_t822019144_m817856767_RuntimeMethod_var);
}

Let’s look at the state machine object the compiler generated for us:

struct  U3CAwakeU3Ec__async0_t822019144 
{
public:
    // The outcome
    MyOutcome_t3673921053  ___U3CoutcomeU3E__0_0;
 
    // The AsyncVoidMethodBuilder
    AsyncVoidMethodBuilder_t3819840891  ___U24builder_1;
 
    // Keeps track of the current state of the state machine
    int32_t ___U24PC_2;
 
    // The awaiter
    MyAwaiter_t3895522201  ___U24awaiter0_3;
};

Now let’s look at AsyncVoidMethodBuilder.Start:

extern "C" IL2CPP_METHOD_ATTR void AsyncVoidMethodBuilder_Start_TisU3CAwakeU3Ec__async0_t822019144_m817856767_gshared (AsyncVoidMethodBuilder_t3819840891 * __this, U3CAwakeU3Ec__async0_t822019144 * ___stateMachine0, const RuntimeMethod* method)
{
    // Call MoveNext on the state machine
    U3CAwakeU3Ec__async0_t822019144 * L_2 = ___stateMachine0;
    U3CAwakeU3Ec__async0_MoveNext_m102017332((U3CAwakeU3Ec__async0_t822019144 *)(U3CAwakeU3Ec__async0_t822019144 *)L_2, /*hidden argument*/NULL);
    IL2CPP_LEAVE(0x42, FINALLY_003a);
}

Finally we can look at the state machine’s MoveNext:

extern "C" IL2CPP_METHOD_ATTR void U3CAwakeU3Ec__async0_MoveNext_m102017332 (U3CAwakeU3Ec__async0_t822019144 * __this, const RuntimeMethod* method)
{
    // Create and initialize a MyTask
    uint32_t V_0 = 0;
    MyTask_t2112936038  V_1;
    memset(&V_1, 0, sizeof(V_1));
 
    // Switch on the current state
    uint32_t L_1 = V_0;
    switch (L_1)
    {
        case 0:
        {
            goto IL_0021;
        }
        case 1:
        {
            goto IL_0076;
        }
    }
 
// Default case. Just return.
IL_001c:
    goto IL_00d1;
 
// First state
IL_0021:
    // Call Debug.Log("Calling await...)
    IL2CPP_RUNTIME_CLASS_INIT(Debug_t3317548046_il2cpp_TypeInfo_var);
    Debug_Log_m4051431634(NULL /*static, unused*/, _stringLiteral2193103261, /*hidden argument*/NULL);
 
    // Create a MyTask
    il2cpp_codegen_initobj((&V_1), sizeof(MyTask_t2112936038 ));
 
    // MyTask.Value = 5
    (&V_1)->set_Value_0(5);
 
    // Call MyTask.GetAwaiter()
    MyAwaiter_t3895522201  L_2 = MyTask_GetAwaiter_m4206088222((MyTask_t2112936038 *)(&V_1), /*hidden argument*/NULL);
 
    // Set the awaiter on the state machine
    __this->set_U24awaiter0_3(L_2);
 
    // Call MyAwaiter.IsCompleted
    MyAwaiter_t3895522201 * L_3 = __this->get_address_of_U24awaiter0_3();
    bool L_4 = MyAwaiter_get_IsCompleted_m4242789144((MyAwaiter_t3895522201 *)L_3, /*hidden argument*/NULL);
 
    // If IsCompleted returned true, go to the second state
    if (L_4)
    {
        goto IL_0076;
    }
 
// IsCompleted returned false
IL_0058:
    // Indirectly call MyAwaiter.OnCompleted
    __this->set_U24PC_2(1);
    AsyncVoidMethodBuilder_t3819840891 * L_5 = __this->get_address_of_U24builder_1();
    MyAwaiter_t3895522201 * L_6 = __this->get_address_of_U24awaiter0_3();
    AsyncVoidMethodBuilder_AwaitOnCompleted_TisMyAwaiter_t3895522201_TisU3CAwakeU3Ec__async0_t822019144_m1251349311((AsyncVoidMethodBuilder_t3819840891 *)L_5, (MyAwaiter_t3895522201 *)L_6, (U3CAwakeU3Ec__async0_t822019144 *)__this, /*hidden argument*/AsyncVoidMethodBuilder_AwaitOnCompleted_TisMyAwaiter_t3895522201_TisU3CAwakeU3Ec__async0_t822019144_m1251349311_RuntimeMethod_var);
 
    // Return
    goto IL_00d1;
 
// IsCompleted returned true
IL_0076:
    // Call MyAwaiter.GetResult()
    MyAwaiter_t3895522201 * L_7 = __this->get_address_of_U24awaiter0_3();
 
    // Set outcome on the state machine
    MyOutcome_t3673921053  L_8 = MyAwaiter_GetResult_m2482822138((MyAwaiter_t3895522201 *)L_7, /*hidden argument*/NULL);
    __this->set_U3CoutcomeU3E__0_0(L_8);
 
    // Call Debug.Log("Outcome: " + outcome.Message)
    MyOutcome_t3673921053 * L_9 = __this->get_address_of_U3CoutcomeU3E__0_0();
    String_t* L_10 = L_9->get_Message_0();
    String_t* L_11 = String_Concat_m3937257545(NULL /*static, unused*/, _stringLiteral593320699, L_10, /*hidden argument*/NULL);
    IL2CPP_RUNTIME_CLASS_INIT(Debug_t3317548046_il2cpp_TypeInfo_var);
    Debug_Log_m4051431634(NULL /*static, unused*/, L_11, /*hidden argument*/NULL);
 
    // Go to the end
    goto IL_00bf;
 
// The end
IL_00bf:
    // Terminate the state machine
    __this->set_U24PC_2((-1));
 
    // Clear the result
    AsyncVoidMethodBuilder_t3819840891 * L_14 = __this->get_address_of_U24builder_1();
    AsyncVoidMethodBuilder_SetResult_m1991744790((AsyncVoidMethodBuilder_t3819840891 *)L_14, /*hidden argument*/NULL);
 
IL_00d1:
    return;
}

The IL2CPP output is convoluted, but essentially it’s generated a state machine class for the async Awake function. So SuspendTheFunction is really just a return after one case of the switch in MoveNext that executes the current state. ResumeTheFunction is just calling MoveNext again. All the local variables are stored on an instance of the state machine class so they’re remembered across suspension and resumption of the async function.

At this point it’s good to notice a couple of things about what we’ve implemented above. First, at no point did we use Task or Task<T>. They’re simply not required by the async and await keywords since we can make our own “task” types. Likewise, Task and Task<T> can be used without async and await. So while the two work together well, there’s no requirement to use them together.

Second, we didn’t perform any threading in this example. Threading is also not required, but it’s easy to see how it fits in with the functions that the system will call. In fact, let’s create a small multithreading system of our own to see how we can implement something truly asynchronous:

using System;
using System.Runtime.CompilerServices;
using UnityEngine;
using System.Threading;
using System.Threading.Tasks;
 
public class TestScript : MonoBehaviour
{
    public static int CurrentThreadId
    {
        get
        {
            return Thread.CurrentThread.ManagedThreadId;
        }
    }
 
    public struct MyOutcome
    {
        public string Message;
    }
 
    public struct MyAwaiter : INotifyCompletion
    {
        public int Value;
 
        public void OnCompleted(Action continuation)
        {
            Debug.Log("OnCompleted. Thread = " + CurrentThreadId);
            SynchronizationContext context = SynchronizationContext.Current;
            new Thread(
                () => {
                    Debug.Log("In Thread. Thread = " + CurrentThreadId);
                    Thread.Sleep(500);
                    if (context != null)
                    {
                        context.Post(s => continuation(), null);
                    }
                    else
                    {
                        continuation();
                    }
                }
            ).Start();
        }
 
        public bool IsCompleted
        {
            get
            {
                Debug.Log("IsCompleted. Thread = " + CurrentThreadId);
                return false;
            }
        }
 
        public MyOutcome GetResult()
        {
            Debug.Log("GetResult. Thread = " + CurrentThreadId);
            return new MyOutcome { Message = "You gave me: " + Value };
        }
    }
 
    public struct MyTask
    {
        public int Value;
 
        public MyAwaiter GetAwaiter()
        {
            Debug.Log("GetAwaiter. Thread = " + CurrentThreadId);
            return new MyAwaiter { Value = Value };
        }
    }
 
    async void Awake()
    {
        Debug.Log("Awake Thread = " + CurrentThreadId);
        MyOutcome outcome = await new MyTask { Value = 5 };
        Debug.Log(
            "Outcome: " + outcome.Message
            + ". Thread = " + CurrentThreadId);
    }
 
    void Update()
    {
        Debug.Log("Update. Thread = " + CurrentThreadId);
    }
}

The major change here is to OnCompleted which now runs a Thread that calls Sleep for 500 milliseconds before calling the continuation Action. A SynchronizationContext is used to prevent resuming Awake off of the main thread by calling the continuation directly from there. The other changes are minor: logs now print the current thread ID and there is an Update to demonstrate that the main thread isn’t blocked.

Here’s the output of running this:

Awake Thread = 1
GetAwaiter. Thread = 1
IsCompleted. Thread = 1
OnCompleted. Thread = 1
In Thread. Thread = 131
Update. Thread = 1
Update. Thread = 1
Update. Thread = 1
Update. Thread = 1
Update. Thread = 1
Update. Thread = 1
Update. Thread = 1
Update. Thread = 1
Update. Thread = 1
Update. Thread = 1
Update. Thread = 1
Update. Thread = 1
Update. Thread = 1
Update. Thread = 1
GetResult. Thread = 1
Update. Thread = 1
Outcome: You gave me: 5. Thread = 1

The main thread is 1 and the thread created by OnCompleted is 131.

That wraps up today’s discussion of async and await. Hopefully this has clarified how the system works, how it’s only peripherally related to the Task system, and how it can be used with and without multithreading.