This question is specific to multi-threaded applications under .NET 9.
I often deal with COM Interop scenarios where references must be disposed of in a specific order, while the order of obtaining them is non-deterministic. I am also of the opinion that interface inheritence is a better approach vs implementation inheritence in scenarios that deal with programming constructs rather than business logic. This question, however, is not about approach.
I use a concrete, abstract implementation of the IDisposable
pattern as the base for top-level classes that requires fine control over the order of disposal. This is the first time I am implementing the IAsyncDispose
pattern and have tried to adhere to the documentation available here. Am still wrapping my head around it so not sure if the question title is appropriate.
internal abstract class Disposable:
IDisposable,
IAsyncDisposable
{
private bool Disposed = false;
private readonly List<SafeHandle?> SafeHandleList = [];
private readonly Stack<SafeHandle?> SafeHandleStack = [];
private readonly Queue<SafeHandle?> SafeHandleQueue = [];
private readonly List<IDisposable?> DisposableList = [];
private readonly Stack<IDisposable?> DisposableStack = [];
private readonly Queue<IDisposable?> DisposableQueue = [];
private readonly List<IAsyncDisposable?> AsyncDisposableList = [];
private readonly Stack<IAsyncDisposable?> AsyncDisposableStack = [];
private readonly Queue<IAsyncDisposable?> AsyncDisposableQueue = [];
protected Disposable () { }
~Disposable () { this.Dispose(disposing: false); }
public bool IsDisposed => this.Disposed;
protected T AddDisposable<T> (T disposable) where T : IDisposable
{ this.ThrowDisposedException(); this.DisposableList.Add(disposable); return disposable; }
protected T PushDisposable<T> (T disposable) where T : IDisposable
{ this.ThrowDisposedException(); this.DisposableStack.Push(disposable); return disposable; }
protected T EnqueueDisposable<T> (T disposable) where T : IDisposable
{ this.ThrowDisposedException(); this.DisposableQueue.Enqueue(disposable); return disposable; }
protected T AddAsyncDisposable<T> (T asyncDisposable) where T : IAsyncDisposable
{ this.ThrowDisposedException(); this.AsyncDisposableList.Add(asyncDisposable); if (asyncDisposable is IDisposable disposable) { this.DisposableList.Add(disposable); } return asyncDisposable; }
protected T PushAsyncDisposable<T> (T asyncDisposable) where T : IAsyncDisposable
{ this.ThrowDisposedException(); this.AsyncDisposableStack.Push(asyncDisposable); if (asyncDisposable is IDisposable disposable) { this.DisposableStack.Push(disposable); } return asyncDisposable; }
protected T EnqueueAsyncDisposable<T> (T asyncDisposable) where T : IAsyncDisposable
{ this.ThrowDisposedException(); this.AsyncDisposableQueue.Enqueue(asyncDisposable); if (asyncDisposable is IDisposable disposable) { this.DisposableQueue.Enqueue(disposable); } return asyncDisposable; }
protected T AddSafeHandle<T> (T safeHandle) where T : SafeHandle
{ this.ThrowDisposedException(); this.SafeHandleList.Add(safeHandle); return safeHandle; }
protected T PushSafeHandle<T> (T disposable) where T : SafeHandle
{ this.ThrowDisposedException(); this.SafeHandleStack.Push(disposable); return disposable; }
protected T EnqueueSafeHandle<T> (T disposable) where T : SafeHandle
{ this.ThrowDisposedException(); this.SafeHandleQueue.Enqueue(disposable); return disposable; }
public void ThrowDisposedException ()
{
if (this.Disposed)
{
var type = this.GetType();
throw new ObjectDisposedException(type.FullName, $@"Attempt to access a disposed object: [{type.FullName}].");
}
}
public void Dispose ()
{
this.Dispose(disposing: true);
GC.SuppressFinalize(obj: this);
}
protected virtual void Dispose (bool disposing)
{
if (!this.Disposed)
{
if (disposing)
{
// Dispose objects implementing [IDisposable] and [SafeHandle].
while (this.DisposableList.Count > 0) { try { this.DisposableList [0]?.Dispose(); } catch { } this.DisposableList.RemoveAt(0); }
while (this.DisposableStack.Count > 0) { try { this.DisposableStack.Pop()?.Dispose(); } catch { } }
while (this.DisposableQueue.Count > 0) { try { this.DisposableQueue.Dequeue()?.Dispose(); } catch { } }
while (this.SafeHandleList.Count > 0) { try { this.SafeHandleList [0]?.Dispose(); } catch { } this.SafeHandleList.RemoveAt(0); }
while (this.SafeHandleStack.Count > 0) { try { this.SafeHandleStack.Pop()?.Dispose(); } catch { } }
while (this.SafeHandleQueue.Count > 0) { try { this.SafeHandleQueue.Dequeue()?.Dispose(); } catch { } }
// This approach is meant to help both async and non-async consumption scenarios.
// Any objects implementing [IAsyncDisposable] as well as [IDisposable] have already been dealt with at this point.
// https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/implementing-disposeasync#implement-both-dispose-and-async-dispose-patterns.
// It is up to the comsuming code to ensure that [DisposeAsync] is called, if needed. Example: [using (var ad = new AsyncDisposable())] vs. [await using (var ad = = new AsyncDisposable())].
}
// Dispose unmanaged resources (excluding [SafeHandle] objects).
// Free unmanaged resources (unmanaged objects), override finalizer, and set large fields to null.
this.Disposed = true;
}
}
public async ValueTask DisposeAsync ()
{
// Perform asynchronous cleanup.
await this.DisposeAsyncCore()
.ConfigureAwait(false);
// Dispose of unmanaged resources.
this.Dispose(false);
// Suppress finalization.
GC.SuppressFinalize(this);
}
protected virtual async ValueTask DisposeAsyncCore ()
{
if (!this.Disposed)
{
while (this.AsyncDisposableList.Count > 0)
{
var asyncDisposable = this.AsyncDisposableList [0];
if (asyncDisposable is not null) { try { await asyncDisposable.DisposeAsync().ConfigureAwait(false); } catch { } }
this.AsyncDisposableList.RemoveAt(0);
}
while (this.AsyncDisposableStack.Count > 0)
{
var asyncDisposable = this.AsyncDisposableStack.Pop();
if (asyncDisposable is not null) { try { await asyncDisposable.DisposeAsync().ConfigureAwait(false); } catch { } }
}
while (this.AsyncDisposableQueue.Count > 0)
{
var asyncDisposable = this.AsyncDisposableQueue.Dequeue();
if (asyncDisposable is not null) { try { await asyncDisposable.DisposeAsync().ConfigureAwait(false); } catch { } }
}
// TODO: We want to ensure that objects implementing only [IDisposable] are handled as well.
// Although there are not circular references between the dispose mthods, calling [this.Dispose()] here directly smells funny.
this.Dispose();
this.Disposed = true;
}
}
}
Although I have tried to adhere to best practices, I am unsure about the following:
Dispose
from within DisposeAsyncCore
. Something about tha does not sit right with me. Perhaps I have not thought this through properly. Should I be calling the virtual implementation instead Dispose(disposing: true|false???)
?using (var ad = new AsyncDisposable())
] vs. [await using (var ad = new AsyncDisposable())
]?SafeHandle
] objects go? etc.Thye goal is to allow inherited classes to be able to add disposable objects as they please, while not having to override/burden their own Dispose(bool)/DisposeAsyncCore
overrides.
Any advise would be appreciated.
Here is an example of usage for clarification:
public sealed class SampleTask:
Disposable
{
private readonly MemoryStream Stream1;
private readonly Excel.Application Application;
public SampleTask ()
{
// Add child objects in any desired order.
this.Application = this.AddDisposable(new Excel.Application());
this.Stream1 = this.PushAsyncDisposable(new MemoryStream());
}
// This class needs to have control over the lifetime of some
// exposed objects irrespective of their external reference count.
public Image GetImage () => this.AddDisposable(new Bitmap(10, 10));
public Excel.Worksheet GetExcelWorksheet () => this.AddDisposable(this.Application.Workbooks [0].Sheets [0]);
protected override void Dispose (bool disposing) => base.Dispose(disposing);
protected override ValueTask DisposeAsyncCore () => base.DisposeAsyncCore();
}
EDIT: I am not sure why this question was voted closed since the comments and answers, although helpful in general, did not address the three bulleted questions (particularly the one about calling Dispose
from within DisposeAsyncCore
since releasing of unmanaged resources is centralized in Dispose
). Irrespective of finalization, my scenario needs to assume that one of the dispose/disposeasync methods is called explicitly with using / await using
statements.
Your code, like a lot of code dealing with finalization (and indeed the design of C#'s "destructors") is appears to be colored by some misunderstandings about how finalization actually works.
Contrary to popular belief, an object's Finalize
method doesn't get run when an object is garbage-collected, but rather when would have been garbage-collected, but for the existence of registered finalizer somewhere. The existence of a registered finalizer will prevent an object, or any objects to which it holds a strong reference, from actually being garbage-collected until after the finalizer has been unregistered (which would occur as a consequence of triggering it), and as long as a reference to an object exists anywhere in the universe it's impossible to be certain that code won't try to use an object agin.
If one wants to have code that will be executed when an object is actually garbage collected, one should have a public facing "shell" object which constructs and holds references to two or three private objects. One of these should encapsulate the main object functionality and handle any subscribed events or notifications, and one of these should be a "canary" object that will sing when its owner is garbage collected. The third object would hold just enough information to perform cleanup, but would only be required if the first object would hold references to outside objects. The canary object should hold a "long weak reference" (a WeakReference
with the optional Boolean constructor argument set to true) to its owner (the shell object), and an orinary reference to the object responsible for cleanup, and its finalize method should check if that WeakReference
is still alive and reregister itself for finalization (if the WeakReference
is still alive) or call the main functionality object's cleanup method. Note that when the cleanup method is called, the main object might still be the target of things like event subscriptions, but code can be certain that no reference to the outer object will ever again exist anywhere in the universe.
It's been ages since I've done substantial coding in .NET, and many types really shouldn't need to do anything with finalization, but if finalization is needed I'd suggest using the multi-object pattern and being careful to ensure that no cycle of references would connect the canary object or cleanup object back to the shell object, and no strong references to the canary object exist outside the shell object. When finalization does trigger, one should seek to minimize the range of objects that can't be garbage collected.