Wrappers synchrones pour les méthodes asynchrones

Lorsqu’une bibliothèque expose uniquement des API asynchrones, les consommateurs les encapsulent parfois dans des appels synchrones pour satisfaire une interface ou un contrat synchrone. Ce modèle « sync-over-async » peut sembler simple, mais il s’agit d’une source courante d’interblocages et de problèmes de performances.

Modèles de base d'enveloppement

Un wrapper synchrone autour d’une méthode TAP (Task-based Asynchrone Pattern) accède à la propriété de Result la tâche, ce qui bloque le thread appelant :

public class TapWrapper
{
    public static int Foo(Func<Task<int>> fooAsync)
    {
        return fooAsync().Result;
    }
}
Public Module TapWrapper
    Public Function Foo(fooAsync As Func(Of Task(Of Integer))) As Integer
        Return fooAsync().Result
    End Function
End Module

Cette approche semble simple, mais elle peut entraîner de graves problèmes en fonction de l’environnement dans lequel il s’exécute.

Interblocages avec des contextes à thread unique

Le scénario le plus dangereux se produit lorsque vous appelez un wrapper synchrone à partir d’un thread qui a un thread SynchronizationContextunique . Ce scénario est généralement un thread d’interface utilisateur dans des applications WPF, Windows Forms ou MAUI.

public static class DeadlockExample
{
    private static void Delay(int milliseconds)
    {
        DelayAsync(milliseconds).Wait();
    }

    private static async Task DelayAsync(int milliseconds)
    {
        await Task.Delay(milliseconds);
    }
}
Public Module DeadlockExample
    Private Sub Delay(milliseconds As Integer)
        DelayAsync(milliseconds).Wait()
    End Sub

    Private Async Function DelayAsync(milliseconds As Integer) As Task
        Await Task.Delay(milliseconds)
    End Function
End Module

Voici ce qui se passe pas à pas :

  1. Le thread d’interface utilisateur appelle Delay, qui appelle DelayAsync(milliseconds).Wait().
  2. DelayAsync s’exécute de manière synchrone jusqu’à ce qu’elle atteigne await Task.Delay(milliseconds).
  3. Étant donné que le délai n’est pas encore terminé, await capture l'état actuel SynchronizationContext puis se suspend. DelayAsync retourne un Task à l’appelant.
  4. Le thread UI se bloque en .Wait() en attendant que cette tâche se termine.
  5. Une fois le délai terminé, la continuation doit s’exécuter sur l’original SynchronizationContext qui est le thread d’interface utilisateur.
  6. Le thread d’interface utilisateur ne peut pas traiter la continuation, car elle est bloquée dans .Wait().
  7. Impasse.

Important

La réussite ou l’échec du code sync-over-async dépend de l’environnement dans lequel il s’exécute. Le code qui fonctionne dans une application console peut entraîner un blocage sur un thread UI ou dans ASP.NET (du .NET Framework). Cette dépendance environnementale est une raison essentielle pour éviter d’exposer des wrappers synchrones.

Épuisement du pool de threads

Les interblocages ne sont pas limités aux threads d’interface utilisateur. Si une méthode asynchrone dépend du pool de threads pour accomplir son travail, par exemple, en mettant en file d’attente une étape de traitement finale, le fait de bloquer de nombreux threads du pool avec des wrappers synchrones peut entraîner une saturation du pool.

public static class ThreadPoolDeadlockExample
{
    public static int Foo(Func<Task<int>> fooAsync)
    {
        return fooAsync().Result;
    }

    public static async Task DemonstrateDeadlockRiskAsync()
    {
        var tasks = Enumerable.Range(0, 25)
            .Select(_ => Task.Run(() => Foo(() => SomeIOOperationAsync())));
        await Task.WhenAll(tasks);
    }

    private static async Task<int> SomeIOOperationAsync()
    {
        await Task.Delay(100);
        return 42;
    }
}

Dans ce scénario :

  1. De nombreux threads de pool de threads appellent Foo, qui sont bloqués dans .Result.
  2. Chaque opération asynchrone termine ses opérations d’entrées/sorties et a besoin d’un thread du pool pour exécuter son rappel d’achèvement.
  3. Étant donné que les appels bloqués occupent les threads de travail disponibles, les achèvements peuvent attendre longtemps qu’un thread devienne disponible.
  4. Le .NET moderne peut ajouter davantage de threads au pool de threads au fil du temps, mais l’application peut toujours souffrir d'une grave pénurie de threads, d'un faible débit, de longs retards ou d'un gel apparent.

Ce modèle a affecté HttpWebRequest.GetResponse dans .NET Framework 1.x, où la méthode synchrone a été implémentée en tant que wrapper autour du BeginGetResponse/EndGetResponse asynchrone.

Directive : éviter d’exposer des wrappers synchrones

N’exposez pas de méthode synchrone qui encapsule une implémentation asynchrone. Au lieu de cela, laissez au consommateur la décision de bloquer ou non. Le consommateur connaît son environnement d'exécution des threads et peut faire un choix éclairé.

Si vous avez besoin d’appeler une méthode asynchrone de manière synchrone, pensez d’abord à restructurer le code pour qu’il soit « asynchrone tout au bas ». La refactorisation est souvent la meilleure solution à long terme.

Stratégies d’atténuation lorsque la synchronisation sur async est inévitable

Parfois, la synchronisation sur async est vraiment inévitable. Par exemple, il est inévitable lorsque vous implémentez une interface qui nécessite une méthode synchrone et que la seule implémentation disponible est asynchrone. Dans ces cas, appliquez les stratégies suivantes pour réduire le risque.

Utiliser ConfigureAwait(false) dans l’implémentation asynchrone

Si vous contrôlez la méthode asynchrone, utilisez Task.ConfigureAwait avec false sur chaque await pour empêcher la continuation de rebasculement vers l'original SynchronizationContext.

public static class ConfigureAwaitMitigation
{
    public static async Task<int> LibraryMethodAsync()
    {
        await Task.Delay(100).ConfigureAwait(false);
        return 42;
    }

    public static int Sync()
    {
        return LibraryMethodAsync().GetAwaiter().GetResult();
    }
}
Public Module ConfigureAwaitMitigation
    Public Async Function LibraryMethodAsync() As Task(Of Integer)
        Await Task.Delay(100).ConfigureAwait(False)
        Return 42
    End Function

    Public Function Sync() As Integer
        Return LibraryMethodAsync().Result
    End Function
End Module

En tant qu’auteur de bibliothèque, utilisez ConfigureAwait(false) sur tous les await, sauf si votre code doit spécifiquement récupérer dans le contexte sauvegardé. L’utilisation ConfigureAwait(false) est une bonne pratique pour les performances et permet d’empêcher les blocages lorsque les consommateurs bloquent.

Décharger vers le pool de threads

Si vous ne contrôlez pas l'implémentation asynchrone (et qu'elle pourrait ne pas utiliser ConfigureAwait(false)), confiez l'appel au pool de threads. Le pool de threads n’a pas de SynchronizationContext ; par conséquent, l’attente n’essaie pas de rediriger vers un thread bloqué.

public int Sync()
{
    return Task.Run(() => Library.FooAsync()).Result;
}
Public Function Sync() As Integer
    Return Task.Run(Function() Library.FooAsync()).Result
End Function

Tester dans plusieurs environnements

Si vous devez expédier un wrapper synchrone, testez-le à partir de :

  • Thread d’interface utilisateur (WPF, Windows Forms).
  • Pool de threads en cours de chargement.
  • Pool de threads avec un nombre de threads maximal faible.
  • Une application console.

Le comportement qui fonctionne dans un environnement peut se bloquer dans un autre.

Voir également