I have a background task that I am modeling with a Task
and which is stopped with an IAsyncDisposable
.
// implementation
public sealed class Worker : IAsyncDisposable
{
private readonly CancellationTokenSource _cts;
private readonly Task _worker;
public Worker()
{
_cts = new CancellationTokenSource();
_worker = DoWork(_cts.Token);
}
public ValueTask DisposeAsync()
{
_cts.Cancel();
_cts.Dispose();
return new ValueTask(_worker);
}
private static async Task DoWork(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
do async work
}
}
}
static async Task CorrectUsage()
{
await using var worker = new Worker();
do more stuff
}
static async Task IncorrectUsage()
{
var worker = new Worker();
do more stuff
}
Under normal circumstances this works fine, however, there seems to me to be a memory leak here which might occur if the calling code does not dispose of the worker
. If the Worker
component is being misused, I would like the GC to kill the running task when a it goes out of scope with a _cts.Cancel()
call
I have read about ~Worker()
and Dispose(bool disposing)
, but all of the documentation I have found points to these methods being specifically for the disposal of unmanaged resources.
_cts.Cancel()
Dispose(bool disposing)
, is there any documentation on how this would be called with disposing: false
?A note on Tasks and the GC Can .NET Task instances go out of scope during run?
How can I get the GC to call
_cts.Cancel()
?
Like this:
public sealed class Worker : IAsyncDisposable
{
private readonly CancellationTokenSource _cts;
~Worker()
{
_cts.Cancel();
}
public ValueTask DisposeAsync()
{
_cts.Cancel();
_cts.Dispose();
GC.SuppressFinalize(this);
return new ValueTask(_worker);
}
}
The _cts.Cancel()
throws an exception if the _cts
is disposed. The line GC.SuppressFinalize(this)
ensures that the ~Worker
finalizer will not be called, preventing this exception.
As a side note, this DisposeAsync()
implementation exposes asynchronously the exception of the _worker
task, if any. This is against the collective wisdom of the C# community, according to which the Dispose
/DisposeAsync
shouldn't throw exceptions.
I should address also a question that you didn't ask, which is whether there is anything fundamentally wrong with calling _cts.Cancel()
in the finalizer. There might be. I can't guarantee you with 100% confidence that it's fine. Quoting from Chris Brumme's article "Finalization":
One of the guidelines for finalization is that a
Finalize
method shouldn’t touch other objects. People sometimes incorrectly assume that this is because those other objects have already been collected. Yet, as I have explained, the entire reachable graph from a finalizable object is promoted.The real reason for the guideline is to avoid touching objects that may have already been finalized. That’s because finalization is unordered.
So, like most guidelines, this one is made to be broken under certain circumstances. For instance, if your object “contains” a private object that is not itself finalizable, clearly you can refer to it from your own Finalize method without risk.
This article was written at a time when .NET 2.0 was still in the making (February 20, 2004), but contains basic principles that probably still apply. What I am deriving from the quoted text is that:
_cts
to be recycled (have its memory reclaimed) when the ~Worker
finalizer runs, because the _cts
is part of the finalized Worker
's reachable graph._cts
to be already finalized (have its ~CancellationTokenSource
finalizer invoked) when the ~Worker
finalizer runs. AFAICS the current implementation of the CancellationTokenSource
class (.NET 8) doesn't include a finalizer. In case you are using an earlier version of the .NET platform, you may want to find and study the CancellationTokenSource
implementation of your targeted .NET version. As long as the CancellationTokenSource
doesn't have a finalizer, the probability of the _cts.Cancel()
throwing an exception is greatly reduced. I can't guarantee that the probability is zero, because there might by other finalizable objects, related to the specific APIs that you are using, that might still hold a reference to the _cts
when the _cts.Cancel()
is called, and these objects might be already finalized. You could consider enclosing the _cts.Cancel()
in a try
block with an empty catch
block, to minimize the impact of this scenario. According to an answer by Hans Passant, that describes the behavior of .NET 2.0:An exception in a finalizer is fatal. The default CLR host terminates the app for any unhandled exception.
I should add another word of wisdom from Chris Brumme's article:
It’s hard to implement
Finalize
perfectly.