Alternatives to DisposeAsync

Spread the love

It stands to reason that DisposeAsync would be the asynchronous counterpart of IDisposable.Dispose. However, I recommend that you do not use the Dispose pattern for asynchronous cleanup. Why not?

First off, an async version of Dispose would not work properly with the using statement. At best, you would have to roll your own with a try/finally — except that you can’t await inside a finally. This means you’d need to block (e.g. by calling Task.Wait()) which is obviously not a good async coding practice.

Stepping back for a bit, if you think you need an asynchronous Dispose, you are already saying, “My class needs to do some I/O-bound work to close itself down.” Two consequences of this are that the work could take significant time (e.g. flushing to a network file) and that the operations involved could fail (e.g. network connection is broken). These are antithetical to Dispose, which should be a fast, no-fail operation.

It turns out that Windows Communication Foundation (WCF) had this same problem years ago. This is why ICommunicationObject has Abort (for immediate shutdown) and (Begin/End)Close (for orderly shutdown).

Now let’s craft a solution to the async Dispose problem in the spirit of the above guidance. First, we define a new interface which enhances IDisposable:

public interface IDestroyable : IDisposable
{
    Task DestroyAsync();
}

Now we need an alternative to using which will always attempt to call DestroyAsync and guarantees that the object will be disposed at the end. This is a standard deferred exception pattern, as follows:

public static class AsyncEx
{
    public static async Task UsingAsync<TDestroyable>(
        Func<TDestroyable> create,
        Func<TDestroyable, Task> doAsync)
        where TDestroyable : IDestroyable
    {
        // 'using' guarantees Dispose as long as create() succeeds
        using (TDestroyable d = create())
        {
            List<Exception> exceptions = new List<Exception>();
            try
            {
                await doAsync(d);
            }
            catch (Exception e)
            {
                // catch and continue, we'll rethrow at the end
                exceptions.Add(e);
            }

            try
            {
                await d.DestroyAsync();
            }
            catch (Exception e)
            {
                // catch and continue, we'll rethrow at the end
                exceptions.Add(e);
            }

            if (exceptions.Count > 0)
            {
                // In case the above exceptions were already wrapped in
                // AggregateException we should flatten everything here
                // before rethrowing.
                throw new AggregateException(exceptions).Flatten();
            }
        }
    }
}

Now for a trivial implementation of IDestroyable which allows to test all the success and failure paths using bit flags to identify which step, if any, we want to fail:

public sealed class MyObj : IDestroyable
{
    private readonly Stopwatch stopwatch;
    private readonly string name;
    private readonly int failureFlags;

    public MyObj(string name, int failureFlags)
    {
        this.stopwatch = Stopwatch.StartNew();
        this.name = name;
        this.failureFlags = failureFlags;
        this.FailIfStepMatches(1);
    }

    public async Task DoAsync()
    {
        await Task.Delay(1000);
        this.Log("Done!");
        this.FailIfStepMatches(2);
    }

    public async Task DestroyAsync()
    {
        await Task.Delay(1000);
        this.Log("Destroyed!");
        this.FailIfStepMatches(4);
    }

    public void Dispose()
    {
        this.Log("Disposed!");
    }

    private void Log(string message)
    {
        Console.WriteLine(
            "[{0:00.000}] ({1}) {2}",
            this.stopwatch.Elapsed.TotalSeconds,
            this.name,
            message);
    }

    private void FailIfStepMatches(int stepFlag)
    {
        if ((this.failureFlags & stepFlag) != 0)
        {
            string message = "Failing (flag " + stepFlag + ")!";
            this.Log(message);
            throw new InvalidOperationException(message);
        }
    }
}

And finally, some sample code showing a few use cases — first, for success:

private static async Task SampleAsync()
{
    // One object, success
    await AsyncEx.UsingAsync(
        () => new MyObj("A", 0),
        d => d.DoAsync());

    Console.WriteLine("----");

    Func<MyObj, MyObj, Task> doBothAsync = async delegate(MyObj a, MyObj b)
    {
        await a.DoAsync();
        await b.DoAsync();
    };

    // Nested objects, success
    await AsyncEx.UsingAsync(
        () => new MyObj("A", 0),
        d1 => AsyncEx.UsingAsync(
            () => new MyObj("B", 0),
            d2 => doBothAsync(d1, d2)));
}

The “stacked using” case for nested objects shown above is obviously a bit more cumbersome than a standard using statement, but it’s not too bad if you keep the lambdas relatively short. The output from this code is as you would expect — last in, first out:

[01.007] (A) Done!
[02.027] (A) Destroyed!
[02.027] (A) Disposed!
----
[01.009] (A) Done!
[02.018] (B) Done!
[03.038] (B) Destroyed!
[03.038] (B) Disposed!
[04.049] (A) Destroyed!
[04.049] (A) Disposed!

Now for some failure cases, starting with a single object:

private static async Task FailAsync(int failureStep)
{
    try
    {
        await AsyncEx.UsingAsync(
            () => new MyObj("A", failureStep),
            d => d.DoAsync());
    }
    catch (Exception e)
    {
        Console.WriteLine(e);
    }

    Console.WriteLine("----");
}

private static async Task FailureSampleAsync()
{
    // One object, fail on construction
    await FailAsync(1);

    // One object, fail on Do
    await FailAsync(2);

    // One object, fail on Destroy
    await FailAsync(4);

    // One object, fail on Do AND Destroy
    await FailAsync(6);
}

The output from the program, omitting a lot of the exception stacks to make it more readable:
[00.001] (A) Failing (flag 1)!
System.InvalidOperationException: Failing (flag 1)!
----
[01.010] (A) Done!
[01.010] (A) Failing (flag 2)!
[02.030] (A) Destroyed!
[02.030] (A) Disposed!
System.AggregateException: One or more errors occurred.
(Inner Exception #0) System.InvalidOperationException: Failing (flag 2)!
----
[01.008] (A) Done!
[02.028] (A) Destroyed!
[02.028] (A) Failing (flag 4)!
[02.028] (A) Disposed!
System.AggregateException: One or more errors occurred.
(Inner Exception #0) System.InvalidOperationException: Failing (flag 4)!
----
[01.004] (A) Done!
[01.004] (A) Failing (flag 2)!
[02.014] (A) Destroyed!
[02.014] (A) Failing (flag 4)!
[02.014] (A) Disposed!
System.AggregateException: One or more errors occurred.
(Inner Exception #0) System.InvalidOperationException: Failing (flag 2)!
(Inner Exception #1) System.InvalidOperationException: Failing (flag 4)!
----

As you can see, if construction succeeds, it immediately throws without further execution. Otherwise, DestroyAsync and Dispose will always be called, regardless of failures.

Finally, let’s explore some failure cases with nested objects:

private static async Task Fail2Async(int failureStep1, int failureStep2)
{
    Func<MyObj, MyObj, Task> doBothAsync = async delegate(MyObj a, MyObj b)
    {
        await a.DoAsync();
        await b.DoAsync();
    };

    try
    {
        await AsyncEx.UsingAsync(
            () => new MyObj("A", failureStep1),
            d1 => AsyncEx.UsingAsync(
                () => new MyObj("B", failureStep2),
                d2 => doBothAsync(d1, d2)));
    }
    catch (Exception e)
    {
        Console.WriteLine(e);
    }

    Console.WriteLine("----");
}

private static async Task FailureSample2Async()
{
    // Two objects, fail on construction of #2
    await Fail2Async(0, 1);

    // Two objects, fail on Do of #2
    await Fail2Async(0, 2);

    // Two objects, fail on Do of #1 and Destroy of #2
    await Fail2Async(2, 4);

    // Two objects, fail on Destroy of #1 and Do/Destroy of #2
    await Fail2Async(4, 6);
}

And the (truncated) output, showing that any successfully constructed object is Destroyed/Disposed regardless of other failures, with all exceptions aggregated at the end:

[00.000] (B) Failing (flag 1)!
[01.015] (A) Destroyed!
[01.016] (A) Disposed!
System.AggregateException: One or more errors occurred.
(Inner Exception #0) System.InvalidOperationException: Failing (flag 1)!
----
[01.006] (A) Done!
[02.026] (B) Done!
[02.026] (B) Failing (flag 2)!
[03.036] (B) Destroyed!
[03.036] (B) Disposed!
[04.046] (A) Destroyed!
[04.046] (A) Disposed!
System.AggregateException: One or more errors occurred.
(Inner Exception #0) System.InvalidOperationException: Failing (flag 2)!
----
[01.011] (A) Done!
[01.012] (A) Failing (flag 2)!
[02.021] (B) Destroyed!
[02.022] (B) Failing (flag 4)!
[02.022] (B) Disposed!
[03.041] (A) Destroyed!
[03.042] (A) Disposed!
System.AggregateException: One or more errors occurred.
(Inner Exception #0) System.InvalidOperationException: Failing (flag 2)!
(Inner Exception #1) System.InvalidOperationException: Failing (flag 4)!
----
[01.019] (A) Done!
[02.029] (B) Done!
[02.029] (B) Failing (flag 2)!
[03.039] (B) Destroyed!
[03.039] (B) Failing (flag 4)!
[03.039] (B) Disposed!
[04.059] (A) Destroyed!
[04.059] (A) Failing (flag 4)!
[04.059] (A) Disposed!
System.AggregateException: One or more errors occurred.
(Inner Exception #0) System.InvalidOperationException: Failing (flag 4)!
(Inner Exception #1) System.InvalidOperationException: Failing (flag 2)!
(Inner Exception #2) System.InvalidOperationException: Failing (flag 4)!
----

Leave a Reply

Your email address will not be published. Required fields are marked *