Task not garbage collected

2019-04-06 11:52发布

问题:

In the following program, I'd expect the task to get GC'd, but it doesn't. I've used a memory profiler which showed that the CancellationTokenSource holds a reference to it even though the task is clearly in a final state. If I remove TaskContinuationOptions.OnlyOnRanToCompletion, everything works as expected.

Why does it happen and what can I do to prevent it?

    static void Main()
    {
        var cts = new CancellationTokenSource();

        var weakTask = Start(cts);

        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();

        Console.WriteLine(weakTask.IsAlive); // prints True

        GC.KeepAlive(cts);
    }

    private static WeakReference Start(CancellationTokenSource cts)
    {
        var task = Task.Factory.StartNew(() => { throw new Exception(); });
        var cont = task.ContinueWith(t => { }, cts.Token, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Default);
        ((IAsyncResult)cont).AsyncWaitHandle.WaitOne(); // prevents inlining of Task.Wait()
        Console.WriteLine(task.Status); // Faulted
        Console.WriteLine(cont.Status); // Canceled
        return new WeakReference(task);
    }

My suspicion is that because the continuation never runs (it doesn't meet the criteria specified in its options), it never unregisters from the cancellation token. So the CTS holds a reference to the continuation, which holds a reference to the first task.

Update

The PFX team has confirmed that this does appear to be a leak. As a workaround, we've stopped using any continuation conditions when using cancellation tokens. Instead, we always execute the continuation, check the condition inside, and throw an OperationCanceledException if it is not met. This preserves the semantics of the continuation. The following extension method encapsulates this:

public static Task ContinueWith(this Task task, Func<TaskStatus, bool> predicate, 
    Action<Task> continuation, CancellationToken token)
{
    return task.ContinueWith(t =>
      {
         if (predicate(t.Status))
              continuation(t);
         else
              throw new OperationCanceledException();
      }, token);
}

回答1:

Short answer: I believe this is a memory leak (or two, see below) and you should report it.

Long answer:

The reason why the Task is not GCed is because it is reachable from the CTS like this: ctsconttask. I think both of those references should not exist in your case.

The ctscont reference is there because cont correctly registers for cancellation using the token, but it never unregisters. It does unregister when a Task completes normally, but not when it's canceled. My guess is the erroneous logic is that if the task was canceled, there is no need to unregister from the cancellation, because it had to be that cancellation that caused the task to be canceled.

The conttask reference is there, because cont is actually ContinuationTaskFromResultTask (a class that derives from Task). This class has a field that holds the antecedent task, which is nulled out when the continuation is executed successfully, but not when it's canceled.



回答2:

as an addition... In this case the Finalizer is being called:

WeakReference weakTask = null;
using (var cts = new CancellationTokenSource())
{
  weakTask = Start(cts);
}

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();

Console.WriteLine(weakTask.IsAlive); // prints false