Nouvelles Chroniques d'Amethyste

Penser au Sens, pas au Verbe

Task.Run: pas toujours une bonne idée

Poster un commentaire

Examinons ce bout de code:

class Workload
{
   public int Index { get; set; }

   public void Process()
   {
      // on simule une opération qui prend environ 1 seconde
      Thread.Sleep(1000);
   }
}

Une classe appelée Workload expose une méthode Process qui exécute une opération que nous simulons avec Sleep.

Il ne vous échappe pas que l’opération est synchrone et relativement longue. On pourrait donc être tenté de créer une méthode asynchrone de la façon suivante:


public static void ProcessAsync()
{
   Task.Run(() =>
   {
      // on simule une opération qui prend environ 1 seconde
      Thread.Sleep(1000);
   });
}

Effectivement en apparence la méthode paraît asynchrone, l’interface reprend la main tout de suite après son lancement.

Le seul problème est qu’en général ce n’est pas une bonne solution pour différentes raisons.

La première raison est que la méthode reste malgré tout synchrone. On a toujours un thread qui attend quelque part, c’est juste un autre puisé dans le pool de threads. Cela ne change rien au fait que l’on mobilise des ressources (le thread), mais pire on a instancié des objets (Task) que le GC devra bien libérer un jour ou l’autre et cette opération est douloureuse pour les performances de l’application.

Le résultat de cette transformation est que l’application aura perdu de la scalabilité par rapport à la version synchrone puisqu’elle utilise des ressources supplémentaires!

Ce n’est pas tout, on a suffixé avec Async une méthode. Par convention on attend alors une méthode asynchrone, c’est à dire qui se « scalabilise » dans de bonnes conditions. Hors ce n’est pas le cas puisqu’en interne on va extraire un thread du pool et le mobiliser pendant 1 seconde. Les threads sont une ressources limitée, on doit les utiliser avec parcimonie, surtout si on veut écrire une application qui tient la charge.

 

Cela signifie t’il que l’on ne doit jamais faire Task.Run()? Vous vous doutez bien que non. Simplement il faut le faire lorsque c’est pertinent.

Le problème est que l’on ne se pose pas la bonne question. Vous voulez faire de l’asynchronisme? Ok, mais pourquoi faire?

On peut répondre de deux manières:

  1. améliorer la scalabilité de l’application
  2. faire du parallélisme

De la réponse que vous allez donner va dépendre la façon dont le code sera écrit.

 

Quel que soit le Framework ou le langage que nous utilisons, nous ne pourrons pas faire tourner plus de threads simultanément qu’il y a de CPU logique sur le serveur.

Par exemple ma machine affiche ceci:

2015-11-23_18-14-06

Je dispose de 2 cœurs physiques et puisqu’ils sont hyperthreadés, je peux utiliser 2×2 = 4 cœurs logiques. J’ai donc à ma disposition 4 threads qui peuvent travailler simultanément. Les autres attendent!

public static BlockingCollection<Workload> LoadInParallel(int total)
{
   var wls = new BlockingCollection<Workload>();
 
   ParallelOptions options = new ParallelOptions();
   //options.MaxDegreeOfParallelism = 1;
 
   Parallel.For(0, total, options, i =>
   {
      Workload.Process(i);
   });
 
   return wls;
}

Par défaut ce code va essayer de paralléliser le plus possible la boucle (4 itérations) et donc d’occuper tous les cœurs disponibles. L’exécution prend de l’ordre de 1 seconde. En fait un peu plus car on ne peut pas paralléliser la totalité du code.

 

Essayez de jouer avec la valeur de MaxDegreeOfParallelism pour voir le comportement.

Avec 2, l’occupation des threads sera la suivante:

2015-11-23_18-29-02

Deux cœurs travaillent et ils vont chacun accomplir 2 itérations. On s’attend donc à un temps d’exécution de 2 secondes ce qui est effectivement ce que l’on observe.

Avec 1, on n’utilise qu’un seul cœur sur les 4 possibles:

2015-11-23_18-31-16

On ne s’étonnera pas de trouver un temps de l’ordre de 4 secondes et même un peu plus à cause de la gestion des threads qui s’ajoute.

Ce scénario où le facteur limitant est le nombre de cœurs est appelé CPU-bound. Notre exemple est précisément un cas de ce type. Peu importe que le thread attende parce que l’on fait un Sleep, une boucle ou autre chose.

C’est dans ce genre de scénario qu’il est légitime d’invoquer Task.Run. La méthode va organiser la répartition des tâches le long des threads disponibles et essayer de paralléliser au mieux le code.

 

Pour pouvoir faire de l’asynchronisme tout en préservant la monté en charge on dispose d’un autre outil: async/await. On pourrait l’invoquer de différentes manières.

 

Il existe nativement une version asynchrone de la charge:

public static async void Process()
{
// on simule une opération qui prend environ 1 seconde
//Thread.Sleep(1000);
await Task.Delay(1000);
}

On peut aussi essayer d’écrire une version asynchrone soi même en utilisant TaskCompletionSource en général, mais notez bien que ce n’est pas forcément possible:

public static Task Process(int index)
{
   TaskCompletionSource<bool> tcs = null;
   var t = new Timer(
   delegate { tcs.TrySetResult(true); },
      null,
      0,
      -1);
 
   tcs = new TaskCompletionSource<bool>(t);
   t.Change(1000, -1);
   return tcs.Task;
}

 

Bon OK, mon problème n’est pas d’optimiser l’utilisation des threads, mais d’avoir une interface responsive, tant pis si je consomme des ressources supplémentaires. C’est bon je peux faire Task.Run????

 

Peut être. Mais une fois encore il faut se poser la bonne question. Qui a besoin de l’asynchronisme? Le code ou bien une IHM avec un thread UI?

Bien souvent le code va être invoqué depuis un WebForm ou une page web. On a donc là un conflit avec le principe de séparation des responsabilités.

Ce n’est pas à votre librairie de résoudre les problèmes spécifique au code qui la consomme. Gérer l’asynchronisme par une méthode ou une autre c’est un choix qui doit être fait au niveau de l’IHM, pas d’une librairie. En tant qu’auteur de librairie vous devez surtout vous assurer qu’elle fasse le boulot et monte en charge le mieux possible.

L’équipe responsable de l’IHM pourra toujours encapsuler un appel à votre code dans un Task.Run au moment où cela lui semble le plus pertinent.

 

Bibliographie

http://blog.stephencleary.com/2013/11/taskrun-etiquette-examples-using.html

http://blog.stephencleary.com/2013/11/taskrun-etiquette-examples-dont-use.html

http://www.kuznero.com/posts/csharp/2014-06-29-async-await-practices.html

http://www.ben-morris.com/why-you-shouldnt-create-asynchronous-wrappers-with-task-run/

https://www.packtpub.com/packtlib/book/Application-Development/9781785286650/8/ch08lvl1sec56/I/O%20and%20CPU-bound%20tasks

http://blog.stevenedouard.com/asynchronousness-vs-concurrency/

http://codeblog.jonskeet.uk/2010/11/02/configuring-waiting/

 

Publicités

Laisser un commentaire

Entrez vos coordonnées ci-dessous ou cliquez sur une icône pour vous connecter:

Logo WordPress.com

Vous commentez à l'aide de votre compte WordPress.com. Déconnexion / Changer )

Image Twitter

Vous commentez à l'aide de votre compte Twitter. Déconnexion / Changer )

Photo Facebook

Vous commentez à l'aide de votre compte Facebook. Déconnexion / Changer )

Photo Google+

Vous commentez à l'aide de votre compte Google+. Déconnexion / Changer )

Connexion à %s