Задача не собирать мусор

В следующей программе я ожидаю, что задача получит GC'd, но это не так. Я использовал профилировщик памяти, который показал, что CancellationTokenSource содержит ссылку на него, хотя задача явно находится в конечном состоянии. Если я удалю TaskContinuationOptions.OnlyOnRanToCompletion, все будет работать так, как ожидалось.

Почему это происходит и что я могу сделать, чтобы предотвратить его?

    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);
    }

Мое подозрение в том, что, поскольку продолжение никогда не запускается (оно не соответствует критериям, указанным в его опциях), оно никогда не отменяет токен отмены. Таким образом, CTS ссылается на продолжение, в котором содержится ссылка на первую задачу.

Обновление

Команда PFX подтвердила, что это, по-видимому, является утечкой. В качестве обходного пути мы прекратили использовать любые условия продолжения при использовании токенов отмены. Вместо этого мы всегда выполняем продолжение, проверяем условие внутри и бросаем OperationCanceledException, если он не выполняется. Это сохраняет семантику продолжения. Следующий способ расширения инкапсулирует это:

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

Короткий ответ: я считаю, что это утечка памяти (или две, см. ниже), и вы должны сообщить об этом.

Длинный ответ:

Причина, по которой Task не является GCed, заключается в том, что она доступна из CTS следующим образом: ctscontTask. Я думаю, что обе эти ссылки не должны существовать в вашем случае.

Ссылка ctscont есть, потому что cont правильно регистрирует для отмены с использованием токена, но никогда не отменяет регистрацию. Он отменяет регистрацию, когда Task завершается нормально, но не отменяется. Я предполагаю, что ошибочная логика заключается в том, что если задача была отменена, нет необходимости отменить регистрацию из-за отмены, потому что это должно быть отменой, которая заставила задачу быть отмененной.

Ссылка contTask есть, потому что cont на самом деле ContinuationTaskFromResultTask (класс, который происходит от Task). Этот класс имеет поле, которое содержит антецедентную задачу, которая отменяется, когда продолжение выполняется успешно, но не когда оно отменено.

Ответ 2

в качестве дополнения... В этом случае вызывается Finalizer:

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

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

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