Unity 2018.1 was released last week and with it comes support for C# 6. Today we’ll take a look at the C++ that IL2CPP generates when we use the new features in C# 6. Warning: one of them is buggy and shouldn’t be used.

Null Selection Operator

Many new features in C# 6 are simply syntax sugar that behaves identically to older C# versions. For example, using static System.Math allows for calls like Abs(-10) instead of Math.Abs(-10). Obviously this will compile to the same IL as before, so I’ll skip in-depth discussions of features like these in this article. However, one that’s on the border line is the null conditional operator. Let’s look at a tiny example to confirm that it’s just syntax sugar:

public static class TestClass
{
	public static string NullConditionalOperator(object x)
	{
		return x?.ToString();
	}
}

Here’s the C++ that IL2CPP in Unity 2018.1.0f2 generates for iOS:

extern "C"  String_t* TestClass_NullConditionalOperator_m43940844 (RuntimeObject * __this /* static, unused */, RuntimeObject * ___x0, const RuntimeMethod* method)
{
	String_t* G_B3_0 = NULL;
	{
		RuntimeObject * L_0 = ___x0;
		if (L_0)
		{
			goto IL_0009;
		}
	}
	{
		G_B3_0 = ((String_t*)(NULL));
		goto IL_000f;
	}
 
IL_0009:
	{
		RuntimeObject * L_1 = ___x0;
		NullCheck(L_1);
		String_t* L_2 = VirtFuncInvoker0< String_t* >::Invoke(3 /* System.String System.Object::ToString() */, L_1);
		G_B3_0 = L_2;
	}
 
IL_000f:
	{
		return G_B3_0;
	}
}

This is mostly what we’d expect. First there is a check for null (if (L_0)) and the default value is returned (return G_B3_0) in that case. Otherwise, we proceed to call the ToString virtual function. There is a redundant NullCheck before ToString is called, so let’s look at the assembly that Xcode 9.3 compiles this C++ into to make sure the null check was removed on ARM64:

	cbz	x1, LBB0_2
	ldr		x8, [x1]
	ldp	x2, x8, [x8, #344]
	mov	 x0, x1
	mov	 x1, x8
	br	x2
LBB0_2:
	mov	x0, #0
	ret

There’s only one null check (cbz) present here, so the C++ compiler has done a good job of optimizing the IL2CPP output.

Conclusion: The “null conditional operator” is just syntax sugar. Feel free to use it if you think it makes your code more readable.

Exception Filters

C# 6 has introduced the ability to only catch an exception when certain conditions are met. Let’s see an example:

public static class TestClass
{
	public static int ExceptionFilter(object x)
	{
		int ret;
		try
		{
			ret = 0;
			x.ToString();
		}
		catch (ArgumentException ae) when (ae.Message == "foo")
		{
			ret = 1;
		}
		catch (NullReferenceException)
		{
			ret = 2;
		}
		finally
		{
			ret = 3;
		}
 
		return ret;
	}
}

Here we only enter the catch for ArgumentException when ae.Message == "foo" evaluates to true. Let’s see the IL2CPP output:

extern "C"  int32_t TestClass_ExceptionFilter_m4120834967 (RuntimeObject * __this /* static, unused */, RuntimeObject * ___x0, const RuntimeMethod* method)
{
	static bool s_Il2CppMethodInitialized;
	if (!s_Il2CppMethodInitialized)
	{
		il2cpp_codegen_initialize_method (TestClass_ExceptionFilter_m4120834967_MetadataUsageId);
		s_Il2CppMethodInitialized = true;
	}
	int32_t V_0 = 0;
	ArgumentException_t132251570 * V_1 = NULL;
	Exception_t * __last_unhandled_exception = 0;
	NO_UNUSED_WARNING (__last_unhandled_exception);
	Exception_t * __exception_local = 0;
	NO_UNUSED_WARNING (__exception_local);
	int32_t __leave_target = 0;
	NO_UNUSED_WARNING (__leave_target);
	int32_t G_B4_0 = 0;
 
IL_0000:
	try
	{ // begin try (depth: 1)
		try
		{ // begin try (depth: 2)
			V_0 = 0;
			RuntimeObject * L_0 = ___x0;
			NullCheck(L_0);
			VirtFuncInvoker0< String_t* >::Invoke(3 /* System.String System.Object::ToString() */, L_0);
			IL2CPP_LEAVE(0x42, FINALLY_003f);
		} // end try (depth: 2)
		catch(Il2CppExceptionWrapper& e)
		{
			__exception_local = (Exception_t *)e.ex;
		}
		{ // begin filter(depth: 2)
			bool __filter_local = false;
			try
			{ // begin implicit try block
				{
					V_1 = ((ArgumentException_t132251570 *)IsInstClass((RuntimeObject*)((Exception_t *)__exception_local), ArgumentException_t132251570_il2cpp_TypeInfo_var));
					ArgumentException_t132251570 * L_1 = V_1;
					if (L_1)
					{
						goto IL_001d;
					}
				}
				{
					G_B4_0 = 0;
					goto IL_002d;
				}
 
IL_001d:
				{
					ArgumentException_t132251570 * L_2 = V_1;
					NullCheck(L_2);
					String_t* L_3 = VirtFuncInvoker0< String_t* >::Invoke(5 /* System.String System.Exception::get_Message() */, L_2);
					bool L_4 = String_op_Equality_m920492651(NULL /*static, unused*/, L_3, _stringLiteral2506556841, /*hidden argument*/NULL);
					G_B4_0 = ((int32_t)(L_4));
				}
 
IL_002d:
				{
					__filter_local = (G_B4_0) ? true : false;
				}
			} // end implicit try block
			catch(Il2CppExceptionWrapper&)
			{ // begin implicit catch block
				__filter_local = false;
			} // end implicit catch block
			if (__filter_local)
			{
				goto FILTER_002f;
			}
			else
			{
				IL2CPP_RAISE_MANAGED_EXCEPTION(__exception_local, NULL, TestClass_ExceptionFilter_m4120834967_RuntimeMethod_var);
			}
		} // end filter (depth: 2)
 
FILTER_002f:
		{ // begin catch(filter)
			V_0 = 1;
			IL2CPP_LEAVE(0x42, FINALLY_003f);
		} // end catch (depth: 2)
 
CATCH_0037:
		{ // begin catch(System.NullReferenceException)
			V_0 = 2;
			IL2CPP_LEAVE(0x42, FINALLY_003f);
		} // end catch (depth: 2)
	} // end try (depth: 1)
	catch(Il2CppExceptionWrapper& e)
	{
		__last_unhandled_exception = (Exception_t *)e.ex;
		goto FINALLY_003f;
	}
 
FINALLY_003f:
	{ // begin finally (depth: 1)
		V_0 = 3;
		IL2CPP_END_FINALLY(63)
	} // end finally (depth: 1)
	IL2CPP_CLEANUP(63)
	{
		IL2CPP_JUMP_TBL(0x42, IL_0042)
		IL2CPP_RETHROW_IF_UNHANDLED(Exception_t *)
	}
 
IL_0042:
	{
		int32_t L_5 = V_0;
		return L_5;
	}
}

This should look somewhat familiar to IL2CPP’s usual output for exceptions. It’s been augmented with exception filters though, so let’s go through it one chunk at a time. First, there’s method initialization overhead for any use of exceptions. After that there are nested try blocks. The inner try is equivalent to the try we wrote in C#: ret = 0. It’s catch catches everything and then proceeds afterward to process it in yet-another try.

First, the type of the exception is checked to see if it matches the first block: ArgumentException. Then the exception filter is run and we see the call to the Message property get function then the string comparison against the "foo" literal. If it matches the filter, we jump to FILTER_0028, do the work (ret = 1), and then jump to execute the finally.

If the exception is either not an ArgumentException or doesn’t match the exception filter, then it is re-thrown and the finally block executes. This is actually incorrect, as the next catch should be checked. So when the parameter x is null, this function throws a NullReferenceException when it should just return 3. I’ve filed an issue with Unity, so hopefully the bug will be fixed soon.

Conclusion: Exception filters are currently buggy and should not be used.

String Interpolation

String interpolation simplifies the process of building a string even further than the + operator and string.Format previously allowed. Let’s have a look at the new $ prefix that allows for variable names inside of strings:

public static class TestClass
{
	public static string StringInterpolation(int a, int b, int sum)
	{
		return $"{a} + {b} = {sum}";
	}
}

Here’s what IL2CPP outputs:

extern "C"  String_t* TestClass_StringInterpolation_m4227470419 (RuntimeObject * __this /* static, unused */, int32_t ___a0, int32_t ___b1, int32_t ___sum2, const RuntimeMethod* method)
{
	static bool s_Il2CppMethodInitialized;
	if (!s_Il2CppMethodInitialized)
	{
		il2cpp_codegen_initialize_method (TestClass_StringInterpolation_m4227470419_MetadataUsageId);
		s_Il2CppMethodInitialized = true;
	}
	{
		int32_t L_0 = ___a0;
		int32_t L_1 = L_0;
		RuntimeObject * L_2 = Box(Int32_t2950945753_il2cpp_TypeInfo_var, &L_1);
		int32_t L_3 = ___b1;
		int32_t L_4 = L_3;
		RuntimeObject * L_5 = Box(Int32_t2950945753_il2cpp_TypeInfo_var, &L_4);
		int32_t L_6 = ___sum2;
		int32_t L_7 = L_6;
		RuntimeObject * L_8 = Box(Int32_t2950945753_il2cpp_TypeInfo_var, &L_7);
		String_t* L_9 = String_Format_m3339413201(NULL /*static, unused*/, _stringLiteral761173394, L_2, L_5, L_8, /*hidden argument*/NULL);
		return L_9;
	}
}

First, we have method initialization overhead because we used a string literal. Then there’s boxing of the three int parameters into object types. Finally, string.Format is called with the three boxed int values.

Conclusion: String interpolation is syntax sugar for string.Format, so feel free to use it if you think it makes your string.Format calls more readable.

nameof

The new nameof operator evaluates to a string that is the identifier name for whatever you pass into it. Let’s see how it looks:

public static class TestClass
{
	public static string Nameof(Vector3 x)
	{
		return nameof(x.magnitude);
	}
}

Now here’s the C++ that IL2CPP generates:

extern "C"  String_t* TestClass_Nameof_m2594209693 (RuntimeObject * __this /* static, unused */, Vector3_t3722313464  ___x0, const RuntimeMethod* method)
{
	static bool s_Il2CppMethodInitialized;
	if (!s_Il2CppMethodInitialized)
	{
		il2cpp_codegen_initialize_method (TestClass_Nameof_m2594209693_MetadataUsageId);
		s_Il2CppMethodInitialized = true;
	}
	{
		return _stringLiteral1482132450;
	}
}

IL2CPP has simply generated a string literal for the nameof result. Because we’re now using a string literal, we get method initialization overhead.

Conclusion: the nameof operator is equivalent to writing a string literal, but more maintainable as it’s automatically updated when identifier names change.

Indexer Initializer

Collections can now be initialized by calling the set block of their indexers.

public static class TestClass
{
	public static Dictionary<int, int> IndexInitializer()
	{
		return new Dictionary<int, int> { [10] = 100, [20] = 200 };
	}
}

Here’s the C++ for this:

extern "C"  Dictionary_2_t1839659084 * TestClass_IndexInitializer_m3823457966 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
	static bool s_Il2CppMethodInitialized;
	if (!s_Il2CppMethodInitialized)
	{
		il2cpp_codegen_initialize_method (TestClass_IndexInitializer_m3823457966_MetadataUsageId);
		s_Il2CppMethodInitialized = true;
	}
	Dictionary_2_t1839659084 * V_0 = NULL;
	{
		Dictionary_2_t1839659084 * L_0 = (Dictionary_2_t1839659084 *)il2cpp_codegen_object_new(Dictionary_2_t1839659084_il2cpp_TypeInfo_var);
		Dictionary_2__ctor_m1287366611(L_0, /*hidden argument*/Dictionary_2__ctor_m1287366611_RuntimeMethod_var);
		V_0 = L_0;
		Dictionary_2_t1839659084 * L_1 = V_0;
		NullCheck(L_1);
		Dictionary_2_set_Item_m1222558250(L_1, ((int32_t)10), ((int32_t)100), /*hidden argument*/Dictionary_2_set_Item_m1222558250_RuntimeMethod_var);
		Dictionary_2_t1839659084 * L_2 = V_0;
		NullCheck(L_2);
		Dictionary_2_set_Item_m1222558250(L_2, ((int32_t)20), ((int32_t)200), /*hidden argument*/Dictionary_2_set_Item_m1222558250_RuntimeMethod_var);
		Dictionary_2_t1839659084 * L_3 = V_0;
		return L_3;
	}
}

This begins with method initialization overhead because we’re using a generic class: Dictionary<TKey, TValue>. Afterward, we see the call to its constructor followed by a series of set calls to the indexer, also known as Item. Each one is prefixed by a NullCheck, so let’s see if the C++ compiler is able to remove those redundant checks by looking at the ARM64 assembly:

	stp	x20, x19, [sp, #-32]!   ; 8-byte Folded Spill
	stp	x29, x30, [sp, #16]     ; 8-byte Folded Spill
	add	x29, sp, #16            ; =16
	adrp	x19, __ZZ38Tes[email protected]PAGE
	ldrb	w8, [x19, __ZZ38Tes[email protected]PAGEOFF]
	tbnz	w8, #0, LBB4_2
	.loc	1 2171 37               ; /Users/jackson/Code/UnityPlayground2018_1/iOS/Classes/Native/Bulk_Assembly-CSharp_0.cpp:2171:37
	adrp	x8, [email protected]
	ldr	x8, [x8, [email protected]FF]
	ldr		w0, [x8]
	bl	__ZN6il2cpp2vm13MetadataCache24InitializeMethodMetadataEj
	orr	w8, wzr, #0x1
	strb	w8, [x19, __ZZ38Tes[email protected]PAGEOFF]
LBB4_2:
	adrp	x8, [email protected]
	ldr	x8, [x8, [email protected]]
	ldr		x0, [x8]
	bl	__ZN6il2cpp2vm6Object3NewEP11Il2CppClass
	mov	 x19, x0
	adrp	x8, [email protected]
	ldr	x8, [x8, [email protected]]
	ldr		x1, [x8]
	bl	_Dictionary_2__ctor_m1287366611_gshared
	cbz	x19, LBB4_4
	adrp	x8, [email protected]
	ldr	x8, [x8, [email protected]]
	ldr		x3, [x8]
	mov	w1, #10
	mov	w2, #100
	mov	 x0, x19
	bl	_Dictionary_2_set_Item_m1222558250_gshared
	b	LBB4_5
LBB4_4:
	mov	x0, #0
	bl	__ZN6il2cpp2vm9Exception27RaiseNullReferenceExceptionEP19Il2CppSequencePoint
	adrp	x8, [email protected]
	ldr	x8, [x8, _Dictionary_2_[email protected]]
	ldr		x3, [x8]
	mov	w1, #10
	mov	w2, #100
	mov	 x0, x19
	bl	_Dictionary_2_set_Item_m1222558250_gshared
	mov	x0, #0
	bl	__ZN6il2cpp2vm9Exception27RaiseNullReferenceExceptionEP19Il2CppSequencePoint
LBB4_5:
	adrp	x8, [email protected]
	ldr	x8, [x8, [email protected]]
	ldr		x3, [x8]
	mov	w1, #20
	mov	w2, #200
	mov	 x0, x19
	bl	_Dictionary_2_set_Item_m1222558250_gshared
	mov	 x0, x19
	ldp	x29, x30, [sp, #16]     ; 8-byte Folded Reload
	ldp	x20, x19, [sp], #32     ; 8-byte Folded Reload
	ret

The first chunk is the method initialization. Next the constructor is called. Before the first indexer is called, there’s a null check: cbz. However, there’s no null check before the second indexer call. So the C++ compiler has successfully removed one null check but failed to remove the other.

Conclusion: indexer initializers are syntax sugar for calling the set block of an indexer, so feel free to use them if you think they make your code more readable.

async and await

Finally, we have the big pair of features that were introduced in C# 5: async and await. These allow for writing arguably simpler multi-threaded code using a futures and promises model. Here’s an async function (i.e. it can run asynchronously on another thread) that creates an asynchronous Task (to do nothing) and then uses await to wait for that task to complete:

public static class TestClass
{
	public static async Task AsyncAwait()
	{
		Task task = Task.Run(() => { });
		await task;
	}
}

Here’s the C++ that IL2CPP generates:

extern "C"  Task_t3187275312 * TestClass_AsyncAwait_m3918118942 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
	static bool s_Il2CppMethodInitialized;
	if (!s_Il2CppMethodInitialized)
	{
		il2cpp_codegen_initialize_method (TestClass_AsyncAwait_m3918118942_MetadataUsageId);
		s_Il2CppMethodInitialized = true;
	}
	U3CAsyncAwaitU3Ec__async0_t454875446  V_0;
	memset(&V_0, 0, sizeof(V_0));
	{
		IL2CPP_RUNTIME_CLASS_INIT(AsyncTaskMethodBuilder_t3536885450_il2cpp_TypeInfo_var);
		AsyncTaskMethodBuilder_t3536885450  L_0 = AsyncTaskMethodBuilder_Create_m2603633305(NULL /*static, unused*/, /*hidden argument*/NULL);
		(&V_0)->set_U24builder_1(L_0);
		AsyncTaskMethodBuilder_t3536885450 * L_1 = (&V_0)->get_address_of_U24builder_1();
		AsyncTaskMethodBuilder_t3536885450 * L_2 = L_1;
		AsyncTaskMethodBuilder_Start_TisU3CAsyncAwaitU3Ec__async0_t454875446_m3850200940((AsyncTaskMethodBuilder_t3536885450 *)L_2, (U3CAsyncAwaitU3Ec__async0_t454875446 *)(&V_0), /*hidden argument*/AsyncTaskMethodBuilder_Start_TisU3CAsyncAwaitU3Ec__async0_t454875446_m3850200940_RuntimeMethod_var);
		Task_t3187275312 * L_3 = AsyncTaskMethodBuilder_get_Task_m115678985((AsyncTaskMethodBuilder_t3536885450 *)L_2, /*hidden argument*/NULL);
		return L_3;
	}
}

We have method initialization then we move on to more interesting parts of the function. IL2CPP has generated a U3CAsyncAwaitU3Ec__async0_t454875446 class to represent the state machine of our function, similar to what it does for iterator functions that use the yield keyword. Here’s what the pertinent parts of it look like:

struct  U3CAsyncAwaitU3Ec__async0_t454875446 
{
public:
	// System.Threading.Tasks.Task TestClass/<AsyncAwait>c__async0::<task>__0
	Task_t3187275312 * ___U3CtaskU3E__0_0;
	// System.Runtime.CompilerServices.AsyncTaskMethodBuilder TestClass/<AsyncAwait>c__async0::$builder
	AsyncTaskMethodBuilder_t3536885450  ___U24builder_1;
	// System.Int32 TestClass/<AsyncAwait>c__async0::$PC
	int32_t ___U24PC_2;
	// System.Runtime.CompilerServices.TaskAwaiter TestClass/<AsyncAwait>c__async0::$awaiter0
	TaskAwaiter_t919683548  ___U24awaiter0_4;
 
	// Jackson: accessor functions removed...
};

Next there is a call to AsyncTaskMethodBuilder.Create. Since this is a struct, the function is trivial:

extern "C"  AsyncTaskMethodBuilder_t3536885450  AsyncTaskMethodBuilder_Create_m2603633305 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
	AsyncTaskMethodBuilder_t3536885450  V_0;
	memset(&V_0, 0, sizeof(V_0));
	{
		il2cpp_codegen_initobj((&V_0), sizeof(AsyncTaskMethodBuilder_t3536885450 ));
		AsyncTaskMethodBuilder_t3536885450  L_0 = V_0;
		return L_0;
	}
}

The AsyncTaskMethodBuilder is then stored in the U3CAsyncAwaitU3Ec__async0_t454875446 class before AsyncTaskMethodBuilder.Start is called. At this point there’s so many function calls and they’re so verbose and difficult to read in generated C++ that we’ll switch to looking at decompiled C# code from mscorlib.dll:

[DebuggerStepThrough, SecuritySafeCritical]
public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine
{
	if (stateMachine == null)
	{
		throw new ArgumentNullException("stateMachine");
	}
	ExecutionContextSwitcher executionContextSwitcher = default(ExecutionContextSwitcher);
	RuntimeHelpers.PrepareConstrainedRegions();
	try
	{
		ExecutionContext.EstablishCopyOnWriteScope(ref executionContextSwitcher);
		stateMachine.MoveNext();
	}
	finally
	{
		executionContextSwitcher.Undo();
	}
}

PrepareConstrainedRegions is a no-op:

[MonoTODO("Currently a no-op"), ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)]
public static void PrepareConstrainedRegions()
{
}

EstablishCopyOnWriteScope just calls an overload to do the work on the current thread:

[SecurityCritical]
internal static void EstablishCopyOnWriteScope(ref ExecutionContextSwitcher ecsw)
{
	ExecutionContext.EstablishCopyOnWriteScope(Thread.CurrentThread, false, ref ecsw);
}
 
[SecurityCritical]
private static void EstablishCopyOnWriteScope(Thread currentThread, bool knownNullWindowsIdentity, ref ExecutionContextSwitcher ecsw)
{
	ecsw.outerEC = currentThread.GetExecutionContextReader();
	ecsw.outerECBelongsToScope = currentThread.ExecutionContextBelongsToCurrentScope;
	currentThread.ExecutionContextBelongsToCurrentScope = false;
	ecsw.thread = currentThread;
}

Finally, the important part is the call to stateMachine.MoveNext, mirroring closely the IEnumerator.MoveNext that implements iterator functions. This is a call on a TStateMachine. This is the auto-generated type (U3CAsyncAwaitU3Ec__async0_t454875446) for our async method, so let’s jump back to C++ and look at it:

extern "C"  void U3CAsyncAwaitU3Ec__async0_MoveNext_m123594166 (U3CAsyncAwaitU3Ec__async0_t454875446 * __this, const RuntimeMethod* method)
{
	static bool s_Il2CppMethodInitialized;
	if (!s_Il2CppMethodInitialized)
	{
		il2cpp_codegen_initialize_method (U3CAsyncAwaitU3Ec__async0_MoveNext_m123594166_MetadataUsageId);
		s_Il2CppMethodInitialized = true;
	}
	uint32_t V_0 = 0;
	Exception_t * V_1 = NULL;
	Exception_t * __last_unhandled_exception = 0;
	NO_UNUSED_WARNING (__last_unhandled_exception);
	Exception_t * __exception_local = 0;
	NO_UNUSED_WARNING (__exception_local);
	int32_t __leave_target = 0;
	NO_UNUSED_WARNING (__leave_target);
	U3CAsyncAwaitU3Ec__async0_t454875446 * G_B5_0 = NULL;
	U3CAsyncAwaitU3Ec__async0_t454875446 * G_B4_0 = NULL;
	{
		int32_t L_0 = __this->get_U24PC_2();
		V_0 = L_0;
		__this->set_U24PC_2((-1));
	}
 
IL_000e:
	try
	{ // begin try (depth: 1)
		{
			uint32_t L_1 = V_0;
			switch (L_1)
			{
				case 0:
				{
					goto IL_0021;
				}
				case 1:
				{
					goto IL_0088;
				}
			}
		}
 
IL_001c:
		{
			goto IL_00c3;
		}
 
IL_0021:
		{
			Action_t1264377477 * L_2 = ((U3CAsyncAwaitU3Ec__async0_t454875446_StaticFields*)il2cpp_codegen_static_fields_for(U3CAsyncAwaitU3Ec__async0_t454875446_il2cpp_TypeInfo_var))->get_U3CU3Ef__amU24cache0_3();
			G_B4_0 = __this;
			if (L_2)
			{
				G_B5_0 = __this;
				goto IL_003a;
			}
		}
 
IL_0029:
		{
			intptr_t L_3 = (intptr_t)U3CAsyncAwaitU3Ec__async0_U3CU3Em__0_m3149591005_RuntimeMethod_var;
			Action_t1264377477 * L_4 = (Action_t1264377477 *)il2cpp_codegen_object_new(Action_t1264377477_il2cpp_TypeInfo_var);
			Action__ctor_m75143462(L_4, NULL, L_3, /*hidden argument*/NULL);
			((U3CAsyncAwaitU3Ec__async0_t454875446_StaticFields*)il2cpp_codegen_static_fields_for(U3CAsyncAwaitU3Ec__async0_t454875446_il2cpp_TypeInfo_var))->set_U3CU3Ef__amU24cache0_3(L_4);
			G_B5_0 = G_B4_0;
		}
 
IL_003a:
		{
			Action_t1264377477 * L_5 = ((U3CAsyncAwaitU3Ec__async0_t454875446_StaticFields*)il2cpp_codegen_static_fields_for(U3CAsyncAwaitU3Ec__async0_t454875446_il2cpp_TypeInfo_var))->get_U3CU3Ef__amU24cache0_3();
			IL2CPP_RUNTIME_CLASS_INIT(Task_t3187275312_il2cpp_TypeInfo_var);
			Task_t3187275312 * L_6 = Task_Run_m1807195689(NULL /*static, unused*/, L_5, /*hidden argument*/NULL);
			G_B5_0->set_U3CtaskU3E__0_0(L_6);
			Task_t3187275312 * L_7 = __this->get_U3CtaskU3E__0_0();
			NullCheck(L_7);
			TaskAwaiter_t919683548  L_8 = Task_GetAwaiter_m3638629061(L_7, /*hidden argument*/NULL);
			__this->set_U24awaiter0_4(L_8);
			TaskAwaiter_t919683548 * L_9 = __this->get_address_of_U24awaiter0_4();
			bool L_10 = TaskAwaiter_get_IsCompleted_m1762140293((TaskAwaiter_t919683548 *)L_9, /*hidden argument*/NULL);
			if (L_10)
			{
				goto IL_0088;
			}
		}
 
IL_006a:
		{
			__this->set_U24PC_2(1);
			AsyncTaskMethodBuilder_t3536885450 * L_11 = __this->get_address_of_U24builder_1();
			TaskAwaiter_t919683548 * L_12 = __this->get_address_of_U24awaiter0_4();
			AsyncTaskMethodBuilder_AwaitUnsafeOnCompleted_TisTaskAwaiter_t919683548_TisU3CAsyncAwaitU3Ec__async0_t454875446_m4255926080((AsyncTaskMethodBuilder_t3536885450 *)L_11, (TaskAwaiter_t919683548 *)L_12, (U3CAsyncAwaitU3Ec__async0_t454875446 *)__this, /*hidden argument*/AsyncTaskMethodBuilder_AwaitUnsafeOnCompleted_TisTaskAwaiter_t919683548_TisU3CAsyncAwaitU3Ec__async0_t454875446_m4255926080_RuntimeMethod_var);
			goto IL_00c3;
		}
 
IL_0088:
		{
			TaskAwaiter_t919683548 * L_13 = __this->get_address_of_U24awaiter0_4();
			TaskAwaiter_GetResult_m3227166796((TaskAwaiter_t919683548 *)L_13, /*hidden argument*/NULL);
			goto IL_00b1;
		}
	} // end try (depth: 1)
	catch(Il2CppExceptionWrapper& e)
	{
		__exception_local = (Exception_t *)e.ex;
		if(il2cpp_codegen_class_is_assignable_from (Exception_t_il2cpp_TypeInfo_var, il2cpp_codegen_object_class(e.ex)))
			goto CATCH_0098;
		throw e;
	}
 
CATCH_0098:
	{ // begin catch(System.Exception)
		V_1 = ((Exception_t *)__exception_local);
		__this->set_U24PC_2((-1));
		AsyncTaskMethodBuilder_t3536885450 * L_14 = __this->get_address_of_U24builder_1();
		Exception_t * L_15 = V_1;
		AsyncTaskMethodBuilder_SetException_m3731552766((AsyncTaskMethodBuilder_t3536885450 *)L_14, L_15, /*hidden argument*/NULL);
		goto IL_00c3;
	} // end catch (depth: 1)
 
IL_00b1:
	{
		__this->set_U24PC_2((-1));
		AsyncTaskMethodBuilder_t3536885450 * L_16 = __this->get_address_of_U24builder_1();
		AsyncTaskMethodBuilder_SetResult_m3263625660((AsyncTaskMethodBuilder_t3536885450 *)L_16, /*hidden argument*/NULL);
	}
 
IL_00c3:
	{
		return;
	}
}

There’s a ton of clutter here for method initialization, exceptions, unnecessary code blocks ({}), redundant local variable copies, and goto-based flow control. Looking past that, we see that the core of the function is getting an integer (__this->get_U24PC_2()) that represents the current state or phase of the state machine, using a switch on it to execute that state’s code, then setting the integer back (set_U24PC_2) to change the state.

This function has just two states. First, there’s a phase that calls Task.Run to create a Task. This causes a managed allocation for the GC to eventually collect. Then there’s a call to GetAwaiter on it to check if the task completed already. The second state is just the call to GetAwaiter to check if the task has completed. When the task ultimately completes, there’s a call to AsyncTaskMethodBuilder.SetResult. This jumps through a few overloads before eventually ending up in a gigantic function that’s about 400 lines in generated C++ but quite terse in C# (except for the extremely long lines):

[SecuritySafeCritical]
private Task<TResult> GetTaskForResult(TResult result)
{
	if (default(TResult) != null)
	{
		if (typeof(TResult) == typeof(bool))
		{
			return JitHelpers.UnsafeCast<Task<TResult>>(((bool)((object)result)) ? AsyncTaskCache.TrueTask : AsyncTaskCache.FalseTask);
		}
		if (typeof(TResult) == typeof(int))
		{
			int num = (int)((object)result);
			if (num < 9 && num >= -1)
			{
				return JitHelpers.UnsafeCast<Task<TResult>>(AsyncTaskCache.Int32Tasks[num - -1]);
			}
		}
		else if ((typeof(TResult) == typeof(uint) && (uint)((object)result) == 0u) || (typeof(TResult) == typeof(byte) && (byte)((object)result) == 0) || (typeof(TResult) == typeof(sbyte) && (sbyte)((object)result) == 0) || (typeof(TResult) == typeof(char) && (char)((object)result) == '\0') || (typeof(TResult) == typeof(decimal) && decimal.Zero == (decimal)((object)result)) || (typeof(TResult) == typeof(long) && (long)((object)result) == 0L) || (typeof(TResult) == typeof(ulong) && (ulong)((object)result) == 0uL) || (typeof(TResult) == typeof(short) && (short)((object)result) == 0) || (typeof(TResult) == typeof(ushort) && (ushort)((object)result) == 0) || (typeof(TResult) == typeof(IntPtr) && (IntPtr)0 == (IntPtr)((object)result)) || (typeof(TResult) == typeof(UIntPtr) && (UIntPtr)0 == (UIntPtr)((object)result)))
		{
			return AsyncTaskMethodBuilder<TResult>.s_defaultResultTask;
		}
	}
	else if (result == null)
	{
		return AsyncTaskMethodBuilder<TResult>.s_defaultResultTask;
	}
	return new Task<TResult>(result);
}

The comparisons with null results in boxing when TResult is a value type, like in this case. The same goes for the numerous casts to object. The bizarre if (default(TResult) != null) is checking for TResult being a non-Nullable value type and the nested checks are all to check if the type is one of the various types for which there is a cached Task<TResult> object. If not, a new Task<TResult> is created to hold the result. This may save a little memory, but it sure is expensive to call typeof and create all that garbage due to boxing.

With AsyncAwait covered, we’ll have an easier time understanding CallAsyncAwait:

public static class TestClass
{
	public static async Task AsyncAwait()
	{
		Task task = Task.Run(() => { });
		await task;
	}
 
	public static async void CallAsyncAwait()
	{
		await AsyncAwait();
	}
}

Here’s the C++ that IL2CPP generates for this:

extern "C"  void TestClass_CallAsyncAwait_m1740468128 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
	static bool s_Il2CppMethodInitialized;
	if (!s_Il2CppMethodInitialized)
	{
		il2cpp_codegen_initialize_method (TestClass_CallAsyncAwait_m1740468128_MetadataUsageId);
		s_Il2CppMethodInitialized = true;
	}
	U3CCallAsyncAwaitU3Ec__async1_t424607014  V_0;
	memset(&V_0, 0, sizeof(V_0));
	{
		AsyncVoidMethodBuilder_t3819840891  L_0 = AsyncVoidMethodBuilder_Create_m1976941025(NULL /*static, unused*/, /*hidden argument*/NULL);
		(&V_0)->set_U24builder_0(L_0);
		AsyncVoidMethodBuilder_t3819840891 * L_1 = (&V_0)->get_address_of_U24builder_0();
		AsyncVoidMethodBuilder_Start_TisU3CCallAsyncAwaitU3Ec__async1_t424607014_m3506464720((AsyncVoidMethodBuilder_t3819840891 *)L_1, (U3CCallAsyncAwaitU3Ec__async1_t424607014 *)(&V_0), /*hidden argument*/AsyncVoidMethodBuilder_Start_TisU3CCallAsyncAwaitU3Ec__async1_t424607014_m3506464720_RuntimeMethod_var);
		return;
	}
}

Here we see a lot of the same parts as in AsyncAwait. It’s exactly the same except that it doesn’t return a Task because this function returns void. All of the differences lie in the MoveNext for the class that was generated for this function. This means that there’s little difference between using await to call an async function that returns a Task and using await directly on a Task. That mirrors iterator functions in the sense that they’re just a way to create an IEnumerator and using that IEnumerator is just like using a List<T> or any other class implementing IEnumerator.

Conclusion: await, async, and Task are like iterator functions. They require garbage to be created and a “state machine” class to be generated. They also perform a lot of boxing and type checks, which adds to the overhead. As such, they’re more expensive than manually using Thread or the new job system.