How Async and Await Work
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:
- Implement System.Runtime.CompilerServices.INotifyCompletion
- A
void OnCompleted(Action)
instance method - A
bool IsCompleted { get; }
property - A
R GetResult()
instance method. TheR
return type is known as the “outcome” type.
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 theasync
function and callOnCompleted
.OnCompleted
is called to schedule a “continuation” to the task. This is anAction
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 evenvoid
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.
#1 by Stephen Hodgson on September 17th, 2018 ·
Awesome article as always! Thanks!
#2 by David Frank on April 4th, 2019 ·
Hi, I was reading this article and ran into an issue:
Do we need to declare AsyncMethodBuilder manually?
https://github.com/dotnet/roslyn/blob/master/docs/features/task-types.md
When I try to follow your example in Unity, I got this error:
error CS1983: The return type of an async method must be void, Task or Task
More info here:
https://forum.unity.com/threads/whats-wrong-with-my-bool-awaiter.655843/
https://stackoverflow.com/questions/55516217/whats-wrong-with-this-implementation-of-awaiter?noredirect=1&lq=1
#3 by jackson on April 4th, 2019 ·
I just tried pasting the two examples from the article into Unity 2018.3.1f1 with
Scripting Runtime Version
set to.NET 4.x Equivalent
andApi Compatibility Level
set to.NET Standard 2.0
inProject Settings
and both compiled just fine for me. Which version of Unity and what scripting settings are you using that are causing you troubles?#4 by David Frank on April 8th, 2019 ·
Thx, I now realize my problem is with trying to result this value on async function, simply being Task-like isn’t enough to make
async SomeAwaiter Func () {}
work, it only works withawait SomeAwaiter
#5 by TobS on February 29th, 2020 ·
This artical is gold. Thanks for sharing.
Cheers,
Tobs.