This post is part of the series 'Async/await in C#'. Be sure to check out the rest of the blog posts of the series!
IDisposable.Dispose returns void. That is fine when cleanup means releasing a handle, and a problem when it means flushing a buffer to disk or sending a close frame over a socket. Those are I/O, and a void method has only two ways to do I/O: block a thread, or fire and forget.
IAsyncDisposable exists to give cleanup somewhere to await.
#The interface
C#
public interface IAsyncDisposable
{
ValueTask DisposeAsync();
}
ValueTask rather than Task, for the usual reason: most disposals have nothing to flush and complete synchronously, and allocating a Task for every using block would be a poor trade.
It is used by await using:
C#
await using var writer = new BufferedWriter(sink);
writer.Write("a");
which the compiler turns into roughly this:
C#
var writer = new BufferedWriter(sink);
try
{
writer.Write("a");
}
finally
{
if (writer is not null)
{
await writer.DisposeAsync();
}
}
The finally is the important part, and the sample's tests check it: DisposeAsync runs even when the body throws, and the exception still propagates afterwards.
#Ordering
Nested await using blocks dispose in reverse order of acquisition, exactly like using:
C#
await using (var outer = new Recorder("outer", order))
await using (var inner = new Recorder("inner", order))
{
order.Add("body");
}
// order is: body, inner, outer
This matters more in async code than it looks, because each disposal can suspend. Two nested disposals are two sequential awaits, not two concurrent ones. Disposing ten buffered writers at the end of a request is ten round trips one after another, and if that shows up in a profile the fix is to flush them together rather than to reach for await using.
#Implementing it
The recommended shape when your callers might be either synchronous or asynchronous is to implement both interfaces:
C#
public sealed class BufferedWriter(IList<string> sink) : IAsyncDisposable, IDisposable
{
public async ValueTask DisposeAsync()
{
if (_disposed)
return;
_disposed = true;
await FlushAsync();
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
// Not DisposeAsync().GetAwaiter().GetResult().
Flush();
}
}
Three rules are doing work in there.
Both are idempotent. Disposing twice does the work once. The sample tests this because it is easy to get wrong and the failure is silent duplicate I/O.
await using prefers DisposeAsync. When a type implements both, await using binds to IAsyncDisposable and plain using binds to IDisposable. There is no ambiguity and no runtime check.
#When you need it
Not for everything. IAsyncDisposable is worth it when cleanup does real I/O:
- Buffered writers. A
StreamWriter with unflushed content, a Utf8JsonWriter, a compression stream that needs to write a trailer. - Network connections that send a close handshake: WebSockets, message brokers, database connections that return themselves to a pool asynchronously.
- Anything holding an async lock, where releasing may need to signal a waiter.
IAsyncEnumerator<T>, which derives from IAsyncDisposable precisely so that leaving an await foreach early can run the iterator's finally blocks. That is the mechanism the async streams post relies on.
If your Dispose just sets a field to null or calls SafeHandle.Dispose, plain IDisposable is correct and adding the async variant only adds ceremony.
#Where it bites
Dependency injection. A DI container disposing a scope has to call DisposeAsync on services that implement it. IServiceScope alone cannot: you need AsyncServiceScope, and creating a scope with CreateScope() rather than CreateAsyncScope() means the container falls back to Dispose, or throws for types that only implement IAsyncDisposable. In ASP.NET Core the request scope is already an async scope; in a background service you create scopes yourself, and that is where this goes wrong.
Fields. A class holding an IAsyncDisposable field usually has to become IAsyncDisposable itself. The pattern propagates up an object graph the same way async does.
ref struct and using. A ref struct cannot implement an interface with an async method, so await using on one is not possible. Since C# 8 using on a ref struct binds to a Dispose method by pattern rather than through the interface, and there is no async equivalent of that pattern.
A forgotten await still compiles. Task implements IDisposable, so using (OpenConnectionAsync()) is valid C#. It disposes the task rather than the connection: the connection leaks, and if the task has not completed yet, Dispose throws InvalidOperationException.
Finalizers do not help. There is no async finalizer, and there never will be. If a caller forgets to dispose, the buffered content is simply lost. Deterministic disposal is the only mechanism there is.
#What to take away
IAsyncDisposable.DisposeAsync returns ValueTask, because most disposals complete synchronously.await using generates a try/finally and awaits the disposal, so it runs even when the body throws.- Nested disposals run in reverse order, sequentially, each one able to suspend.
- Implement both interfaces when callers may be synchronous, and make
Dispose do the synchronous equivalent rather than blocking on DisposeAsync. - Make both idempotent.
- Use
CreateAsyncScope() when a DI scope holds async disposables. - Use it when cleanup does I/O. Plain
IDisposable is still right for releasing a handle.
The next post covers the parameter that should be threaded through all of this and usually is not: CancellationToken.
#Additional resources
Do you have a question or a suggestion about this post? Contact me!