Utrzymywanie aktywnych metod asynchronicznych

Praca typu „uruchom i zapomnij” jest łatwa do rozpoczęcia i łatwo się ją traci. Jeśli uruchomisz operację asynchroniczną i pominiesz zwrócony Task, utracisz możliwość monitorowania zakończenia, anulowania i wystąpienia błędów.

Większość usterek okresu istnienia kodu asynchronicznego to błędy własności, a nie błędy kompilatora. Maszyna stanowa async i jej Task pozostają aktywne, podczas gdy zadania są nadal dostępne poprzez kontynuacje. Problemy występują, gdy aplikacja nie śledzi już tej pracy.

Dlaczego koncepcja fire-and-forget powoduje błędy dotyczące cyklu życia

Kiedy rozpoczynasz pracę w tle bez śledzenia, stwarzasz trzy czynniki ryzyka:

  • Operacja może zakończyć się niepowodzeniem i nikt nie obserwuje wyjątku.
  • Proces lub host może zostać zamknięty przed zakończeniem operacji.
  • Operacja może trwać dłużej niż obiekt lub zakres, które miały ją kontrolować.

Używaj metody "fire-and-forget" tylko wtedy, gdy zadanie jest rzeczywiście opcjonalne, a niepowodzenie jest akceptowalne.

Jawne śledzenie pracy w tle

W tym przykładzie zdefiniowano BackgroundTaskTrackerniestandardową klasę pomocnika, która zawiera bezpieczny wątkowo słownik zadań w locie. Po wywołaniu Track, program zarejestrowuje kontynuację ContinueWith zadania, aby usunąć zadanie ze słownika po jego zakończeniu i zapisać ewentualne błędy. Wywołanie DrainAsync powoduje wywołanie Task.WhenAll na każdym zadaniu w słowniku i zwraca wynikowe zadanie.

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

W poniższym przykładzie użyto BackgroundTaskTracker polecenia, aby uruchomić, obserwować i opróżniać operację w tle.

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

Możesz zapytać: skoro DrainAsync po prostu czeka na zadanie, które rozpocząłeś, dlaczego nie użyć await backgroundTask bezpośrednio i całkowicie pominąć tracker? W przypadku jednego zadania w jednej metodzie możesz to zrobić. Tracker staje się cenny, gdy zadania są uruchamiane z wielu różnych miejsc w całym okresie istnienia składnika. Każdy obiekt wywołujący przekazuje swoje zadanie do udostępnionego trackera, a jedno DrainAsync wywołanie przy zamykaniu czeka na wszystkich bez wiedzy, ile zostało uruchomionych lub kto je uruchomił. Tracker wymusza również spójne zasady obserwacji wyjątków: każde zarejestrowane zadanie otrzymuje tę samą kontynuację rejestrowania błędów, więc żaden wyjątek nie może przechodzić niezauważone, niezależnie od tego, która ścieżka kodu rozpoczęła pracę.

Trzy kluczowe składniki śledzonego wzorca to:

  • Przypisz zadanie do zmiennej — to odwołanie do backgroundTask umożliwia śledzenie. Zadanie, do którego nie możesz się odnieść, to zadanie, którego nie możesz opróżnić ani obserwować.
  • Zarejestruj się za pomocą trackeratracker.Track dołącza kontynuację rejestrowania błędów i dodaje zadanie do zestawu w locie. Każdy wyjątek, który praca w tle rzuca, pojawia się w tej kontynuacji, zamiast znikać po cichu.
  • Opróżnianie przy zamykaniutracker.DrainAsync czeka na wszystko, co nadal działa. Wywołaj go przed zakończeniem działania komponentu lub procesu, aby mieć pewność, że żadne aktualnie wykonywane zadanie nie zostanie przerwane.

Konsekwencje nieśledzonego systemu typu "fire-and-forget"

Jeśli odrzucisz zwróconą wartość Task zamiast ją śledzić, utworzysz cichą awarię:

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

Trzy problemy wynikają z porzucania zadania:

  • Ciche wyjątkiInvalidOperationException wynik operacji w tle nigdy nie jest zauważony. Środowisko uruchomieniowe przekierowuje to do UnobservedTaskException podczas finalizacji, która jest niedeterministyczna i zdecydowanie zbyt późna, aby obsłużyć to w sposób łagodny.
  • Brak koordynacji zamknięcia — wywołujący kontynuuje działanie i kończy je bez oczekiwania na zakończenie operacji. W przypadku krótkotrwałego procesu lub hosta z przekroczeniem limitu czasu zamknięcia praca w tle jest anulowana lub całkowicie utracona.
  • Brak widoczności — bez odwołania do zadania nie można określić, czy operacja zakończyła się pomyślnie, nie powiodła się, czy nadal jest uruchomiona.

Nieśledzony tryb fire-and-forget jest akceptowalny tylko wtedy, gdy wszystkie trzy z następujących warunków są spełnione: praca jest naprawdę opcjonalna, niepowodzenie można bezpiecznie zignorować, a operacja zakończy się pomyślnie w jakimkolwiek oczekiwanym czasie trwania procesu. Rejestrowanie niekrytycznego ping telemetrii jest jednym z przykładów, kiedy wszystkie te warunki mogą być spełnione.

Zachowaj jawność własności

Użyj jednego z następujących modeli własności:

  • Zwróć Task i wymagaj od wywołujących, aby na niego czekali.
  • Śledź zadania w tle za pomocą dedykowanej usługi właściciela.
  • Użyj abstrakcji w tle zarządzanej przez hosta, aby host był właścicielem okresu istnienia.

Jeśli praca musi być kontynuowana po powrocie wywołującego, jawnie przenieś własność. Na przykład przekaż zadanie do narzędzia śledzącego, które rejestruje błędy i uczestniczy w zakończeniu działania.

Ujawnij wyjątki z zadań w tle

Porzucone zadania mogą zakończyć się niepowodzeniem w trybie dyskretnym, dopóki nie wystąpi finalizacja i nieobserwowana obsługa wyjątków. Ten czas nie jest deterministyczny i za późno w przypadku normalnego żądania lub obsługi przepływu pracy.

Dołącz logikę obserwacji podczas kolejkowania pracy w tle. Co najmniej rejestruj niepowodzenia w trakcie kontynuacji. Preferowany jest scentralizowany tracker, aby każda operacja w kolejce stosowała te same zasady.

Aby uzyskać szczegółowe informacje na temat propagacji wyjątków zadania, zobacz sekcję Obsługa wyjątków zadania.

Anulowanie i zamykanie współrzędnych

Powiąż pracę w tle z tokenem anulującym, który reprezentuje okres istnienia aplikacji lub operacji. Podczas zamykania:

  1. Przestań akceptować nową pracę.
  2. Anulowanie sygnału.
  3. Oczekuj na śledzone zadania z określonym limitem czasu.
  4. Rejestrowanie nieukończonych operacji.

Ten proces zapewnia przewidywalne wyłączenie i zapobiega częściowym zapisom lub porzuconym operacjom.

Czy GC może zebrać asynchroniczną metodę przed jej zakończeniem?

Środowisko uruchomieniowe zachowuje żywą maszynę stanu asynchronicznego, podczas gdy kontynuacje nadal się do niego odwołują. Zwykle nie tracisz operacji asynchronicznych w locie w celu odzyskiwania pamięci samej maszyny stanu.

Nadal można utracić poprawność, jeśli utracisz własność zwróconego zadania, pozbądź się wymaganych zasobów wcześniej, lub jeśli pozwolisz procesowi zakończyć się przed ukończeniem. Skoncentruj się na odpowiedzialności za zadania i skoordynowanym wyłączaniu.

Zobacz także