All articles

IDisposable, Finalizers and the Dispose Pattern in .NET

Deterministic cleanup in .NET means releasing a resource the moment you are done with it, instead of waiting for the garbage collector. You get it by…

0 · log in to like, save & follow Share on LinkedIn Share on X

Deterministic cleanup in .NET means releasing a resource the moment you are done with it, instead of waiting for the garbage collector. You get it by implementing IDisposable and calling Dispose() — most often through a using statement. In modern .NET 9 (C# 13) you rarely write a finalizer: SafeHandle and using handle almost every case, and IAsyncDisposable covers cleanup that must run asynchronously.

IDisposable, Finalizers and the Dispose Pattern in .NET

Managed vs unmanaged resources and the GC

The garbage collector tracks managed memory — objects on the managed heap. It runs non-deterministically: you cannot know when an object's memory is reclaimed. That is fine for pure memory, but it is a problem for unmanaged resources: file handles, sockets, database connections, OS window handles, native memory. These live outside the GC's view. If you wait for finalization to release them, you may exhaust the handle table or hold a database connection open far longer than intended.

The rule: memory is the GC's job; everything else is yours. Anything that wraps an unmanaged resource — or holds another IDisposable — needs deterministic cleanup.

This distinction is the whole reason the Dispose pattern exists. The GC is excellent at reclaiming memory when it decides the time is right, but "when it decides" is exactly the wrong policy for a scarce OS handle. Deterministic disposal puts you back in control of when, while the GC keeps control of where the memory goes.

What does IDisposable give you?

IDisposable declares a single method, Dispose(), that releases the object's resources on demand. Callers invoke it explicitly, or let the compiler do it. It is the contract that types like FileStream, HttpClient, SqlConnection, and CancellationTokenSource implement.

var stream = new FileStream("data.bin", FileMode.Open);
try
{
    // work with the stream
}
finally
{
    stream.Dispose(); // released here, deterministically
}

Use the using statement and using declarations

Writing try/finally by hand is noise. The using statement compiles to exactly that, guaranteeing Dispose() runs even if an exception is thrown.

using (var stream = new FileStream("data.bin", FileMode.Open))
{
    // work with the stream
} // Dispose() called automatically

Since C# 8, a using declaration drops the braces. The variable is disposed at the end of its enclosing scope:

static void Read()
{
    using var stream = new FileStream("data.bin", FileMode.Open);
    // work with the stream
} // disposed here, at the closing brace of the method

Reach for using whenever you create an IDisposable you own within a scope. It is the single most important habit for correct resource handling.

One caveat about ownership: only dispose what you actually own. If a method receives a stream from its caller, disposing it inside the method breaks the caller. Dispose objects you create; leave borrowed ones alone. Injected dependencies registered with a dependency-injection container are a common example — the container owns their lifetime and disposes them for you, so a using there would be a bug.

Implement the canonical Dispose pattern

When you write a class that owns disposable or unmanaged resources — and especially one meant to be a base class — implement the full pattern. It separates two situations: an explicit Dispose() call, and cleanup driven by a finalizer.

The canonical Dispose(bool) pattern in a C# class

public class ResourceHolder : IDisposable
{
    private bool _disposed;
    private FileStream? _managed;         // managed, disposable
    private IntPtr _native;               // unmanaged handle

    public void Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;

        if (disposing)
        {
            _managed?.Dispose();          // release managed resources
        }

        // release unmanaged resources here
        if (_native != IntPtr.Zero)
        {
            // NativeMethods.Close(_native);
            _native = IntPtr.Zero;
        }

        _disposed = true;
    }
}

Three details matter:

  • Dispose() is the public entry point. It calls Dispose(true) and then GC.SuppressFinalize(this) so the GC skips finalization — the object is already clean, so there is no reason to promote it to the finalizer queue.
  • Dispose(bool disposing) is protected virtual so derived classes can extend cleanup. When disposing is true you may touch other managed objects; when it is false (finalizer path) you must not, because they may already be collected.
  • The _disposed guard makes Dispose() safe to call more than once, as the contract requires.

Why finalizers are rarely needed today

A finalizer (~ResourceHolder()) is the GC's safety net: it runs Dispose(false) if a caller forgets to dispose. But finalizers are expensive. They keep objects alive for an extra GC generation, run on a dedicated thread in nondeterministic order, and can hurt throughput.

You need a finalizer only if your type directly holds an unmanaged resource as a raw handle. If your fields are all managed IDisposable objects, do not write one — each of them already protects its own unmanaged resource. Adding a redundant finalizer just imposes the finalization cost on every instance for no benefit.

When you do write one, keep it minimal: it should call Dispose(false) and nothing else. Never throw from a finalizer, never allocate, and never touch other managed objects, since their state is undefined at that point. In practice, the safest finalizer is the one you avoid by letting a SafeHandle do the work.

Prefer SafeHandle for unmanaged handles

The modern recommendation is to wrap unmanaged handles in a SafeHandle rather than storing an IntPtr and writing a finalizer yourself. SafeHandle derivatives (SafeFileHandle, and others) are themselves finalizable and handle the reference-counting and release logic correctly, including protection against handle-recycling attacks.

public sealed class MyHandle : SafeHandleZeroOrMinusOneIsInvalid
{
    public MyHandle() : base(ownsHandle: true) { }

    protected override bool ReleaseHandle()
    {
        // return NativeMethods.Close(handle);
        return true;
    }
}

With a SafeHandle field, your outer class implements IDisposable, disposes the handle in Dispose(true), and needs no finalizer of its own — the SafeHandle carries that responsibility. This is why direct finalizers have become an edge case in .NET 9.

Clean up asynchronously with IAsyncDisposable

Some resources must be released with async work — flushing a buffered stream to disk, closing a network connection with a graceful handshake, or draining a channel. Blocking on that in a synchronous Dispose() risks deadlocks. .NET provides IAsyncDisposable with a DisposeAsync() method, consumed by await using.

public sealed class AsyncBuffer : IAsyncDisposable
{
    private readonly Stream _stream = new MemoryStream();

    public async ValueTask DisposeAsync()
    {
        await _stream.FlushAsync();
        await _stream.DisposeAsync();
    }
}

await using var buffer = new AsyncBuffer();
// use buffer; DisposeAsync awaited at end of scope

If a type supports both, implement IDisposable and IAsyncDisposable, and prefer await using where you are already in async code. Types like DbConnection and Utf8JsonWriter already implement both. Note that DisposeAsync() returns a ValueTask, not a Task, to avoid an allocation on the common synchronous-completion path — a small but deliberate performance choice in the .NET 9 base class library.

A practical guideline: when a class already implements both interfaces, do not call the synchronous Dispose() from DisposeAsync() or vice versa. Route the shared logic through a private core method and let each public entry point do only its own part. That keeps the async path from blocking and the sync path from spinning up async machinery it does not need.

Putting it together

For everyday code the decision tree is short. Consuming a disposable? Wrap it in using or await using. Writing a class that holds disposables? Implement IDisposable, dispose the fields, skip the finalizer. Writing a class that wraps a raw OS handle? Reach for SafeHandle first, and only hand-roll a finalizer when you genuinely cannot. Following that order keeps the vast majority of your types finalizer-free, which is exactly what the .NET 9 runtime is optimized for.

Key takeaways

  • The GC manages memory only; unmanaged resources need deterministic cleanup through IDisposable.
  • Use using statements and using declarations for any IDisposable you own — they guarantee disposal on exceptions.
  • The canonical pattern is Dispose()Dispose(bool disposing)GC.SuppressFinalize(this), with a _disposed guard.
  • Write a finalizer only when your type directly owns a raw unmanaged handle; otherwise skip it.
  • Prefer SafeHandle over raw IntPtr plus a hand-written finalizer — it is the .NET 9 recommendation.
  • Use IAsyncDisposable and await using when cleanup requires asynchronous work.

Frequently asked questions

Do I always need a finalizer when I implement IDisposable?

No. Most types that implement IDisposable only hold other managed IDisposable fields, and those already protect their own unmanaged resources. Add a finalizer only when your class directly owns a raw unmanaged handle and you are not using SafeHandle.

What does GC.SuppressFinalize actually do?

It tells the garbage collector not to call the object's finalizer, because Dispose() has already performed the cleanup. This avoids the cost of promoting the object to the finalization queue and keeping it alive an extra generation.

When should I use IAsyncDisposable instead of IDisposable?

Use IAsyncDisposable when releasing the resource involves genuinely asynchronous work, such as flushing to disk or a network round trip. Consuming it with await using avoids blocking a thread and the deadlocks that a synchronous Dispose() can cause.

Is calling Dispose() more than once safe?

It should be. The pattern's _disposed flag makes repeated calls a no-op, and the IDisposable contract requires implementations to tolerate multiple Dispose() calls without throwing.

Enjoyed this article? Get the best GeeksArray articles in your inbox — once a week, no spam, unsubscribe anytime.

Comments (0)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.