C# Dispose vs Destructor: Key Differences Explained
c# dispose vs destructor: Understand the difference between Dispose and destructors in C#, when each runs, and how to manage unmanaged resources correctly.
The Core Difference: Deterministic vs Non-Deterministic
The difference between C# dispose vs destructor comes down to determinism: Dispose() is called explicitly by your code, giving you deterministic control over when resources are released. A destructor is called by the garbage collector at some unspecified time after the object becomes unreachable, making cleanup non-deterministic.
Consider this simple example:
public class FileHandler : IDisposable { private FileStream _stream; public FileHandler(string path) { _stream = File.OpenRead(path); } public void Dispose() { _stream.Dispose(); } }
When you call Dispose() explicitly, the file handle is released immediately. When you rely on a destructor, you have no idea when the GC will run.
What a Destructor Actually Does
A destructor in C# is syntactically similar to a finalizer:
public class ResourceHolder { ~ResourceHolder() { // Cleanup code here } }
The ~ClassName() syntax defines a finalizer. The garbage collector places objects with finalizers on the finalization queue when it determines they're unreachable. A dedicated finalizer thread runs the finalizer, and the object survives until the next GC cycle.
This means a destructor doesn't run when you expect it to. It runs when the GC decides, which could be seconds, minutes, or longer after the object becomes unreachable.
How Dispose Fits In
IDisposable is the interface that defines the Dispose() method:
public interface IDisposable { void Dispose(); }
The contract is simple: call Dispose() when you're done with the object. The using statement makes this easier:
using (var handler = new FileHandler("data.txt")) { // Work with the file } // Dispose() is called here automatically
The using statement compiles to a try/finally block that calls Dispose() even if an exception occurs. This is the recommended way to work with disposable objects in most cases.
Why Destructors Are Not a Substitute
Relying on a destructor for cleanup has several problems:
- Timing: You can't control when the finalizer runs.
- Thread: Finalizers run on a dedicated GC thread, not your thread.
- Order: You can't guarantee the order in which objects are finalized.
- Performance: Objects with finalizers take longer to collect because they survive at least one GC cycle.
For example, if you open a file and rely on a destructor to close it, the file stays locked until the GC runs. On Windows, this can cause sharing violations when other processes try to access the file.
The Finalizer/Dispose Pattern
When you hold unmanaged resources directly, you may need both a finalizer and Dispose(). The standard pattern looks like this:
public class NativeBuffer : IDisposable { private IntPtr _handle; private bool _disposed; public NativeBuffer(int size) { _handle = Marshal.AllocHGlobal(size); } ~NativeBuffer() { Dispose(false); } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (_disposed) return; if (disposing) { // Release managed resources here } // Release unmanaged resources here Marshal.FreeHGlobal(_handle); _disposed = true; } }
The disposing parameter distinguishes between two paths:
disposing == true: Called fromDispose(). Both managed and unmanaged resources can be released.disposing == false: Called from the finalizer. Only unmanaged resources should be released, because managed resources may already be collected.
GC.SuppressFinalize(this) tells the GC that the object no longer needs finalization, which avoids the extra GC cycle cost.
Runtime Cost and GC Interaction
Objects with finalizers have a measurable GC cost. When the GC finds an unreachable object with a finalizer, it doesn't collect it immediately. Instead, it moves the object to the finalization queue. The finalizer thread runs the finalizer, and only then can the object be collected in a subsequent GC cycle.
This means:
- The object survives at least one extra GC generation.
- The finalizer thread adds latency to the finalization process.
- Long-running finalizers can delay the finalizer thread, affecting other objects waiting for finalization.
In contrast, Dispose() with GC.SuppressFinalize() allows the GC to collect the object immediately when it becomes unreachable, with no extra cycles.
Common Mistakes and Edge Cases
Calling Dispose twice: The pattern above uses the _disposed flag to make Dispose() idempotent. Without it, double disposal can throw exceptions or double-release unmanaged resources.
Throwing from Dispose: Dispose() should not throw. If your cleanup code can throw, catch and handle exceptions inside Dispose().
Dispose in a destructor: Don't call Dispose() from a finalizer. The finalizer should call Dispose(false) to release only unmanaged resources.
Forgetting to suppress finalization: If you call Dispose() but don't call GC.SuppressFinalize(), the finalizer still runs later, which can cause double cleanup. The pattern above handles this.
Async disposal: For async cleanup, use IAsyncDisposable with DisposeAsync(), which works with await using:
await using (var stream = new FileStream("data.txt", FileMode.Open)) { // Work with the stream }
This is separate from the sync IDisposable pattern and is useful when cleanup involves async I/O operations.
When You Actually Need a Finalizer
Finalizers are rarely necessary in modern C#. Most resources are wrapped in managed classes that already implement IDisposable. You should only write a finalizer when you directly hold an unmanaged resource, such as a native handle obtained via P/Invoke or a raw memory allocation.
If you're using managed wrappers like FileStream, SqlConnection, or HttpClient, you should implement IDisposable and call Dispose() on the wrapped resources. You don't need a finalizer because the wrapper classes handle their own finalization.
The NativeBuffer example above is the canonical case: you allocate unmanaged memory with Marshal.AllocHGlobal, and the finalizer ensures Marshal.FreeHGlobal runs even if the caller forgets to call Dispose(). The _handle field is set to IntPtr.Zero after freeing, so a double call to Dispose() is harmless. The disposing parameter is technically unused in that example because there are no managed resources to release, but keeping it preserves the standard pattern for future extension.
For most application code, though, you won't write a finalizer at all. The managed classes you use already implement IDisposable, and your job is to call Dispose() — typically through a using statement — rather than to implement cleanup from scratch. When you do need a finalizer, keep it minimal: it should only release unmanaged resources and should never block on locks, perform I/O, or touch other managed objects that may already be finalized.