При каких условиях уничтожается Task?

Абстрактный пример: есть _reader, который асинхронно читает данные. Есть cancellationToken, который станет Cancel, когда приложение должно завершить свою работу:

while (!cancellationToken.IsCancellationRequested)
{
    var readingTask = _reader.ReadAsync();
    await Task.WhenAny(readingTask, Task.Delay(Timeout.Infinite, cancellationToken));
    
    if (readingTask.IsCompletedSuccessfully)
    {
        var readResult = readingTask.Result;
        // Обрабатываем readResult
    }
}

Предположим, что цикл выполнился раз 40. Будет ли засоряться память либо проседать производительность из-за того, что каждую итерацию цикла создается задача Task.Delay(Timeout.Infinite, cancellationToken), которая завершится только в случае, когда токен будет Cancel? Либо сборщик мусора (либо кто-то еще) будет уничтожать эти бесконечные задачи?


Ответы (2 шт):

Автор решения: CrazyElf

Я потестировал вырожденный случай - вообще без cancellationToken, тут это по-моему не важно. Результат - память таки кончается, не смотря на работу сборщика мусора.

using System;
using System.Threading.Tasks;
                    
public class Program
{
    public static async void Main()
    {
        Console.WriteLine("Begin...");
        Console.WriteLine("Memory used {0:N0}", GC.GetTotalMemory(false));
        for(var i = 0; i < 100000; i++)
        {
            await Task.WhenAny(Task.CompletedTask, Task.Delay(100000));
            if(i % 10000 == 0)
            {
                Console.WriteLine("Collecting");
                GC.Collect();
                GC.Collect();
                Console.WriteLine("Memory used {0:N0}", GC.GetTotalMemory(true));
            }
        }
        Console.WriteLine("Hello World!");
    }
}

Вывод:

Begin...
Memory used 8,159,128
Collecting
Memory used 7,964,504
Collecting
Memory used 27,284,692
Fatal Error: Memory usage limit was exceeded

Ссылка, по которой можно проверить работу кода.

→ Ссылка
Автор решения: Pavel Mayorov

Как и любой объект, Task уничтожается когда на него не остаётся ссылок. Обычно ссылок на него - две:

  1. от потребителя объекта (того, кто ждёт);
  2. от того, кто выполняет работу.

Первую ссылку удерживаете вы сами (для временных объектов её "держит" только оператор await). А вот вторая ссылка живёт пока задача не окажется завершена либо отменена.

Соответственно, обычно для уничтожения Task необходимо и достаточно забыть про него потребителю и отменить работу. Но в каждом индивидуальном случае могут быть свои особенности.


Теперь о Task.Delay. Ссылка на Task.Delay удерживается активным таймером, и пропадает при отмене таймера. Таким образом, вам достаточно отменить таймер чтобы объект был собран GC:

using (var tempCts = CancellationTokenSource.CreateLinkedSource(cancellationToken)) {
    await Task.WhenAny(readingTask, Task.Delay(Timeout.Infinite, tempCts.Token));
    tempCts.Cancel(); // отменяет таймер и даёт собрать задачу
}

Однако, судя по Timeout.Infinite, я вижу что эта задача является "вечной". Тут можно обойтись вовсе без Task.Delay:

var tcs = new TaskCompletionSource<object>();
using (cancellationToken.Register(tcs.SetCanceled))
     await Task.WhenAny(readingTask, tcs.Task);

Отмечу, что using тут обязателен, иначе ссылка на tcs будет удерживаться токеном отмены.


PS третий способ сделать то, что вы добиваетесь - вот такой:

await readingTask.ContinueWith(_ => {}, cancellationToken);

Здесь ContinueWith тоже создаёт временную задачу, ссылка на которую удерживается пока она не будет завершена или отменена (однако, в отличии от варианта с Task.Delay, она завершается сразу после завершения readingTask).

→ Ссылка