Asynchrone Methoden lebendig halten

„Fire-and-Forget“-Arbeiten sind einfach zu starten und leicht zu verlieren. Wenn Sie einen asynchronen Vorgang starten und die zurückgegebene Task ignorieren, verlieren Sie die Sichtbarkeit hinsichtlich Abschluss, Abbruch und Fehler.

Die meisten Lebensdauerfehler in asynchronem Code sind Besitzerfehler, nicht Compilerfehler. Der Zustandsautomat async und seine Task bleiben aktiv, solange die Arbeit über Fortsetzungen noch erreichbar ist. Probleme treten auf, wenn Ihre App diese Arbeit nicht mehr nachverfolgt.

Warum „Fire-and-Forget“ zu Lifetime-Bugs führt

Wenn Sie mit der Hintergrundarbeit beginnen, ohne sie zu verfolgen, erstellen Sie drei Risiken:

  • Der Vorgang kann fehlschlagen, und niemand beobachtet die Ausnahme.
  • Der Prozess oder Host kann heruntergefahren werden, bevor der Vorgang abgeschlossen ist.
  • Der Vorgang kann das Objekt oder den Bereich überleben, die ihn steuern sollten.

Verwenden Sie „Fire-and-Forget“ nur, wenn die Arbeit wirklich optional ist und Fehler akzeptabel sind.

Explizites Nachverfolgen von Hintergrundaufgaben

In diesem Beispiel wird eine benutzerdefinierte Hilfsklasse definiert BackgroundTaskTracker, die ein threadsicheres Wörterbuch von In-Flight-Aufgaben enthält. Wenn Sie aufrufen Track, registriert es eine ContinueWith Fortsetzung für die Aufgabe, die die Aufgabe aus dem Wörterbuch entfernt, wenn sie abgeschlossen ist, und protokolliert einen Fehler. Wenn Sie DrainAsync aufrufen, wird Task.WhenAll für jede Aufgabe im Wörterbuch aufgerufen und die resultierende Aufgabe zurückgegeben.

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

Im folgenden Beispiel wird BackgroundTaskTracker verwendet, um einen Hintergrundvorgang zu starten, zu beobachten und abzuschließen.

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

Falls DrainAsync nur auf die von Ihnen gestartete Aufgabe wartet, warum dann nicht direkt await backgroundTask und den Tracker vollständig überspringen? Bei einer einzigen Aufgabe in einer einzelnen Methode wäre dies möglich. Der Tracker wird nützlich, wenn Aufgaben von vielen verschiedenen Stellen innerhalb der Lebensdauer einer Komponente gestartet werden. Jede Aufrufer übergibt seine Aufgabe an den freigegebenen Tracker, und ein einzelner DrainAsync-Aufruf beim Herunterfahren wartet auf alle, ohne dass bekannt ist, wie viele gestartet wurden oder wer sie gestartet hat. Der Tracker erzwingt auch eine konsistente Ausnahmebeobachtungsrichtlinie: Jede registrierte Aufgabe erhält die gleiche Fortsetzung der Fehlerprotokollierung, sodass keine Ausnahme unabhängig davon, welcher Codepfad die Arbeit gestartet hat, unbemerkt durchlaufen kann.

Die drei wichtigsten Komponenten des nachverfolgten Musters sind:

  • Zuweisen der Aufgabe zu einer Variablen – der Erhalt eines Verweises auf backgroundTask ermöglicht die Verfolgung. Eine Aufgabe, auf die Sie nicht verweisen können, ist eine Aufgabe, die Sie nicht leeren oder überwachen können.
  • Beim Tracker registrieren – tracker.Track fügt die Fortsetzung der Fehlerprotokollierung an und fügt die Aufgabe dem In-Flight-Satz hinzu. Jede Ausnahme, die die Hintergrundarbeit auslöst, tritt durch diese Fortsetzung zutage, anstatt im Hintergrund unbemerkt zu bleiben.
  • Beim Herunterfahren leeren – tracker.DrainAsync wartet auf alles, was noch ausgeführt wird. Rufen Sie dies auf, bevor Ihre Komponente oder Ihr Prozess beendet wird, um sicherzustellen, dass keine laufende Arbeit unvollständig bleibt.

Auswirkungen nicht verfolgter „Fire-and-Forget“-Aktionen

Wenn Sie die zurückgegebene Task verwerfen, anstatt sie zu verfolgen, kommt es zu einem Fehler ohne Fehlermeldung:

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

Es folgen drei Probleme beim Ablegen der Aufgabe:

  • Unbemerkte Ausnahmen – die InvalidOperationException des Hintergrundvorgangs wird nie festgestellt. Die Laufzeit leitet sie bei Abschluss an UnobservedTaskException weiter, was nicht deterministisch und viel zu spät ist, um sie auf angemessene Weise zu handhaben.
  • Keine Koordination beim Herunterfahren – der Aufrufer wird weiter ausgeführt und beendet, ohne auf den Abschluss des Vorgangs zu warten. Bei einem kurzlebigen Prozess oder einem Host mit einem Timeout zum Herunterfahren wird der Hintergrundvorgang abgebrochen oder geht vollständig verloren.
  • Keine Sichtbarkeit – ohne Einen Verweis auf die Aufgabe können Sie nicht ermitteln, ob der Vorgang erfolgreich war, fehlgeschlagen ist oder noch ausgeführt wird.

Nicht verfolgte „Fire-and-Forget“-Aktionen sind nur zulässig, wenn alle drei der folgenden Bedingungen zutreffen: Die Aufgabe ist wirklich optional, ein Fehlschlag kann ohne Bedenken ignoriert werden und der Vorgang wird deutlich innerhalb der erwarteten Prozesslaufzeit abgeschlossen. Das Protokollieren eines nicht kritischen Telemetriepings ist ein Beispiel, bei dem alle diese Bedingungen erfüllt sein können.

Explizites Beibehalten des Besitzes

Verwenden Sie eines der folgenden Besitzmodelle:

  • Geben Sie das Task zurück und erfordern Sie, dass Aufrufer darauf warten.
  • Verfolgen Sie Hintergrundaufgaben in einem dedizierten Eigentümerdienst.
  • Verwenden Sie eine vom Host verwaltete Hintergrundabstraktion, sodass der Host die Lebensdauer besitzt.

Wenn die Arbeit nach der Rückkehr des Anrufers fortgesetzt werden muss, übertragen Sie den Besitz explizit. Beauftragen Sie beispielsweise einen Tracker, der Fehler protokolliert und am Systemherunterfahren beteiligt ist.

Ausnahmen aus Hintergrundaufgaben anzeigen

Verworfene Aufgaben können unbemerkt fehlschlagen, bis der Abschluss erfolgt und die Behandlung unüberwachter Ausnahmen durchgeführt wird. Der Zeitpunkt ist unvorhersehbar und zu spät für die normale Bearbeitung von Anforderungen oder Workflows.

Fügen Sie Beobachtungslogik an, wenn Sie Hintergrundarbeiten in die Warteschlange stellen. Protokollieren Sie Fehler zumindest in einer Fortsetzung. Verwenden Sie vorzugsweise einen zentralen Tracker, damit jeder in die Warteschlange gestellte Vorgang dieselbe Richtlinie erhält.

Details zur Ausbreitung von Ausnahmen finden Sie unter Aufgabenausnahmebehandlung.

Abbruch und Herunterfahren koordinieren

Binden Sie Hintergrundarbeit an ein Abbruchtoken, das die Lebensdauer der App oder des Vorgangs darstellt. Während des Abschaltens:

  1. Beenden Sie die Annahme neuer Arbeiten.
  2. Signalauslöschung.
  3. Warten Sie auf verfolgte Aufgaben mit einem begrenzten Timeout.
  4. Protokolliert unvollständige Vorgänge.

Dieser Ablauf sorgt dafür, dass das Herunterfahren vorhersagbar ist, und verhindert partielle Schreibvorgänge oder verwaiste Vorgänge.

Kann GC eine asynchrone Methode vor Abschluss erfassen?

Die Laufzeit hält den asynchronen Zustandsautomaten aktiv, während Fortsetzungen weiterhin darauf verweisen. Normalerweise verlieren Sie keinen asynchronen In-Flight-Vorgang an die Garbage Collection des Zustandsautomaten selbst.

Sie können weiterhin die Richtigkeit verlieren, wenn Sie den Besitz des zurückgegebenen Vorgangs verlieren, erforderliche Ressourcen frühzeitig löschen oder den Prozess vor Abschluss beenden lassen. Konzentrieren Sie sich auf Aufgabeneigentum und koordiniertes Abschalten.

Siehe auch