Loupe

.NET : marquer une méthode async n'est pas anodin en termes de performance

En .Net, il est possible de marquer une méthode comme asynchrone afin de pouvoir utiliser le mot-clef await dans son code et faciliter l'écriture de code asynchrone. Il s'agit d'un sucre syntaxique très pratique mais attention : son utilisation n'est pas anodine !

 

Comment cela fonctionne sous le capot ?

Il n'y a en fait rien de magique et le plus gros du travail est fait par le compilateur : votre code est transformé en machine à états pour gérer tout le code de plomberie que vous auriez dû écrire traditionnellement. 

Prenons pour exemple une classe toute simple avec une méthode marquée comme async :

public class ClassAvecModifier
{
    public async Task<bool> MaMethodeAsync()
    {
        bool jeSuisUnBoolean = false;

        await AutreClass.UneAutreMéthodeAsync();

        return jeSuisUnBoolean;
    }
}

 

Elle sera transformée en un code long comme Guerre et Paix qui fait passer JavaScript pour un langage que l'on pourrait aimer #troll. Attention, il s'agit bien sûr de code décompilé qui ne compile pas forcément en tant que tel mais qui donne une bonne idée de ce qu'il y a dans notre DLL :

public class ClassAvecModifier
{
 public Task MaMethodeAsync()
 {
  ClassAvecModifier.d__0 stateMachine 
	= new ClassAvecModifier.d__0();
  stateMachine.<>4__this = this;
  stateMachine.<>t__builder 
  = AsyncTaskMethodBuilder.Create();
  stateMachine.<>__state = -1;
  stateMachine.<>t__builder
	.Startd__0>(ref stateMachine);
  return stateMachine.<>t__builder.Task;
 }

 public Task UneAutreMéthodeAsync()
 {
  return Task.FromResult(true);
 }

 public ClassAvecModifier()
 {
  base.ctor();
 }

 [CompilerGenerated]
 private sealed class d__0 
 : IAsyncStateMachine
 {
  public int <>__state;
  public AsyncTaskMethodBuilder <>t__builder;
  public ClassAvecModifier <>4__this;
  private bool 5__1;
  private TaskAwaiter <>u__1;

  public d__0()
  {
   base.\u002Ector();
  }

  void IAsyncStateMachine.MoveNext()
  {
   int num1 = this.<>__state;
   bool jeSuisUnBoolean51;
   try
   {
    TaskAwaiter awaiter;
    int num2;
    if (num1 != 0)
    {
     this.5__1 = false;
     awaiter = AutreClass.UneAutreMéthodeAsync().GetAwaiter();
     if (!awaiter.IsCompleted)
     {
      this.<>__state = num2 = 0;
      this.<>u__1 = awaiter;
      ClassAvecModifier.d__0 stateMachine=this;
      this.<>t__builder.AwaitUnsafeOnCompleted<
				TaskAwaiter, 
				ClassAvecModifier.d__0>(
					ref awaiter, ref stateMachine);
      return;
     }
    }
    else
    {
     awaiter = this.<>u__1;
     this.<>u__1 = new TaskAwaiter();
     this.<>__state = num2 = -1;
    }
    awaiter.GetResult();
    jeSuisUnBoolean51 = this.5__1;
   }
   catch (Exception ex)
   {
    this.<>__state = -2;
    this.<>t__builder.SetException(ex);
    return;
   }
   this.<>__state = -2;
   this.<>t__builder.SetResult(jeSuisUnBoolean51);
  }

  [DebuggerHidden]
  void IAsyncStateMachine
	.SetStateMachine(IAsyncStateMachine stateMachine)
  {
  }
 }
}

 

Si l'on analyse un peu le code, on voit donc qu'une classe implémentant "IAsyncStateMachine" est créée. Celle-ci est instantiée et initialisée dans la méthode asynchrone que nous avions initialement déclarée. Il y aura une nouvelle classe par méthode asynchrone de votre classe d'origine. Chacune de ces classes va implémenter la méthode MoveNext qui va contenir la vraie logique de votre méthode asynchrone initiale dont le code va être déplacé à l'intérieur. Des utilitaires du framework tels que AsyncTaskMethodBuilder sont aussi utilisés pour déclencher l'exécution en conservant la callstack etc. Je ne vais pas rentrer plus dans les détails car il s'agit de code généré par le compilateur et cela peut donc changer à chaque sortie d'une nouvelle version. Ce qu'il faut retenir c'est que votre code est massivement modifié pour permettre d'utiliser async/await !

 Pour rappel et pour les plus courageux, le compilateur Roslyn est disponible en open-source sur GitHub

 

On peut aussi utiliser des outils d'analyse de performance pour confirmer ce que l'on vient de découvrir en affichant l'arbre d'exécution avec await/async :

overhead.PNG

Ou sans await/async :

overhead_simple.PNG

En passant, cela explique aussi pourquoi nos callstack de code avec async sont si complexes à intérpréter car il y a pleins de méthodes intermédiaires dans nos appels. Il existe d'ailleurs des packages nugets tel que qu'AsyncStackTraceEx pour simplifier leur affichage.

Quelques tests de performances

Lorsque l'on regarde le code généré précédemment, on peut vite prendre peur pour ses performances. Rappelons quand même que qui dit code généré dit code optimisable plus facilement. Effectuons donc quelques boucles de tests rapidement avec ce code :

// premier appel de warmup
await new ClassAvecModifier().MaMethodeAsync();
await new ClassSansModifier().MaMethodeAsync();

var sw= new Stopwatch();
for (int i = 0; i < howMuchLoop; i++)
{
    var newInstance = new ClassAvecModifier();
    sw.Start();
    var result = await newInstance.MaMethodeAsync();
    sw.Stop();
}
sw.Stop();
Console.WriteLine($"Spend {sw.ElapsedMilliseconds} ms with async.");

sw= new Stopwatch();
for (int i = 0; i < howMuchLoop; i++)
{
    var newInstance = new ClassSansModifier();
    sw.Start();
    var result = await newInstance.MaMethodeAsync();
    sw.Stop();
}
sw.Stop();
Console.WriteLine($"Spend {sw.ElapsedMilliseconds} ms without async.");

 

Nous aurons alors un résultat des plus rassurants sur ma machine en utilisant .netcore 2.0 sur 1 000 000 boucles:

Spend 203 ms with async.
Spend 150 ms without async.

 

En exécutant ce code 1000 fois j'obtiens un ratio async / sans-async d'environ 1,25. Le code sans async est donc environ 25% plus performant. 

Ratio avg 1,24396711901266.

 

Petite FAQ sur les tests réalisés :

  • ClassSansModifier est exactement la même chose que ClassAvecModifier mais sans async/await : uniquement un retour de Task.
  • Tests effectués en Release ? Oui !
  • Débugger attaché ? Non !
  • Même résultat avec le framework .NET classique ? Oui !
  • Même résultat si je wrappe les appels dans des try/catch ? Oui !
  • Même résultat si je chaîne plusieurs méthodes asynchrones ? Je suis allé sur une profondeur de 5 appels : Oui !
  • En conclusion

Lors des premières versions du compilateur on pouvait trouver des articles en anglais indiquant de bien meilleurs temps pour la version sans mot-clef async/await (ici -17 fois plus long!,  - 35% plus long) mais on peut voir que les équipes Microsoft ont travaillé d'arrache pied pour optimiser ces scenarii.

Par contre on peut se rendre compte que les tests sont effectués sur de grand nombre d'itérations et que l'on ne gagne au final que 50 ms sur 1 million d'appels.

Je pense donc qu'il est raisonnable de dire qu'enlever async/await permet d'améliorer les performances sur les chemins critiques de code appelés trés fréquemment. 

 

Où enlever le mot clef async/await ?

La première idée est de l'enlever sur les méthodes servant uniquement de surcharge pour donner une valeur à un paramètre comme dans l'exemple ci-dessous. Cela aura aussi comme avantage de faire disparaître l'appel de la "surcharge" de la callstack lors des debug :

public static Task<Lol> UneAutreMéthodeAsync()
{
    return UneAutreMéthodeAsync(42);
}

public static async Task<Lol> UneAutreMéthodeAsync(int variable)
{
    var kikou = await ...
    return kikou;
}

 

De façon similaire, cela peut être fait dans toutes les méthodes n'ayant pas besoin du résultat d'un traitement asynchrone. Cela est souvent le cas lorsque l'on a du code synchrone à exécuter avant de déclencher un traitement asynchrone. Attention cependant de ne pas le faire à l'intérieur d'un using auquel cas l'objet serait disposé avant la fin du traitement !

public static  Task<bool> KikouLolAsync()
{
    // initialisation & code synchrone 

    // déclenchement d'un traitement asynchrone
    // et retour de la tâche correspondante.
    return UneAutreMéthod2eAsync();
}

 

N'hésitez pas à flâner sur notre blog pour en savoir plus sur aync/await :

 

Happy coding :)

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus