Disposable & Resource Management
Key Points
IDisposableis the synchronous resource-cleanup contract. Implement it on any class holding an unmanaged resource (file handle, DB connection, lock, native buffer).IAsyncDisposable(.NET Core 3+) is the async equivalent — use when cleanup itself is async (network drain, async flush).usingdeclaration (using var x = ...;) is concise and widely preferred overusing (...) { }blocks.await usingis forIAsyncDisposable. ASP.NET Core'sAsyncServiceScopeuses it.- Don't write a finalizer unless you really need a backstop. Use
SafeHandlefor native handles. GC.SuppressFinalize(this)is required in yourDispose()only if you have a finalizer — to skip the gen2 promotion penalty.- The dispose pattern in modern code is simpler than the legacy template — just implement
Dispose()and (optionally)DisposeAsync().
Concepts (deep dive)
using and using declarations
// Block form (legacy):
using (var f = File.OpenRead(path))
{
Process(f);
}
// Declaration form (C# 8+; preferred):
using var f = File.OpenRead(path);
Process(f);
// f.Dispose() called at end of enclosing scope
The declaration form is concise and avoids deeply nested blocks when you have multiple disposables. The compiler lowers both to try/finally with Dispose.
💡 Senior insight: in a method with many disposables, the declaration form keeps logical flow at the top of indentation; the block form forces a step-down per resource.
await using
public async Task ProcessAsync()
{
await using var scope = _provider.CreateAsyncScope();
var svc = scope.ServiceProvider.GetRequiredService<IFoo>();
await svc.DoAsync();
// scope.DisposeAsync() awaited here
}
Use await using for IAsyncDisposable. Critical when the dispose itself does async work (DB connection drain, network flush). await using is using + await Dispose Async().
IDisposable shape
public sealed class CounterScope : IDisposable
{
private readonly Action _onDispose;
private bool _disposed;
public CounterScope(Action onDispose) => _onDispose = onDispose;
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_onDispose();
// No finalizer here, so no SuppressFinalize call needed.
}
}
Modern shape: - Dispose() is idempotent (calling twice is fine). - No finalizer unless you need one (with SafeHandle you don't). - No GC.SuppressFinalize unless you have a finalizer. - Mark sealed if not designed for inheritance.
IAsyncDisposable shape
public sealed class StreamWriterAsync : IAsyncDisposable
{
private readonly Stream _inner;
private bool _disposed;
public StreamWriterAsync(Stream inner) => _inner = inner;
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
await _inner.FlushAsync().ConfigureAwait(false);
await _inner.DisposeAsync().ConfigureAwait(false);
}
}
DisposeAsync returns ValueTask (zero-allocation if completes synchronously). When the type holds only async-disposable resources, IAsyncDisposable alone is fine. If it holds both sync and async, implement both:
public sealed class Both : IDisposable, IAsyncDisposable
{
public void Dispose() { /* sync cleanup */ }
public async ValueTask DisposeAsync()
{
await /* async cleanup */;
Dispose(); // call sync as the last step
}
}
When to write a finalizer
Almost never. A finalizer: - Forces gen2 promotion (the object survives at least one collection). - Runs on a single low-priority thread. - Runs in unspecified order. - Throwing from it crashes the process in modern .NET.
If you wrap an unmanaged handle, use SafeHandle — see GC Tuning & Diagnosing:
public sealed class FileWrapper : IDisposable
{
private readonly FileSafeHandle _handle;
public FileWrapper(string path) { _handle = NativeApi.Open(path); }
public void Dispose() => _handle.Dispose();
}
SafeHandle is IDisposable and the runtime guarantees ReleaseHandle() runs even during AppDomain unload or process tear-down. No finalizer needed in your domain types.
The legacy "Dispose pattern"
You'll see this in old code:
public class LegacyType : IDisposable
{
private bool _disposed;
private IntPtr _native; // unmanaged
private Stream _inner; // managed
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
_inner?.Dispose();
}
if (_native != IntPtr.Zero) NativeApi.Close(_native);
_native = IntPtr.Zero;
_disposed = true;
}
~LegacyType() => Dispose(disposing: false);
}
Modern alternative: SafeHandle for native; just IDisposable for managed. No finalizer, no Dispose(bool).
using and exceptions
using var f = File.OpenRead(path);
ThrowSometimes();
// f.Dispose() runs even if ThrowSometimes throws — guaranteed by try/finally.
The dispose runs in the implicit finally. If Dispose itself throws AND the body threw, the body's exception is lost and the dispose's wins. (You should very rarely throw from Dispose.)
Multiple disposables — order matters
using var conn = new SqlConnection(...);
using var cmd = conn.CreateCommand();
// cmd disposed first (LIFO of declarations), then conn
The declaration order determines disposal order. Inner-most-declared disposes first — same as using blocks nested.
Resource ownership
public class Wrapper(Stream inner)
{
private readonly Stream _inner = inner;
// Does Wrapper "own" inner? Should Wrapper.Dispose() call _inner.Dispose()?
}
Decide explicitly:
- Owned:
Wrapper.Dispose()calls_inner.Dispose(). Caller passes ownership at construction; caller doesn't dispose. - Borrowed: caller still owns;
Wrapperdoes not dispose. Convention: pass anIDisposable.Wrap(...)if you want to opt-in to ownership.
Document this in the constructor's XML comment. Confusion here causes "object disposed" exceptions.
IAsyncEnumerable + await using
public async IAsyncEnumerable<int> ReadAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
await using var conn = await OpenAsync(ct); // disposed when iterator exits
while (true)
{
var x = await conn.NextAsync(ct);
if (x is null) yield break;
yield return x.Value;
}
}
The await using is captured in the iterator's state machine and runs when the consumer exhausts or stops the enumeration.
Disposable scopes pattern
public static class Telemetry
{
public static IDisposable BeginScope(string name)
=> new ScopeImpl(name);
private sealed class ScopeImpl : IDisposable
{
private readonly string _name;
private readonly Stopwatch _sw = Stopwatch.StartNew();
public ScopeImpl(string name) { _name = name; }
public void Dispose() => Logger.LogInformation("{Name} took {Ms}ms", _name, _sw.ElapsedMilliseconds);
}
}
// Use:
using (Telemetry.BeginScope("DB.Query"))
{
/* ... */
}
The "disposable scope" idiom is everywhere: Activity for tracing, Logger.BeginScope, transactions, locks. It's a powerful way to attach exit behavior to a code block.
IDisposable and DI
ASP.NET Core's DI container disposes scoped + transient IDisposables when the scope ends. Don't dispose them yourself — the container will:
public class Service(DbContext db) // DbContext is IDisposable; injected as scoped
{
public void Use() { /* don't call db.Dispose() */ }
}
// Container disposes db when the request scope ends.
Captive-dependency warning: a singleton holding a transient (or scoped) IDisposable keeps it alive forever — the singleton holds a reference. Container can't dispose. Symptoms: connections never released, file handles leaked. See Dependency Injection.
How it works under the hood
using lowers to:
await using lowers to:
{
var f = await OpenAsync();
try { /* body */ }
finally { if (f is not null) await f.DisposeAsync(); }
}
The compiler handles IDisposable via the standard Dispose() call. For IAsyncDisposable, it calls DisposeAsync().
SafeHandle interop with Dispose: SafeHandle.Dispose() decrements an internal reference count; only when the count reaches zero is ReleaseHandle() called. P/Invoke marshalers cooperate, so a SafeHandle argument can't be released mid-call by another thread.
DI containers track IDisposable registrations during scope construction. At scope dispose, they walk the list LIFO and Dispose() each.
Code: correct vs wrong
❌ Wrong: forgetting to dispose
public string ReadFirstLine(string path)
{
var s = File.OpenRead(path); // leak
return new StreamReader(s).ReadLine();
}
✅ Correct: using
public string ReadFirstLine(string path)
{
using var s = File.OpenRead(path);
using var r = new StreamReader(s);
return r.ReadLine() ?? "";
}
❌ Wrong: not awaiting DisposeAsync
public async Task DoAsync()
{
var conn = await OpenAsync();
try { await conn.UseAsync(); }
finally { _ = conn.DisposeAsync(); } // ❌ fire-and-forget
}
✅ Correct: await using
❌ Wrong: writing a finalizer for managed cleanup
public class Cache : IDisposable
{
public void Dispose() => Clear();
~Cache() => Clear(); // ❌ runs on finalizer thread; gen2 promotion; serializes
}
✅ Correct: just IDisposable
❌ Wrong: catching exception in Dispose and silently dropping
✅ Correct: log and continue
public void Dispose()
{
try { _conn.Close(); }
catch (Exception ex) { _log.LogWarning(ex, "close failed"); }
}
(Or: just let it throw. The caller may want to know.)
Design patterns for this topic
Pattern 1 — "using var everywhere"
- Intent: concise, idiomatic resource cleanup.
- When to use it: every disposable in a method.
Pattern 2 — "await using for async resources"
- Intent: correct async cleanup.
Pattern 3 — "Disposable scope for telemetry/timing"
- Intent: attach exit behavior to a code block.
- Code sketch: see Telemetry above.
Pattern 4 — "SafeHandle over finalizer"
- Intent: safer native resource ownership.
Pattern 5 — "Document ownership"
- Intent: clarify who disposes.
- Code sketch:
/// <param name="ownStream">If true, this object disposes <paramref name="stream"/>.</param>
public Wrapper(Stream stream, bool ownStream) { ... }
Pros & cons / trade-offs
| Approach | Pros | Cons |
|---|---|---|
using declaration | Concise; no nesting | Ties dispose to method scope |
using block | Explicit scope control | More indentation |
await using | Async-correct | Newer (.NET Core 3+) |
IDisposable only | Simple | No async cleanup |
| Finalizer | Backstop | Gen2 promotion; rarely worth it |
SafeHandle | Runtime-guaranteed release | Slight overhead; one per native |
When to use / when to avoid
- Use
usingdeclarations for almost all disposables. - Use
await usingforIAsyncDisposable. - Use
SafeHandlefor native resources. - Avoid finalizers unless you have a real reason.
- Avoid disposing twice — make
Disposeidempotent. - Avoid disposing borrowed resources — document ownership.
Interview Q&A
Q1. What's the difference between using block and using declaration? The block (using (...) { ... }) creates an explicit scope; dispose at end of block. The declaration (using var x = ...;) ties dispose to the enclosing method/scope. Both lower to try/finally.
Q2. When should you implement IAsyncDisposable? When cleanup itself is async — flushing buffers, draining connections, awaiting graceful shutdown. Just IDisposable if cleanup is sync.
Q3. Should you implement both IDisposable and IAsyncDisposable? Yes, if your type holds both sync and async resources. The async one runs first (cleaner async cleanup); call sync Dispose() at the end of DisposeAsync for the sync remnants.
Q4. Why is GC.SuppressFinalize needed in dispose pattern? Only when you have a finalizer. It removes the object from the finalization queue, avoiding gen2 promotion. If your class has no finalizer, SuppressFinalize is a no-op.
Q5. Why is SafeHandle better than a finalizer? The runtime treats SafeHandle specially: it reference-counts across P/Invoke calls, releases even during AppDomain unload, and integrates with the dispose pattern. No finalizer needed in your wrapper type.
Q6. What happens if a constructor throws after acquiring a resource? The object isn't fully constructed, so its Dispose may not be reachable. Solutions: (1) use SafeHandle (auto-released), (2) wrap the construction in a try/catch and dispose explicitly on error.
Q7. What's a "captive dependency"? A long-lived service holding a reference to a shorter-lived disposable. Common: singleton holds scoped DbContext — keeps it alive forever, container can't dispose. Symptoms: connection leaks, file-handle leaks. Validate via the DI container's scope verification or a test.
Q8. Should Dispose throw? Generally no — it makes cleanup paths fragile. Log and continue on error. The exception: if disposing genuinely fails in a way the caller must know about, document it.
Q9. How does using interact with goto / return / throw? The implicit finally ensures Dispose runs on any exit from the scope, including exceptions and early returns.
Q10. What's the order of disposal for nested usings? LIFO. The last-acquired resource is disposed first. Same as nested using blocks, just expressed via declaration order.
Q11. Why might you call GC.SuppressFinalize(this) even without a finalizer? You shouldn't — it's a no-op without a finalizer. Some legacy code does it as a "just in case" but adds nothing.
Q12. How does ASP.NET Core dispose IDisposable services? The DI scope tracks registered services that implement IDisposable/IAsyncDisposable. On scope dispose, they're disposed LIFO. For IAsyncDisposable, an AsyncServiceScope uses await using.
Gotchas / common mistakes
- ⚠️ Forgetting
using— leaked file handles, DB connections. - ⚠️
await usingwithout async support — compile error. - ⚠️
usingon a borrowed resource — caller still expected to dispose; double-dispose. - ⚠️ Throwing in
Dispose— masks original exceptions. - ⚠️ Calling
Disposefrom a finalizer — runs on finalizer thread; may deadlock. - ⚠️ Fire-and-forget
DisposeAsync— async cleanup may not complete. - ⚠️ Disposing in a long-lived service — DI container handles scoped/transient; you don't.
- ⚠️ Captive dependency — singleton holding scoped — slow leak.