Remarque
L’accès à cette page nécessite une autorisation. Vous pouvez essayer de vous connecter ou de modifier des répertoires.
L’accès à cette page nécessite une autorisation. Vous pouvez essayer de modifier des répertoires.
Lorsque vous disposez d’une méthode synchrone dans une bibliothèque, vous pouvez être tenté d’exposer un équivalent asynchrone qui l’encapsule :Task.Run
public T Foo() { /* synchronous work */ }
// Don't do this in a library:
public Task<T> FooAsync()
{
return Task.Run(() => Foo());
}
Cet article explique pourquoi cette approche est presque toujours incorrecte pour les bibliothèques et comment réfléchir aux compromis.
Scalabilité et déchargement
La programmation asynchrone offre deux avantages distincts :
- Scalabilité : réduisez la consommation des ressources en libérant des threads pendant les attentes d’E/S.
- Déchargement : déplacez le travail vers un autre thread pour maintenir la réactivité (par exemple, conserver un thread d’interface utilisateur libre) ou obtenir un parallélisme.
Ces avantages nécessitent différentes approches. Distinction critique : encapsuler une méthode synchrone dans Task.Run aide au déchargement, mais ne fait rien pour l'évolutivité.
Pourquoi Task.Run n'améliore pas la scalabilité
Une implémentation véritablement asynchrone réduit le nombre de threads consommés pendant une opération de longue durée. Un Task.Run wrapper bloque toujours un thread : il déplace simplement le blocage d’un thread vers un autre :
public static class TimerExampleWrong
{
public static Task SleepAsync(int millisecondsTimeout)
{
return Task.Run(() => Thread.Sleep(millisecondsTimeout));
}
}
Public Module TimerExampleWrong
Public Function SleepAsync(millisecondsTimeout As Integer) As Task
Return Task.Run(Sub() Thread.Sleep(millisecondsTimeout))
End Function
End Module
Comparez cette approche à une implémentation véritablement asynchrone qui ne consomme aucun thread en attendant :
public static class TimerExampleRight
{
public static Task SleepAsync(int millisecondsTimeout)
{
var tcs = new TaskCompletionSource<bool>();
var timer = new Timer(
_ => tcs.TrySetResult(true), null, millisecondsTimeout, Timeout.Infinite);
tcs.Task.ContinueWith(
_ => timer.Dispose(), TaskScheduler.Default);
return tcs.Task;
}
}
Public Module TimerExampleRight
Public Function SleepAsync(millisecondsTimeout As Integer) As Task
Dim tcs As New TaskCompletionSource(Of Boolean)()
Dim tmr As New Timer(
Sub(state) tcs.TrySetResult(True), Nothing, millisecondsTimeout, Timeout.Infinite)
tcs.Task.ContinueWith(
Sub(t) tmr.Dispose(), TaskScheduler.Default)
Return tcs.Task
End Function
End Module
Les deux implémentations se terminent après le délai spécifié, mais la deuxième implémentation ne bloque aucun thread en attendant. Pour les applications serveur qui gèrent de nombreuses requêtes simultanées, cette différence affecte directement le nombre de requêtes qu’un serveur peut traiter simultanément.
Le déchargement est la responsabilité du consommateur
Encapsuler les appels synchrones dans Task.Run est utile pour décharger le thread de l’interface utilisateur. Toutefois, c’est le consommateur, et non la bibliothèque, qui doit gérer cet enveloppement :
public static class UIOffloadExample
{
public static int ComputeIntensive(int input)
{
int result = 0;
for (int i = 0; i < input; i++)
{
result += i;
}
return result;
}
public static async Task ConsumeFromUIThreadAsync()
{
int result = await Task.Run(() => ComputeIntensive(10_000));
Console.WriteLine($"Result: {result}");
}
}
Public Module UIOffloadExample
Public Function ComputeIntensive(input As Integer) As Integer
Dim result As Integer = 0
For i As Integer = 0 To input - 1
result += i
Next
Return result
End Function
Public Async Function ConsumeFromUIThreadAsync() As Task
Dim result As Integer = Await Task.Run(Function() ComputeIntensive(10_000))
Console.WriteLine($"Result: {result}")
End Function
End Module
Le consommateur connaît son contexte : qu'il s'agisse d'un thread d'interface utilisateur, du niveau de granularité dont il a besoin, et si le déchargement ajoute de la valeur. La bibliothèque ne le fait pas.
Pourquoi les bibliothèques ne doivent pas exposer les wrappers async-over-sync
Lorsqu’une bibliothèque expose uniquement la méthode synchrone (et non pas un wrapper asynchrone), les consommateurs bénéficient de plusieurs façons :
- Surface d’exposition d’API réduite : moins de méthodes pour apprendre, tester et gérer.
- Aucune attente d’extensibilité trompeuse : les utilisateurs savent que seules les méthodes exposées comme asynchrones offrent réellement des avantages d’extensibilité.
-
Contrôle du consommateur : les appelants choisissent si et comment décharger, au bon niveau de granularité. Une application serveur à débit élevé peut appeler directement la méthode synchrone, ce qui évite une surcharge inutile.
Task.Run - Meilleures performances : les wrappers asynchrones ajoutent une surcharge par le biais d’allocations, de commutateurs de contexte et de planification de pool de threads. Pour les opérations affinées, cette surcharge peut être importante.
Exceptions à la règle
Certaines classes de base exposent des méthodes asynchrones afin que les classes dérivées puissent les remplacer par des implémentations véritablement asynchrones. La classe de base fournit une valeur par défaut asynchrone sur la synchronisation.
Par exemple, Stream expose ReadAsync et WriteAsync. Les implémentations de base encapsulent les méthodes Read et Write synchrones. Les classes dérivées comme FileStream et NetworkStream substituent ces méthodes avec des implémentations d’E/S asynchrones qui offrent des avantages réels en matière d’extensibilité.
De même, TextReader fournit ReadToEndAsync sur la classe de base en tant que wrapper, et StreamReader la surclasse avec une implémentation véritablement asynchrone qui appelle ReadAsync en interne.
Ces exceptions sont valides, car :
- Le modèle est conçu pour le polymorphisme. Les appelants interagissent avec le type de base.
- Les types dérivés fournissent des remplacements véritablement asynchrones.
Instruction
Exposez des méthodes asynchrones à partir d’une bibliothèque uniquement lorsque l’implémentation offre des avantages réels en matière d’extensibilité par rapport à son équivalent synchrone. N’exposez pas de méthodes asynchrones uniquement pour le déchargement. Laissez ce choix au consommateur.