Search code examples
c#async-awaitgarbage-collection

Do all value types become subject to GC in when async function is suspended?


Say you have:

  • An async function, in which...
  • ... A variable with a value type being initialised before an async/await point
  • Said value type does not escape the function
  readonly struct MyStruct : IDisposable
  {
    // ... fields, IDisposable implementation and so on ...
  }

  async Task test()
  {
    using var myStructInstance = GetMyStruct(); // Some external function which creates and returns an instance of MyStruct
    await SomethingElse();
  }

What is the impact on the overall GC behaviour of the system, depending on whether the subsequent async call executes synchronously, or truly asynchronously?

In particular,

  • Is it correct to assume "myStructInstance" will remain purely on the stack (no heap allocations) in case SomethingElse() returns synchronously?
  • What happens if SomethingElse() does suspend the async execution here? Will "myStructInstance" be promoted to an individual GC-tracked object on the heap? Or, since technically its lifetime is strictly tied to the generated async state machine, would it be part of the underlying memory layout of the state machine itself, and not be tracked as an individual object on the heap/by the GC?

As you can probably tell, my general aim is to figure out if locally used value types in async functions could potentially contribute to GC pressure, or if they generally have minimal to no impact.


Solution

  • Or, since technically its lifetime is strictly tied to the generated async state machine, would it be part of the underlying memory layout of the state machine itself

    In current compiler implementation the local variables used after the await will be stored as part of the async state machine. You can use https://sharplab.io/ to dabble with the decompilation.

    Consider the following code:

    public class C {
      async Task test()
      {
        var o = new object();
        var o1 = new object();
        var myStructInstance = new MyStruct(1); 
        Console.WriteLine(o1);
        await Task.Yield();
        Console.WriteLine(o);
        Console.WriteLine(myStructInstance.I);
      }
    }
    
    readonly struct MyStruct
    {
        public MyStruct(int i) => I = i;
        public int I {get;}
    }
    

    This will lead to o and myStructInstance stored in the fields (but not o1):

    [StructLayout(LayoutKind.Auto)]
    [CompilerGenerated]
    private struct <test>d__0 : IAsyncStateMachine
    {
        // ...
    
        private object <o>5__2;
    
        private MyStruct <myStructInstance>5__3;
    
    }
    

    Demo @sharplab.io.

    So no, myStructInstance should not be boxed as a separate object.

    my general aim is to figure out if locally used value types in async functions could potentially contribute to GC pressure, or if they generally have minimal to no impact.

    In theory yes - if you drill down into decompiled state machine you will see that at some point it can be "boxed" into internal IAsyncStateMachineBox interface (it happens when task has not finished, AsyncTaskMethodBuilder.AwaitUnsafeOnCompleted is called which will result in the GetStateMachineBox call), so the whole statemachine will be placed on heap and the size of the state machine depends on captured variables. So yes some effect is there but I would argue that in most cases it is negligible.