Поддержание жизнеспособности асинхронных методов

Работа по принципу 'запустил и забыл' легко начать и легко упустить. При запуске асинхронной операции и удалении возвращаемого объекта Taskвы теряете видимость завершения, отмены и сбоев.

Большинство ошибок времени существования в асинхронном коде — это ошибки владения, а не ошибки компилятора. Конечный автомат async и его Task остаются активными, пока работа остается доступной через продолжения. Проблемы возникают, когда приложение больше не отслеживает работу.

Почему одноразовое действие вызывает ошибки срока существования

При запуске фоновой работы, не отслеживая ее, вы создаете три риска:

  • Операция может завершиться ошибкой, и никто не наблюдает исключение.
  • Процесс или хост могут завершить выполнение до окончания операции.
  • Операция может существовать дольше объекта или области, предназначенной для её контроля.

Используйте fire-and-forget только в тех случаях, когда выполняемая работа действительно необязательна, и сбой является приемлемой возможностью.

Явное отслеживание фоновой деятельности

В этом примере определяется BackgroundTaskTracker, пользовательский вспомогательный класс, содержащий потокобезопасный словарь задач в процессе выполнения. При вызове Track он регистрирует ContinueWith продолжение задачи, которая удаляет задачу из словаря после завершения и логирует любые сбои. При вызове DrainAsync и Task.WhenAll, он вызывает каждую задачу, которая еще находится в словаре, и возвращает итоговую задачу.

public sealed class BackgroundTaskTracker
{
    private readonly ConcurrentDictionary<int, Task> _inFlight = new();

    public void Track(Task operationTask, string name)
    {
        int id = operationTask.Id;
        _inFlight[id] = operationTask;

        _ = operationTask.ContinueWith(completedTask =>
        {
            _inFlight.TryRemove(id, out _);

            if (completedTask.IsFaulted)
            {
                Console.WriteLine($"{name} failed: {completedTask.Exception?.GetBaseException().Message}");
            }
        }, TaskScheduler.Default);
    }

    public Task DrainAsync()
    {
        Task[] snapshot = _inFlight.Values.ToArray();
        return snapshot.Length == 0 ? Task.CompletedTask : Task.WhenAll(snapshot);
    }
}
Public NotInheritable Class BackgroundTaskTracker
    Private ReadOnly _inFlight As New ConcurrentDictionary(Of Integer, Task)()

    Public Sub Track(operationTask As Task, name As String)
        Dim id As Integer = operationTask.Id
        _inFlight(id) = operationTask

        Dim continuationTask As Task = operationTask.ContinueWith(Sub(completedTask)
                                                                      Dim removedTask As Task = Nothing
                                                                      _inFlight.TryRemove(id, removedTask)

                                                                      If completedTask.IsFaulted Then
                                                                          Console.WriteLine($"{name} failed: {completedTask.Exception.GetBaseException().Message}")
                                                                      End If
                                                                  End Sub,
                                                                  TaskScheduler.Default)
    End Sub

    Public Function DrainAsync() As Task
        Dim snapshot As Task() = _inFlight.Values.ToArray()

        If snapshot.Length = 0 Then
            Return Task.CompletedTask
        End If

        Return Task.WhenAll(snapshot)
    End Function
End Class

В следующем примере используется BackgroundTaskTracker для запуска, наблюдения и очистки фоновой операции:

public static class FireAndForgetFix
{
    public static async Task RunAsync(BackgroundTaskTracker tracker)
    {
        Task backgroundTask = Task.Run(async () =>
        {
            await Task.Delay(100);
            throw new InvalidOperationException("Background operation failed.");
        });

        tracker.Track(backgroundTask, "Cache refresh");

        try
        {
            await tracker.DrainAsync();
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Drain observed failure: {ex.GetBaseException().Message}");
        }
    }
}
Public Module FireAndForgetFix
    Public Async Function RunAsync(tracker As BackgroundTaskTracker) As Task
        Dim backgroundTask As Task = Task.Run(Async Function()
                                                  Await Task.Delay(100)
                                                  Throw New InvalidOperationException("Background operation failed.")
                                              End Function)

        tracker.Track(backgroundTask, "Cache refresh")

        Try
            Await tracker.DrainAsync()
        Catch ex As Exception
            Console.WriteLine($"Drain observed failure: {ex.GetBaseException().Message}")
        End Try
    End Function
End Module

Вы можете спросить: если DrainAsync только ждет выполнения той задачи, которую вы начали, почему бы не использовать await backgroundTask напрямую и полностью исключить трекер? Для одной задачи в одном методе можно. Средство отслеживания становится ценным при запуске задач из многих разных мест в течение всего времени существования компонента. Каждый вызывающий абонент передает свою задачу общему трекировщику, и один DrainAsync звонок при завершении работы ожидает всех из них, не зная, сколько было запущено или кто их начал. Средство отслеживания также применяет согласованную политику наблюдения за исключениями: каждая зарегистрированная задача получает одинаковую обработку записей о сбоях, поэтому никакое исключение не может остаться незамеченным, независимо от того, какой путь кода инициировал выполнение задачи.

Ниже перечислены три основных компонента отслеживаемого шаблона:

  • Назначьте задачу переменной — хранение ссылки backgroundTask позволяет отслеживать. Задача, на которую вы не можете ссылаться, — это задача, которую вы не можете слить или наблюдать.
  • Зарегистрируйтесь в трекереtracker.Track прикрепляет продолжение ведения журнала ошибок и добавляет задачу в набор выполняемых задач. Любое исключение фоновой работы становится явным через это продолжение, а не исчезает бесшумно.
  • Слив при завершении работыtracker.DrainAsync ожидает завершения всего, что все еще работает. Вызовите его перед выходом компонента или процесса, чтобы гарантировать, что работа в полете не прекращена в середине полета.

Последствия неконтролируемых систем "выстрелил и забыл"

Если вы откажетесь от возвращаемого Task объекта вместо отслеживания, создадите незаметный сбой.

public static class FireAndForgetPitfall
{
    public static async Task RunAsync()
    {
        _ = Task.Run(async () =>
        {
            await Task.Delay(100);
            throw new InvalidOperationException("Background operation failed.");
        });

        await Task.Delay(150);
        Console.WriteLine("Caller finished without observing background completion.");
    }
}
Public Module FireAndForgetPitfall
    Public Async Function RunAsync() As Task
        Dim discardedTask As Task = Task.Run(Async Function()
                                                 Await Task.Delay(100)
                                                 Throw New InvalidOperationException("Background operation failed.")
                                             End Function)

        Await Task.Delay(150)
        Console.WriteLine("Caller finished without observing background completion.")
    End Function
End Module

Три проблемы следуют за удалением задачи:

  • Тихие исключенияInvalidOperationException из фоновой операции никогда не наблюдаются. Среда выполнения направляет её UnobservedTaskException при завершении, что является неопределённым и происходит слишком поздно, чтобы можно было обработать корректно.
  • Отсутствие координации завершения работы — вызывающий продолжает и выходит, не ожидая завершения операции. В кратковременном процессе или узле с временем ожидания завершения работы фоновая работа отменяется или теряется полностью.
  • Нет видимости — без ссылки на задачу невозможно определить, выполнена ли операция успешно, не выполнена или по-прежнему запущена.

Технология "запустил и забыл" без обратной связи приемлема только в том случае, если выполняются все три следующих условия: работа действительно необязательна, сбой можно безопасно игнорировать, и операция завершается хорошо до завершения любого ожидаемого срока существования процесса. Запись некритического телеметрического пинга — это пример, где все эти условия выполняются.

Убедитесь, что право владения указано явно

Используйте одну из следующих моделей владения:

  • Верните Task и требуйте от вызывающих его, чтобы они ожидали результатов.
  • Отслеживание фоновых задач в выделенной службе владельца.
  • Используйте управляемую хостом фоновую абстракцию, чтобы хост управлял временем жизни.

Если работа должна продолжаться после возврата вызывающего абонента, явно передайте владение. Например, передайте задачу в средство отслеживания, которое регистрирует ошибки и участвует в завершении работы.

Визуализация исключений из фоновых задач

Опущенные задачи могут незаметно провалиться до завершения и обработки необнаруженного исключения. Это время недетерминировано и слишком поздно для обычной обработки запросов или рабочих процессов.

Прикрепите логику наблюдения, когда вы ставите фоновую работу в очередь. По крайней мере, регистрируйте ошибки в процессе. Рекомендуется предпочесть централизованный центр отслеживания, чтобы каждая операция в очереди следовала одной и той же политике.

Сведения о распространении исключений см. в разделе "Обработка исключений задач".

Согласование отмены и завершения работы

Привяжите фоновую работу к маркеру отмены, представляющему время существования приложения или операции. Во время завершения работы:

  1. Перестать принимать новую работу.
  2. Отмена сигнала.
  3. Ожидание отслеживаемых задач с ограниченным временем ожидания.
  4. Журнал неполных операций.

Этот поток обеспечивает прогнозируемость завершения работы и предотвращает частичные операции записи или потерянные операции.

Может ли GC собрать асинхронный метод до завершения работы?

Среда выполнения поддерживает асинхронную машину состояний в активном состоянии, пока на нее ссылаются продолжения выполнения. Обычно асинхронная операция, выполняющаяся в данный момент, не теряется из-за сборки мусора, воздействующей на сам автомат состояний.

Вы по-прежнему можете потерять корректность выполнения, если вы потеряете контроль над возвращённой задачей, освободите необходимые ресурсы слишком рано или позволите процессу завершиться раньше времени. Сосредоточьтесь на владении задачами и согласованном завершении работы.

См. также