Search code examples
c#.nettask-parallel-libraryidisposablefinalizer

Ensure that dotnet Tasks are stopped when they go out of garbage collector scope


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.

  • Q1: How can I get the GC to call _cts.Cancel()
  • Q2: If the answer includes usage of 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?


Solution

  • 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:

    1. It is not possible for the _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.
    2. It is possible for the _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.