Les problèmes courants async/await

Async/await simplifie la programmation asynchrone, mais certaines erreurs apparaissent à plusieurs reprises. Cet article décrit les cinq bogues les plus courants dans le code asynchrone et vous montre comment corriger chacun d’eux.

La méthode asynchrone s’exécute de manière synchrone

L’ajout du mot clé async à une méthode ne fait pas exécuter la méthode sur un thread d’arrière-plan. Il indique au compilateur d’autoriser await dans le corps de la méthode et d’encapsuler la valeur de retour dans un Task. Lorsque vous appelez une méthode asynchrone, elle s’exécute de manière synchrone jusqu’à ce qu’elle atteigne la première await sur une awaitable incomplète. Si la méthode ne contient aucune expression await ou si chaque tâche en attente qu’elle attend est déjà terminée, alors la méthode se termine complètement sur le thread appelant :

public static class SyncExecutionExample
{
    public static Task<int> ComputeAsync()
    {
        // No await in this method — it runs entirely synchronously.
        return Task.FromResult(42);
    }
}
Public Module SyncExecutionExample
    Public Function ComputeAsync() As Task(Of Integer)
        ' No Await in this method — it runs entirely synchronously.
        Return Task.FromResult(42)
    End Function
End Module

Ici, la méthode renvoie immédiatement une tâche accomplie, car elle ne cède jamais. Le compilateur émet un avertissement lorsqu’une méthode asynchrone ne contient await pas d’expressions.

Si votre objectif est de décharger le travail lié au CPU sur un thread de pool de threads, utilisez Run plutôt que async :

public static class OffloadExample
{
    public static int ComputeIntensive()
    {
        int sum = 0;
        for (int i = 0; i < 1_000; i++)
            sum += i;
        return sum;
    }

    public static Task<int> ComputeOnThreadPoolAsync()
    {
        return Task.Run(() => ComputeIntensive());
    }
}
Public Module OffloadExample
    Public Function ComputeIntensive() As Integer
        Dim sum As Integer = 0
        For i As Integer = 0 To 999
            sum += i
        Next
        Return sum
    End Function

    Public Function ComputeOnThreadPoolAsync() As Task(Of Integer)
        Return Task.Run(Function() ComputeIntensive())
    End Function
End Module

Pour plus d’informations sur l’utilisation Task.Run, consultez wrappers asynchrones pour les méthodes synchrones.

Impossible d’attendre une méthode async void

Lorsque vous convertissez une méthode synchrone de retour void en asynchrone, remplacez le type de retour par Task. Si vous laissez le type de retour comme void, la méthode devient « async void », que vous ne pouvez pas awaiter :

public static class AsyncVoidExample
{
    // BAD: async void — can't be awaited.
    public static async void DoWorkBadAsync()
    {
        await Task.Delay(100);
    }

    // GOOD: async Task — callers can await this.
    public static async Task DoWorkGoodAsync()
    {
        await Task.Delay(100);
    }
}
Public Module AsyncVoidExample
    ' BAD: Async Sub — can't be awaited.
    Public Async Sub DoWorkBadAsync()
        Await Task.Delay(100)
    End Sub

    ' GOOD: Async Function returning Task — callers can await this.
    Public Async Function DoWorkGoodAsync() As Task
        Await Task.Delay(100)
    End Function
End Module

Les méthodes async void servent un objectif spécifique : les gestionnaires d’événements de niveau supérieur dans les infrastructures d’interface utilisateur. En dehors des gestionnaires d’événements, retournez toujours Task ou Task<T> à partir de méthodes asynchrones. Les méthodes Async void présentent ces inconvénients :

  • Les exceptions passent inaperçues. Les exceptions levées dans une méthode async void se propagent à celle SynchronizationContext qui était active au démarrage de la méthode. L’appelant ne peut pas intercepter ces exceptions.
  • Les appelants ne peuvent pas suivre l’achèvement. Sans un Task, il n’y a aucun mécanisme à savoir quand l’opération se termine.
  • Les tests sont difficiles. Vous ne pouvez pas, dans un test, attendre la méthode pour vérifier son comportement.

Interblocages dus au blocage du code asynchrone

Ce bogue est la cause la plus courante du code asynchrone qui « ne se termine jamais ». Cela se produit lorsque vous bloquez de manière synchrone (appel Wait, Task<TResult>.Result, ou GetAwaiter.GetResult) sur un thread qui a un modèle de SynchronizationContext à thread unique.

Séquence qui provoque un interblocage :

  1. Le code sur le thread de l'interface utilisateur (ou un thread de requête ASP.NET dans les anciennes versions d'ASP.NET) appelle une méthode asynchrone et bloque sur la tâche retournée.
  2. La méthode asynchrone attend une tâche incomplète sans utiliser ConfigureAwait(false).
  3. Une fois la tâche attendue terminée, la continuation tente de revenir à l’original SynchronizationContext.
  4. Le thread de ce contexte est bloqué en attente de la fin de la tâche : interblocage.
public static class DeadlockExample
{
    public static async Task<string> GetDataAsync()
    {
        // Without ConfigureAwait(false), this continuation
        // posts back to the original SynchronizationContext.
        await Task.Delay(100);
        return "data";
    }

    public static void CallerThatDeadlocks()
    {
        // On a single-threaded SynchronizationContext (e.g. UI thread),
        // the following line deadlocks because the continuation needs
        // the same thread that .Result is blocking.
        string result = GetDataAsync().Result;
    }
}
Public Module DeadlockExample
    Public Async Function GetDataAsync() As Task(Of String)
        ' Without ConfigureAwait(False), this continuation
        ' posts back to the original SynchronizationContext.
        Await Task.Delay(100)
        Return "data"
    End Function

    Public Sub CallerThatDeadlocks()
        ' On a single-threaded SynchronizationContext (e.g. UI thread),
        ' the following line deadlocks because the continuation needs
        ' the same thread that .Result is blocking.
        Dim result As String = GetDataAsync().Result
    End Sub
End Module

Comment éviter les interblocages

Utilisez une ou plusieurs de ces stratégies :

  • Ne bloquez pas. Utilisez await plutôt que .Result ou .Wait().

    public static class DeadlockFix1
    {
        public static async Task CallerFixedAsync()
        {
            // Use await instead of .Result
            string result = await DeadlockExample.GetDataAsync();
            Console.WriteLine(result);
        }
    }
    
    Public Module DeadlockFix1
        Public Async Function CallerFixedAsync() As Task
            ' Use Await instead of .Result
            Dim result As String = Await DeadlockExample.GetDataAsync()
            Console.WriteLine(result)
        End Function
    End Module
    
  • Utiliser ConfigureAwait(false) dans le code de la bibliothèque. Lorsque la méthode de votre bibliothèque n’a pas besoin de reprendre dans le contexte de l’appelant, spécifiez ConfigureAwait(false) à chaque await:

    public static class DeadlockFix2
    {
        public static async Task<string> GetDataSafeAsync()
        {
            await Task.Delay(100).ConfigureAwait(false);
            return "data";
        }
    }
    
    Public Module DeadlockFix2
        Public Async Function GetDataSafeAsync() As Task(Of String)
            Await Task.Delay(100).ConfigureAwait(False)
            Return "data"
        End Function
    End Module
    

    L’utilisation de ConfigureAwait(false) informe l'environnement d'exécution de ne pas transférer la continuation vers l’original SynchronizationContext. Cette approche protège les appelants qui bloquent et améliore les performances en évitant les commutations de threads inutiles.

Avertissement

Blocages statiques du constructeur. Le CLR contient un verrou lors de l’exécution de constructeurs statiques (cctor). Si un constructeur statique bloque une tâche et que la continuation de cette tâche doit exécuter du code dans le même type (ou un type impliqué dans la chaîne de construction), la continuation ne peut pas continuer, car le cctor verrou est conservé. Évitez de bloquer complètement les appels à l’intérieur des constructeurs statiques.

Dépliage de la<tâche>

Lorsque vous passez une lambda asynchrone à une méthode telle que StartNew, l’objet retourné est un Task<Task> (ou Task<Task<TResult>>), et non un simple Task. La tâche externe se termine dès que l’lambda asynchrone atteint son premier rendement await. Il n’attend pas que la tâche interne se termine :

public static class TaskTaskBugExample
{
    public static async Task DemoAsync()
    {
        var sw = Stopwatch.StartNew();
        // StartNew returns Task<Task>, not Task.
        // The outer task completes immediately when the lambda yields.
        await Task.Factory.StartNew(async () =>
        {
            await Task.Delay(1000);
        });
        // Elapsed shows ~0 seconds, not ~1 second.
        Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds:F2}s");
    }
}
Public Module TaskTaskBugExample
    Public Async Function DemoAsync() As Task
        Dim sw = Stopwatch.StartNew()
        ' StartNew returns Task(Of Task), not Task.
        ' The outer task completes immediately when the lambda yields.
        Await Task.Factory.StartNew(Async Function()
                                        Await Task.Delay(1000)
                                    End Function)
        ' Elapsed shows ~0 seconds, not ~1 second.
        Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds:F2}s")
    End Function
End Module

Corrigez ce problème de l’une des trois manières suivantes :

  • Utilisez Run à la place. Task.Run désencapsule automatiquement Task<Task>:

    public static class TaskTaskFix1
    {
        public static async Task DemoAsync()
        {
            var sw = Stopwatch.StartNew();
            await Task.Run(async () =>
            {
                await Task.Delay(1000);
            });
            Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds:F2}s");
        }
    }
    
    Public Module TaskTaskFix1
        Public Async Function DemoAsync() As Task
            Dim sw = Stopwatch.StartNew()
            Await Task.Run(Async Function()
                               Await Task.Delay(1000)
                           End Function)
            Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds:F2}s")
        End Function
    End Module
    
  • Appeler Unwrap sur le résultat :

    public static class TaskTaskFix2
    {
        public static async Task DemoAsync()
        {
            var sw = Stopwatch.StartNew();
            await Task.Factory.StartNew(async () =>
            {
                await Task.Delay(1000);
            }).Unwrap();
            Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds:F2}s");
        }
    }
    
    Public Module TaskTaskFix2
        Public Async Function DemoAsync() As Task
            Dim sw = Stopwatch.StartNew()
            Await Task.Factory.StartNew(Async Function()
                                            Await Task.Delay(1000)
                                        End Function).Unwrap()
            Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds:F2}s")
        End Function
    End Module
    
  • Attendez deux fois (d’abord la tâche externe, puis l’intérieur) :

    public static class TaskTaskFix3
    {
        public static async Task DemoAsync()
        {
            var sw = Stopwatch.StartNew();
            Task<Task> outerTask = Task.Factory.StartNew(async () =>
            {
                await Task.Delay(1000);
            });
            Task innerTask = await outerTask;
            await innerTask;
            Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds:F2}s");
        }
    }
    
    Public Module TaskTaskFix3
        Public Async Function DemoAsync() As Task
            Dim sw = Stopwatch.StartNew()
            Dim outerTask As Task(Of Task) = Task.Factory.StartNew(Async Function()
                                                                       Await Task.Delay(1000)
                                                                   End Function)
            Dim innerTask As Task = Await outerTask
            Await innerTask
            Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds:F2}s")
        End Function
    End Module
    

Attente manquante lors d’un appel de retour de tâche

Si vous appelez une méthode de retour de tâche dans une async méthode sans l’attendre, la méthode démarre l’opération asynchrone, mais n’attend pas qu’elle se termine. Le compilateur vous avertit de ce cas avec CS4014 en C# et BC42358 dans Visual Basic :

public static class MissingAwaitExample
{
    // BAD: Task.Delay is started but never awaited.
    public static async Task PauseOneSecondBuggyAsync()
    {
        Task.Delay(1000); // CS4014 warning
    }

    // GOOD: await the task.
    public static async Task PauseOneSecondAsync()
    {
        await Task.Delay(1000);
    }
}
Public Module MissingAwaitExample
    ' BAD: Task.Delay is started but never awaited.
    Public Async Function PauseOneSecondBuggyAsync() As Task
        Task.Delay(1000) ' Warning BC42358
    End Function

    ' GOOD: Await the task.
    Public Async Function PauseOneSecondAsync() As Task
        Await Task.Delay(1000)
    End Function
End Module

Le stockage du résultat d’une variable supprime l’avertissement, mais ne corrige pas le bogue sous-jacent. Toujours await la tâche, sauf si vous souhaitez intentionnellement un comportement de feu et d’oubli.

Voir également