C# 6 in IL2CPP
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, __ZZ38TestClass_IndexInitializer_m3823457966E25s_Il2CppMethodInitialized@PAGE ldrb w8, [x19, __ZZ38TestClass_IndexInitializer_m3823457966E25s_Il2CppMethodInitialized@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, _TestClass_IndexInitializer_m3823457966_MetadataUsageId@GOTPAGE ldr x8, [x8, _TestClass_IndexInitializer_m3823457966_MetadataUsageId@GOTPAGEOFF] ldr w0, [x8] bl __ZN6il2cpp2vm13MetadataCache24InitializeMethodMetadataEj orr w8, wzr, #0x1 strb w8, [x19, __ZZ38TestClass_IndexInitializer_m3823457966E25s_Il2CppMethodInitialized@PAGEOFF] LBB4_2: adrp x8, _Dictionary_2_t1839659084_il2cpp_TypeInfo_var@GOTPAGE ldr x8, [x8, _Dictionary_2_t1839659084_il2cpp_TypeInfo_var@GOTPAGEOFF] ldr x0, [x8] bl __ZN6il2cpp2vm6Object3NewEP11Il2CppClass mov x19, x0 adrp x8, _Dictionary_2__ctor_m1287366611_RuntimeMethod_var@GOTPAGE ldr x8, [x8, _Dictionary_2__ctor_m1287366611_RuntimeMethod_var@GOTPAGEOFF] ldr x1, [x8] bl _Dictionary_2__ctor_m1287366611_gshared cbz x19, LBB4_4 adrp x8, _Dictionary_2_set_Item_m1222558250_RuntimeMethod_var@GOTPAGE ldr x8, [x8, _Dictionary_2_set_Item_m1222558250_RuntimeMethod_var@GOTPAGEOFF] 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, _Dictionary_2_set_Item_m1222558250_RuntimeMethod_var@GOTPAGE ldr x8, [x8, _Dictionary_2_set_Item_m1222558250_RuntimeMethod_var@GOTPAGEOFF] 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, _Dictionary_2_set_Item_m1222558250_RuntimeMethod_var@GOTPAGE ldr x8, [x8, _Dictionary_2_set_Item_m1222558250_RuntimeMethod_var@GOTPAGEOFF] 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.
#1 by Tim Aksu on April 4th, 2021 ·
Hi Jackson, just wondering if you had any updates on your exception filtering bug report. I didn’t realize it was broken until I stumbled upon the documentation mentioning that it is not supported.
https://docs.unity3d.com/Manual/ScriptingRestrictions.html
#2 by jackson on April 10th, 2021 ·
Hi Tim, I don’t have an update other than what you’ve already found: Unity officially does not support exception filters in IL2CPP.