Udržet asynchronní metody v chodu

Práce typu "zadání a zapomenutí" je snadná začít a snadno ji opomenout. Pokud spustíte asynchronní operaci a nevyužijete vrácenou hodnotu Task, přijdete o možnost sledovat dokončení, zrušení a selhání.

Většina chyb životnosti v asynchronním kódu představuje chyby vlastnictví, nikoli chyby kompilátoru. Stavový async automat a jeho Task zůstávají naživu, zatímco práce je stále dosažitelná prostřednictvím pokračování. K problémům dochází, když vaše aplikace už danou práci nesleduje.

Proč metoda fire-and-forget způsobuje chyby související s životností

Když spustíte práci na pozadí bez sledování, vytvoříte tři rizika:

  • Operace může selhat a nikdo nepozoruje výjimku.
  • Proces nebo hostitel se může vypnout dříve, než se operace dokončí.
  • Operace může překonat časové omezení objektu nebo rozsahu, který ji měl řídit.

Používejte princip "vystřel a zapomeň" pouze tehdy, je-li práce skutečně volitelná a selhání je přijatelné.

Explicitní sledování práce na pozadí

Tato ukázka definuje vlastní pomocnou třídu BackgroundTaskTracker, která obsahuje slovník úkolů, jež jsou bezpečně řízeny pomocí vláken. Při volání Track zaregistruje ContinueWith pokračovací akci na úkolu, která po dokončení odebere úlohu ze slovníku a zaloguje případná selhání. Při volání DrainAsync se volá Task.WhenAll na každý úkol, který je ještě ve slovníku, a vrátí tak výsledný úkol.

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

Následující příklad používá BackgroundTaskTracker ke spuštění, sledování a ukončení procesu na pozadí:

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

Můžete se zeptat: Pokud DrainAsync jen čeká na jeden úkol, který jste zahájili, proč ne await backgroundTask přímo a zcela ignorovat tracker? Pro jeden úkol v jedné metodě můžete. Sledování se stává cenným, když jsou úkoly zahájeny z mnoha různých míst v průběhu životnosti komponenty. Každý volající předá svůj úkol sdílenému sledovači, a jedna DrainAsync výzva při vypnutí čeká na všechny, bez ohledu na to, kolik bylo spuštěno, nebo protože není známo, kdo je spustil. Sledování také vynucuje konzistentní zásady pozorování výjimek: každý zaregistrovaný úkol získá stejné pokračování protokolu o selhání, takže žádná výjimka nemůže uniknout bez povšimnutí, bez ohledu na to, která cesta kódu zahájila úkol.

Mezi tři klíčové součásti sledovaného vzoru patří:

  • Přiřaďte úkol proměnné – udržování odkazu na backgroundTask umožňuje sledování. Úkol, na který nemůžete odkazovat, je úkol, který nemůžete dokončit nebo pozorovat.
  • Zaregistrujte se u sledovacího systémutracker.Track připojuje pokračování záznamu selhání a přidává úlohu do sady probíhajících úloh. Jakákoli výjimka, kterou práce na pozadí vyhodí, je zachycena pokračováním, spíše než aby zmizela potichu.
  • Vyprázdnění při vypnutítracker.DrainAsync čeká na všechny stále aktivní procesy. Zavolejte ho před ukončením komponenty nebo procesu, abyste zajistili, že v polovině letu nebude zrušena žádná práce v letu.

Důsledky nesledovaného požáru a zapomenutí

Pokud vrácenou hodnotu Task zahodíte místo sledování, vytvoříte tiché selhání:

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

Tři problémy následují od vyřazení úkolu:

  • Tiché výjimky – operace InvalidOperationException na pozadí není nikdy zaznamenána. Modul běhu ho směruje na UnobservedTaskException při dokončení, což je nedeterministické a příliš pozdě, aby to bylo možné elegantně zpracovat.
  • Žádná koordinace vypnutí – volající pokračuje bez čekání na dokončení operace a ukončí činnost. V krátkodobém procesu nebo na hostiteli, kde vyprší úroveň vypnutí, se práce na pozadí zruší nebo zcela zmizí.
  • Žádná viditelnost – bez odkazu na úlohu nemůžete určit, jestli operace proběhla úspěšně, selhala nebo je stále spuštěná.

Neřízené operace typu fire-and-forget jsou přijatelné pouze tehdy, když platí všechny tři z následujících podmínek: práce je opravdu volitelná, na selhání lze bezpečně zapomenout, a operace se dokončí dobře během očekávané životnosti procesu. Protokolování nekritického telemetrického pingu je jedním z příkladů, kdy všechny tyto podmínky můžou platit.

Zachování explicitního vlastnictví

Použijte jeden z těchto modelů vlastnictví:

  • Task Vraťte a vyžadujte, aby jej volající čekali.
  • Sledování úloh na pozadí ve službě vyhrazeného vlastníka
  • Použijte abstrakci na pozadí spravovanou hostitelem, aby hostitel řídil životnost procesu.

Pokud musí práce pokračovat po vrácení volajícího, přeneste vlastnictví explicitně. Úkol můžete například předat nástroji pro sledování, který protokoluje chyby a podílí se na procesu vypnutí.

Zobrazit výjimky z úloh na pozadí

Vyřazené úlohy mohou tiše selhat, dokud nedojde k dokončení a zpracování nezachycených výjimek. Toto načasování není deterministické a příliš pozdě pro běžné zpracování požadavků nebo pracovních postupů.

Připojte logiku pozorování při zařazování práce do fronty na pozadí. Minimálně zaznamenávejte selhání při pokračování procesu. Preferujte centralizovaný tracker, aby každá operace ve frontě získala stejnou politiku.

Podrobnosti o šíření výjimek naleznete v tématu Zpracování výjimek úlohy.

Koordinace zrušení a vypnutí

Spojte práci na pozadí s tokenem zrušení, který představuje životnost aplikace nebo operace. Během vypnutí:

  1. Přestaňte přijímat novou práci.
  2. Zrušení signálu.
  3. Čekejte na sledované úkoly s ohraničeným časovým limitem.
  4. Protokolovat neúplné operace

Tento tok udržuje předvídatelné vypnutí a zabraňuje částečným zápisům nebo osiřelým operacím.

Může GC shromáždit asynchronní metodu před dokončením?

Modul runtime udržuje asynchronní stavový počítač naživu, zatímco pokračování na něj stále odkazují. K uvolnění paměti samotného stavového počítače obvykle nepřijdete o asynchronní operaci v letu.

Správnost můžete i nadále ztratit, pokud ztratíte vlastnictví vráceného úkolu, vyřaďte požadované prostředky včas nebo nechte proces před dokončením ukončit. Zaměřte se na zodpovědnost za úkoly a koordinované uzavírání.

Viz také