Search code examples
c#structasync-awaitconfigureawaitiasyncdisposable

ConfigureAwait(false) and struct implementation of IAsyncDisposable


I have implemented IAsyncDisposable with an ActionOnAsyncDispose struct as shown below. My understanding is that the compiler will not box it when it is in an async using statement:

ActionOnDisposeAsync x = ...;
await using (x) {
     ...
}

Correct? So far so good. My question is this, when I configure await on it like so:

ActionOnDisposeAsync x = ...;
await using (x.ConfigureAwait()) {
     ...
}

will x be boxed? What about if I put the ConfigureAwait in a method, Caf():

[MethodImpl(MethodImplOptions.AggressiveInlining)]
static public ConfiguredAsyncDisposable Caf(this ActionOnDisposeAsync disposable)
    => disposable.ConfigureAwait(false);

ActionOnDisposeAsync x = ...;
await using (x.Caf()) {
     ...
}

Can I avoid boxing in that case? I was not able to find documentation on what exactly my using variable needs to implement in order to have the effect of ConfigureAwait. There doesn't seem to be any public way of constructing a ConfiguredAsyncDisposable either.

Here is ActionOnDisposeAsync:

public readonly struct ActionOnDisposeAsync : IAsyncDisposable, IEquatable<ActionOnDisposeAsync>
{
    public ActionOnDisposeAsync(Func<Task> actionAsync)
    {
        this.ActionAsync = actionAsync;
    }
    public ActionOnDisposeAsync( Action actionSync)
    {
        this.ActionAsync = () => { actionSync(); return Task.CompletedTask; };
    }
    private Func<Task> ActionAsync { get; }

    public async ValueTask DisposeAsync()
    {
        if (this.ActionAsync != null) {
            await this.ActionAsync();
        }
    }

    ...
}

Solution

  • Yes, the ConfigureAwait on struct disposables causes boxing. Here is an experimental demonstration of this behavior:

    MyDisposableStruct value = new();
    const int loops = 1000;
    var mem0 = GC.GetTotalAllocatedBytes(true);
    for (int i = 0; i < loops; i++)
    {
        await using (value.ConfigureAwait(false)) { }
    }
    var mem1 = GC.GetTotalAllocatedBytes(true);
    Console.WriteLine($"Allocated: {(mem1 - mem0) / loops:#,0} bytes per 'await using'");
    

    ...where MyDisposableStruct is this simple struct:

    readonly struct MyDisposableStruct : IAsyncDisposable
    {
        public ValueTask DisposeAsync() => default;
    }
    

    Output:

    Allocated: 24 bytes per 'await using'
    

    Live demo.

    To prevent the boxing from happening you will have to create a custom ConfiguredAsyncDisposable-like struct, that is tailored specifically for your struct. Here is how it can be done:

    readonly struct MyConfiguredAsyncDisposable
    {
        private readonly MyDisposableStruct _parent;
        private readonly bool _continueOnCapturedContext;
    
        public MyConfiguredAsyncDisposable(MyDisposableStruct parent,
            bool continueOnCapturedContext)
        {
            _parent = parent;
            _continueOnCapturedContext = continueOnCapturedContext;
        }
    
        public ConfiguredValueTaskAwaitable DisposeAsync()
            => _parent.DisposeAsync().ConfigureAwait(_continueOnCapturedContext);
    }
    
    static MyConfiguredAsyncDisposable ConfigureAwait(
        this MyDisposableStruct source, bool continueOnCapturedContext)
    {
        return new MyConfiguredAsyncDisposable(source, continueOnCapturedContext);
    }
    

    Now running the same experiment as before, without making any change in the code whatsoever, does not cause allocations. The output is:

    Allocated: 0 bytes per 'await using'
    

    Live demo.