Conserver les méthodes asynchrones actives

Les tâches de type "tirer-et-oublier" sont faciles à lancer et facilement négligées. Si vous démarrez une opération asynchrone et supprimez le retour Task, vous perdez de la visibilité sur l’achèvement, l’annulation et les échecs.

La plupart des bogues de durée de vie dans le code asynchrone sont des bogues de propriété, et non des bogues du compilateur. La machine d'état async et son Task restent actifs tant que le travail est toujours accessible via des continuations. Des problèmes se produisent lorsque votre application ne suit plus ce travail.

Pourquoi le feu et l’oubli provoquent des bogues de durée de vie

Lorsque vous démarrez un travail en arrière-plan sans le suivre, vous créez trois risques :

  • L’opération peut échouer et personne n’observe l’exception.
  • Le processus ou l’hôte peut s’arrêter avant la fin de l’opération.
  • L'opération peut perdurer au-delà de l'objet ou du périmètre qui était censé la contrôler.

Utilisez le mode "lancer et oublier" uniquement lorsque le travail est vraiment facultatif et que l’échec soit acceptable.

Suivre les tâches en arrière-plan de manière explicite

Cet exemple définit BackgroundTaskTracker une classe d’assistance personnalisée qui contient un dictionnaire sûr pour les threads des tâches en cours d'exécution. Lorsque vous appelez Track, il enregistre une continuation ContinueWith sur la tâche, qui supprime la tâche du dictionnaire une fois terminée et consigne toute défaillance. Lorsque vous appelez DrainAsync, il appelle Task.WhenAll pour chaque tâche encore présente dans le dictionnaire et renvoie la tâche ou le résultat correspondant.

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

L’exemple suivant utilise BackgroundTaskTracker pour démarrer, observer et vider une opération en arrière-plan :

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

Vous pourriez vous demander : si DrainAsync ne se contente d'attendre que la tâche que vous avez démarrée, pourquoi ne pas await backgroundTask directement et ignorer entièrement le traqueur ? Pour une tâche unique dans une seule méthode, vous pourriez le faire. Le suivi devient précieux lorsque les tâches sont démarrées à partir de nombreux endroits différents dans la durée de vie d’un composant. Chaque appelant transmet sa tâche au suivi partagé, et un seul DrainAsync appel lors de l'arrêt attend qu'ils aient tous terminé sans connaître leur nombre ou l'identité de ceux qui les ont lancés. Le traqueur applique également une stratégie d'observation des exceptions cohérente : chaque tâche enregistrée reçoit la même continuation de journalisation des échecs, afin qu'aucune exception ne puisse passer inaperçue, quel que soit le chemin de code qui a initié le travail.

Les trois composants clés du modèle suivi sont les suivants :

  • Affectez la tâche à une variable : gardez une référence à backgroundTask ce qui rend le suivi possible. Une tâche à laquelle vous ne pouvez pas faire référence est une tâche que vous ne pouvez pas épuiser ou observer.
  • Inscrivez-vous auprès du traceur : tracker.Track attache la continuation de la journalisation des défaillances et ajoute la tâche à l'ensemble en cours. Toute exception que le travail en arrière-plan lève apparaît à travers la continuation plutôt que de disparaître silencieusement.
  • Vidage à l’arrêt : tracker.DrainAsync attend que tout soit en cours d’exécution. Appelez-le avant la sortie de votre composant ou processus pour garantir qu’aucun travail en cours d’exécution n’est abandonné à mi-vol.

Conséquences des systèmes « tir et oublie » non suivis

Si vous ignorez le retour Task au lieu de le suivre, vous créez un échec silencieux :

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

Trois problèmes suivent la suppression de la tâche :

  • Exceptions silencieuses : l’opération InvalidOperationException en arrière-plan n’est jamais observée. Le runtime l’achemine vers UnobservedTaskException lors de la finalisation, qui n’est pas déterministe et ce qui rend la gestion correcte impossible à ce stade.
  • Aucune coordination de l’arrêt : l’appelant continue et se ferme sans attendre la fin de l’opération. Sur un processus de courte durée ou un hôte avec un délai d’arrêt, le travail en arrière-plan est annulé ou perdu entièrement.
  • Aucune visibilité : sans référence à la tâche, vous ne pouvez pas déterminer si l’opération a réussi, échoué ou est toujours en cours d’exécution.

Le tir sans suivi est acceptable uniquement lorsque les trois conditions suivantes sont remplies : le travail est véritablement facultatif, l'échec peut être ignoré sans danger, et l'opération se termine bien avant toute durée de vie de processus attendue. La journalisation d’un ping de télémétrie non critique est un exemple où ces conditions peuvent toutes être remplies.

Conserver la propriété explicite

Utilisez l’un des modèles de propriété suivants :

  • Retournez le Task et exigez que les appelants l'attendent.
  • Effectuez le suivi des tâches en arrière-plan dans un service propriétaire dédié.
  • Utilisez une abstraction de fond gérée par l'hôte afin que l'hôte ait le contrôle de la durée de vie.

Si le travail doit continuer après le retour de l’appelant, transférez explicitement la propriété. Par exemple, passez la tâche à un traceur qui enregistre automatiquement les erreurs et participe à la fermeture.

Afficher les exceptions des tâches en arrière-plan

Les tâches supprimées peuvent échouer en mode silencieux jusqu’à ce que la finalisation et la gestion des exceptions non observées se produisent. Ce minutage n’est pas déterministe et trop tard pour la gestion normale des requêtes ou des flux de travail.

Attachez une logique d’observation lorsque vous allez mettre en file d’attente une tâche en arrière-plan. À minima, enregistrer les échecs dans une suite. Préférez un suivi centralisé afin que chaque opération mise en file d’attente obtient la même stratégie.

Pour plus d’informations sur la propagation des exceptions, consultez gestion des exceptions de tâche.

Coordonner l’annulation et l’arrêt

Lier le travail en arrière-plan à un jeton d’annulation qui représente la durée de vie de l’application ou de l’opération. Pendant l’arrêt :

  1. Arrêtez d’accepter de nouveaux travaux.
  2. Signalez l’annulation.
  3. Attendez les tâches suivies avec un délai d’expiration limité.
  4. Journaliser les opérations incomplètes.

Ce flux maintient l’arrêt prévisible et empêche les écritures partielles ou les opérations orphelines.

Le GC peut-il collecter une méthode asynchrone avant qu'elle soit terminée ?

Le runtime maintient l’ordinateur d’état asynchrone actif pendant que les continuations le référencent toujours. Vous ne perdez généralement pas une opération asynchrone en cours à cause de la collecte de déchets de la machine d’état elle-même.

Vous pouvez toujours perdre la justesse si vous perdez la propriété de la tâche retournée, supprimez les ressources requises tôt ou laissez le processus se terminer avant l’achèvement. Concentrez-vous sur la propriété des tâches et l’arrêt coordonné.

Voir également