Hålla asynkrona metoder vid liv

"Ett arbete av typen 'starta och glöm' är lätt att påbörja och lätt att glömma bort." Om du startar en asynkron åtgärd och släpper den returnerade Taskförlorar du insyn i slutförande, annullering och fel.

De flesta livsbuggar i asynkron kod är ägarfel, inte kompilatorbuggar. Tillståndsmaskinen async och dess Task förblir aktiva så länge arbetet fortfarande är tillgängligt genom fortsättningar. Problem uppstår när appen inte längre spårar det arbetet.

Varför eld och glöm orsakar livstidsbuggar

När du startar bakgrundsarbete utan att spåra det skapar du tre risker:

  • Åtgärden kan misslyckas och ingen observerar undantaget.
  • Processen eller värddatorn kan stängas av innan operationen är klar.
  • Åtgärden kan överleva det objekt eller omfång som var avsett att styra det.

Använd endast "eld och glöm"-läget när arbetet verkligen är valfritt och det är acceptabelt att misslyckas.

Spåra bakgrundsarbete explicit.

Det här exemplet definierar BackgroundTaskTracker, en anpassad hjälpklass som innehåller en trådsäker ordlista med uppgifter under flygning. När du anropar Trackregistreras en ContinueWith fortsättning på uppgiften som tar bort aktiviteten från ordlistan när den är klar och loggar eventuella fel. När du anropar DrainAsync, anropas Task.WhenAll på varje uppgift som fortfarande finns med i ordlistan och den resulterande uppgiften returneras.

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

I följande exempel används BackgroundTaskTracker för att starta, observera och tömma en bakgrundsåtgärd:

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

Du kanske frågar: om DrainAsync bara väntar på den enda uppgift du startade, varför inte await backgroundTask direkt och hoppa över spåraren helt? För en enskild uppgift inom en enda metod kan du göra så. Spåraren blir värdefull när uppgifter startas från många olika platser under en komponents livslängd. Varje uppringare ger sin uppgift till den delade spåraren, och ett enda DrainAsync samtal vid avstängning väntar på dem alla utan att veta hur många som startades eller vem som startade dem. Spåraren tillämpar också en konsekvent princip för undantagsövervakning: varje registrerad uppgift får samma fortsättning på felloggning, så inget undantag kan glida igenom obemärkt oavsett vilken kodsökväg som startade arbetet.

De tre viktigaste komponenterna i det spårade mönstret är:

  • Tilldela uppgiften till en variabel – att hålla en referens till backgroundTask är det som gör spårning möjlig. En uppgift som du inte kan referera till är en uppgift som du inte kan avsluta eller observera.
  • Registrera dig med spårarentracker.Track bifogar fortsättningen för felloggning och lägger till uppgiften i uppsättningen under flygning. Alla undantag som bakgrundsarbetet kastar hanteras genom den fortsättningen hellre än att försvinna tyst.
  • Töm vid avstängningtracker.DrainAsync väntar på allt som fortfarande körs. Anropa den innan komponenten eller processen avslutas för att garantera att inget arbete under flygning avbryts mitt under flygning.

Konsekvenser av ospårat "fire-and-forget"

Om du ignorerar returen Task i stället för att spåra den skapar du en tyst felaktighet:

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

Tre problem uppstår från att släppa uppgiften:

  • Tysta undantagInvalidOperationException från bakgrundsåtgärden observeras aldrig. Körmiljön dirigerar den till UnobservedTaskException vid slutförandet, vilket är icke-deterministiskt och alldeles för sent för att hanteras smidigt.
  • Ingen avstängningssamordning – anroparen fortsätter och avslutas utan att vänta på att åtgärden ska slutföras. Vid en kortvarig process eller en värd med en tidsgräns för avstängning avbryts eller förloras bakgrundsarbetet helt.
  • Ingen synlighet – utan en referens till uppgiften kan du inte avgöra om åtgärden lyckades, misslyckades eller fortfarande körs.

Ospårad eld och glöm är bara acceptabelt när alla tre av följande villkor gäller: arbetet är verkligen valfritt, fel är säkert att ignorera och åtgärden slutförs väl inom en förväntad processlivslängd. Att logga en icke-kritisk telemetripuls är ett exempel där alla dessa villkor kan gälla.

Behåll ägarskapet explicit

Använd någon av dessa ägarskapsmodeller:

  • Återlämna Task och kräv att anropare väntar på det.
  • Spåra bakgrundsprocesser i en dedikerad ägartjänst.
  • Använd en värdhanterad bakgrundsabstraktion så att värden äger livslängden.

Om arbetet måste fortsätta efter att anroparen har returnerat överför du ägarskapet explicit. Tilldela till exempel uppgiften till ett spårningsverktyg som loggar fel och är delaktig vid avstängningen.

Surface undantag från bakgrundsaktiviteter

Förlorade uppgifter kan misslyckas tyst tills slutförande och obemärkt undantagshantering inträffar. Den tidpunkten är icke-deterministisk och för sent för normal hantering av begäranden eller arbetsflöden.

Lägg till observationslogik när du köar bakgrundsarbete. Logga åtminstone fel i en fortsättningsprocess. Föredra ett centraliserat spårningsverktyg så att varje köad åtgärd följer samma policy.

Information om undantagsspridning finns i Hantering av task-undantag.

Samordna annullering och avstängning

Koppla bakgrundsarbete till en annulleringstoken som representerar appens eller åtgärdens livslängd. Under avstängning:

  1. Sluta acceptera nytt arbete.
  2. Signaldämpning.
  3. Vänta på spårade uppgifter med en begränsad tidsgräns.
  4. Logga ofullständiga åtgärder.

Det här flödet håller avstängningen förutsägbar och förhindrar partiella skrivningar eller överblivna åtgärder.

Kan GC samla in en asynkron metod innan den är klar?

Körningstiden håller den asynkrona tillståndsmaskinen vid liv medan fortsättningarna fortfarande refererar till den. Du förlorar vanligtvis inte en asynkron åtgärd under flygning till skräpinsamling av själva tillståndsdatorn.

Du kan fortfarande förlora korrekthet om du förlorar ägarskapet för den returnerade uppgiften, tar bort nödvändiga resurser tidigt eller låter processen avslutas innan den slutförs. Fokusera på aktivitetsägarskap och samordnad avstängning.

Se även