IDisposable, Finalizers and Deterministic Cleanup
Objective
The garbage collector reclaims memory, and only memory. File handles, sockets,
database connections, locks, timers, and event subscriptions need someone to
release them at a known moment, and that is the job of IDisposable. This
concept covers using statements and declarations, await using and
IAsyncDisposable, the dispose pattern and when it is needed, what a finalizer
really costs, why SafeHandle replaces almost every finalizer, and how the
dependency injection container disposes services, all measured on .NET 10.
Use Cases
- Making sure a file or connection is closed even when an exception is thrown halfway through.
- Writing a class that owns a
Stream, anHttpClienthandler, or a timer. - Wrapping a native handle from a P/Invoke call so it is released once and only once.
- Explaining why memory keeps growing in a service that resolves disposable transients from the root provider.
- Choosing between
DisposeandDisposeAsyncfor a type that flushes data over the network.
Deep Dive
using statements and declarations
The using statement compiles to try/finally that calls Dispose. The
using declaration (C# 8) drops the braces and disposes at the end of the
enclosing scope; several declarations are disposed in reverse order, and a
null resource is simply skipped:
csharptry
{
using var a = new Res("a");
using var b = new Res("b");
using Res? none = null; // allowed: nothing to dispose
throw new InvalidOperationException("boom");
}
catch (InvalidOperationException e) { Console.WriteLine($"caught {e.Message}"); }
// dispose b
// dispose a
// caught boom
sealed class Res(string name) : IDisposable
{
public void Dispose() => Console.WriteLine($"dispose {name}");
}The variable is read-only, so it cannot be reassigned to something that would
escape disposal. A declaration lives until the end of its block, which is
often the end of the method; when a resource should be released earlier, the
braced form says so. C# 8 also allows using on a ref struct that only has
an accessible, parameterless void Dispose() method, because a ref struct
could not implement interfaces before C# 13.
await using and IAsyncDisposable
Types whose cleanup does I/O (flushing a buffered stream, sending a close
frame) implement IAsyncDisposable, and await using awaits their
DisposeAsync:
csharpawait using (var r = new AsyncRes()) { } // DisposeAsync ran
using (var r = new AsyncRes()) { }
// error CS8418: 'AsyncRes': type used in a using statement must implement
// 'System.IDisposable'. Did you mean 'await using' rather than 'using'?
sealed class AsyncRes : IAsyncDisposable
{
public async ValueTask DisposeAsync() { await Task.Yield(); Console.WriteLine("DisposeAsync ran"); }
}A type that implements both lets the caller choose; the async-streams concept
shows await foreach calling DisposeAsync on its enumerator the same way.
The dispose pattern, and when you need it
Most disposable classes own other managed disposables and nothing native. For
them the guidance is short: seal the class and forward Dispose to what you
own.
csharppublic sealed class ReportWriter(string path) : IDisposable
{
private readonly StreamWriter _writer = new(path);
private bool _disposed;
public void Write(string line)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_writer.WriteLine(line);
}
public void Dispose()
{
if (_disposed) return; // idempotent: a second call does nothing
_disposed = true;
_writer.Dispose();
}
}The full pattern exists for classes that can be inherited: a public
non-virtual Dispose() that calls Dispose(true) and then
GC.SuppressFinalize(this), plus a protected virtual Dispose(bool disposing)
that derived classes override and chain to base.Dispose(disposing). The
disposing flag is false only when a finalizer calls it, and that path must
not touch other managed objects, because the order of finalization is not
deterministic. A finalizer is only needed if the class itself holds a raw
unmanaged resource, which the documentation calls a highly advanced scenario.
Ownership decides who disposes. A class that creates a disposable owns it; a
class that receives one (from a constructor argument or from DI) usually does
not and must not dispose it. Stream decorators make that choice explicit with
leaveOpen, as Stream Class Hierarchy and Decorators shows.
Finalizers are a safety net with a price
A finalizer (~Type()) runs on the finalizer thread some time after the
object becomes unreachable. Allocating one million small objects and forcing
a collection at the end measured, on .NET 10 Release:
| Objects | Time | Extra collections | Finalized |
|---|---|---|---|
| plain class | 7 ms | none | 0 |
| class with a finalizer | 89 to 151 ms | 3 gen0, 1 gen1 | 1,000,000 |
finalizer, GC.SuppressFinalize in the loop |
25 ms | 3 gen0 | 0 |
Every object with a finalizer is registered at allocation, which is why even the suppressed version costs more. An unreachable finalizable object also survives its first collection: it waits on the finalization queue, its finalizer runs, and only a later collection frees the memory:
csharpvar wr = new WeakReference(new WithFinalizer(), trackResurrection: true);
GC.Collect(); // queued for finalization: wr.IsAlive == true
GC.WaitForPendingFinalizers(); // the finalizer has certainly run now
GC.Collect(); // wr.IsAlive == false: memory reclaimedDo not rely on finalizers at shutdown. .NET 5 and later do not run finalizers
when the process exits; a program that created a finalizable object and
returned from Main printed nothing from the finalizer, while an
AppDomain.ProcessExit handler did run. Cleanup that must happen on exit goes
through Dispose called from such a handler or from the host.
SafeHandle instead of your own finalizer
SafeHandle wraps an IntPtr and already has the finalizer, the
release-once guarantee, and the thread safety. You override IsInvalid and
ReleaseHandle:
csharpsealed class NativeBuffer : SafeHandle
{
private NativeBuffer() : base(IntPtr.Zero, ownsHandle: true) { }
public override bool IsInvalid => handle == IntPtr.Zero;
public static NativeBuffer Allocate(int bytes)
{
var h = new NativeBuffer();
h.SetHandle(Marshal.AllocHGlobal(bytes));
return h;
}
protected override bool ReleaseHandle() { Marshal.FreeHGlobal(handle); return true; }
}When the caller forgot to dispose one, ReleaseHandle still ran after
GC.Collect() and GC.WaitForPendingFinalizers(). The class that holds the
NativeBuffer then needs only the simple sealed Dispose above, with no
finalizer of its own. SafeFileHandle, SafeWaitHandle, and the other
built-in handles already follow this design.
Disposal in the DI container
Microsoft.Extensions.DependencyInjection disposes the services it creates:
transient and scoped ones when their scope ends, singletons when the root
provider is disposed. An instance you pass in yourself
(AddSingleton(new Foo())) is never disposed by the container. Two traps
appeared in a test with version 10.0.0:
- Resolving a transient
IDisposable1,000 times from the root provider kept all 1,000 instances alive after a full GC, because the container tracks them for disposal; they were disposed only when the root provider was. The documentation lists this as a memory leak anti-pattern. - A scoped service that implements only
IAsyncDisposable, inside a scope disposed with a plainusing, threwInvalidOperationException: 'OnlyAsync' type only implements IAsyncDisposable. Use DisposeAsync to dispose the container.CreateAsyncScope()withawait usingfixed it.
Lifetimes themselves are covered in Dependency Injection and Service Lifetimes.
Trade-offs
usingdeclaration versususingblock. The declaration is shorter, but it holds the resource until the end of the scope; a lock or a connection held through a slow tail of the method is a real cost.csharpusing var conn = Open(); var rows = Query(conn); Render(rows); // conn is still open here- Finalizer versus no finalizer. A finalizer catches a forgotten
Disposefor raw handles, at the price of slower allocation, an extra collection before the memory returns, and no guarantee at process exit.SafeHandlegives the same safety net without writing one. - Sealed versus inheritable disposables. Sealing removes the
Dispose(bool)ceremony; an unsealed public class has to provide it so derived classes can add cleanup correctly. - Sync versus async disposal. Implementing only
IAsyncDisposableforces every caller, including DI scopes, to useawait using; implementing both is friendlier but means maintaining two paths. - Letting the container dispose. DI disposal is automatic and correct for scoped work, but disposable transients resolved from the root live until shutdown; resolve them inside a scope or create them with a factory you dispose yourself.