Dot.Blog

C#, XAML, Xamarin, UWP/Android/iOS

Silverlight et le multi-tâche

[new:13/6/2010] Silverlight, depuis la version 3, permet via le modèle de programmation classique des Threads de concevoir des applications multi-tâches prenant en compte les processeurs multi-cœur. Cela est essentiel à plus d’un titre.

Il faut en effet savoir que le thread principal de programmation est bloquant pour le dessin : un traitement long en C# suspend le rafraichissement. C’est pour cela que Microsoft conseille de “casser” les “gros morceaux” de code procédural en petites unités de travail. Mais cela n’est pas toujours envisageable. Le multi-tâche peut en revanche facilement permettre de contourner le problème…

Exemple live

[silverlight:source=/SLSamples/SLThreads/SLThreads.xap;width=300;height=300]

Explications

A gauche le bouton de démarrage, à droite un stack panel contenant 6 instances d’un UserControl permettant de visualiser l’activité d’un job. Un job représente une tâche exécutée en arrière plan dans un thread séparé. Chaque job simule aléatoirement un travail connaissant des pics d’activité et des pauses.

Lorsqu’un job est terminé, sa led devient gris pâle. Quand un job tourne sa led est bleue lorsqu’il est en mode pause et rouge quand il travaille. En réalité il n’y a aucune différence entre les deux états puisque autant la pause que le travail sont simulés par un Thread.Sleep() dans cet exemple. Dans la réalité il s’agirait de vrais traitements ou d’attentes de données par exemple.

La durée totale d’un job ainsi que la durée de chaque pause et de chaque phase d’activité sont choisies de façon aléatoire. Deux runs différents donneront des résultats visuels différents donc.

Durant l’activité de touts les job un message sous le bouton indique le nombre de jobs restants actifs. La durée de chacun étant aléatoire, les extinctions de job arrivent dans un ordre quelconque.

Quand tous les jobs sont terminés le texte indique “Inactive” et le bouton de lancement est de nouveau Enabled (pendant le travail des jobs le bouton se grise et n’est plus actif).

Amusez-vous quelques fois… (on se lasse assez vite malgré tout :-) ).

La structure du programme

L’application se décompose comme suit :

  • MainPage (cs/xaml), c’est la fiche principale et unique de l’application.
  • JobView (cs/xaml) est le UserControl permettant de visualiser l’activité d’un thread
  • Job.cs est la classe qui effectue une tâche, nous allons revenir en détail sur son fonctionnement ainsi que la manière dont elle communique avec le thread principal pour mettre à jour l’affichage.

Dans ce billet je n’étudierai pas la mise en page (sous Blend) ni la création du UserControl (sous Blend aussi) avec ses propriétés de dépendance et sa gestion des états via le Visual State Manager. Le code source est téléchargeable en fin d’article, je vous laisse découvrir tout cela (et venir poser des questions si vous en avez, les commentaires sont là pour ça !).

La classe Job

Lorsqu’on veut faire du multi-tâche on s’aperçoit bien vite que communiquer entre les tâches ou entre une tâche et l’application peut s’avérer complexe. De même, chaque tâche doit le plus souvent être capable de gérer un contexte (c’est à dire un ensemble de valeurs).

De même, on sait que le multi-tâche est tellement plus simple lorsqu’on travaille avec des objets immuables (immutable en anglais). Par exemple les chaines de caractères de .NET sont immuables, c’est un choix très malin dans un environnement multi-tâche. En effet les problèmes du multi-tâches ne pointent leur nez qu’à partir du moment où des objets (des valeurs) sont accédées par des threads différents. L’une des astuces consiste donc à ne traiter que des objets immuables et plus aucun conflit n’est possible ! Cette stratégie n’est pas celle adoptée totalement ici car les threads n’ont pas d’interaction entre eux et je ne voulais pas trop alourdir l’exemple.

En revanche, pour gérer le contexte de chaque thread, plutôt que de se mélanger avec des solutions biscornues, il existe une solution très simple: créer une classe qui contient tout le contexte ainsi que la méthode qui sera lancée dans un thread.

Ainsi, chaque thread fait tourner une même méthode mais celle-ci appartient à une instance unique de l’objet contexte. De fait, la méthode qui tourne dans son thread peut accéder sans risque de mélange à toutes les variables et autres méthodes de son contexte. Pas de “lock” à gérer, tout devient simple…

Regardons le code de Job.cs :

   1: public class Job
   2:     {
   3:         private readonly Random rnd = new Random(DateTime.Now.Millisecond);
   4:  
   5:         public int EndValue { get; set; }
   6:         public Action<Job> JobDone { get; set; }
   7:         public Action<Job, JobState> ReportJob { get; set; }
   8:         public int JobID { get; set; }
   9:         public int Counter { get; set; }
  10:         public bool Done { get; set; }
  11:         public JobView View { get; set; }
  12:  
  13:         public void DoJob()
  14:         {
  15:             Done = false;
  16:             Counter = 0;
  17:             while (Counter < EndValue)
  18:             {
  19:                 Counter++;
  20:                 if (ReportJob != null) ReportJob(this, JobState.Idle);
  21:                 Thread.Sleep(rnd.Next(250));
  22:                 if (ReportJob != null) ReportJob(this, JobState.Working);
  23:                 Thread.Sleep(rnd.Next(250));
  24:             }
  25:             Done = true;
  26:             if (JobDone != null) JobDone(this);
  27:         }
  28:     }

Pour son fonctionnement, la classe déclare une variable Random qui sera utilisée pour déterminer la durée des pauses et des phases d’activité. Puis elle déclare une liste de propriétés publiques : EndValue (qui est le compteur de boucles, la durée du job pour simplifier), deux Action<Job> sur lesquelles nous allons revenir, un JobID attribuer par le programme principal (un simple numéro), un compteur (a boucle principale de 0 à EndValue simulant le job) une variable booléenne Done qui sera positionnée à True lorsque le job sera terminé, et View, de type JobView qui pointe l’instance du UserControl servant à visualiser l’activité du thread. Ce contrôle est créé par le programme principal en même temps que l’objet Job qui initialise l’ensemble des propriétés que nous venons de lister.

La méthode DoJob() est la méthode de travail. C’est elle qui sera lancée dans un thread. Mais comme vous pouvez le constater ce modèle de programmation multi-thread est totalement transparent : la classe job est parfaitement “normale”. Rien de spécial n’a été fait pour le multi-tâche.

La création des jobs

Les jobs sont créés une fois la page principale de l’application chargée. Voici comment :

   1: private List<Job> jobs = new List<Job>();
   2:  
   3:        void MainPage_Loaded(object sender, RoutedEventArgs e)
   4:        {
   5:            var r = new Random(DateTime.Now.Millisecond);
   6:            for (var i = 0; i < 6; i++)
   7:            {
   8:                var view = new JobView { Margin = new Thickness(3), JobID = (i + 1).ToString() };
   9:                spJobs.Children.Add(view);
  10:                jobs.Add(new Job
  11:                             {
  12:                                 EndValue = 20 + r.Next(30),
  13:                                 JobID = i,
  14:                                 JobDone = OnJobDone,
  15:                                 ReportJob = OnJobReport,
  16:                                 View = view
  17:                             });
  18:  
  19:            }
  20:            txtState.Text = "Inactive";
  21:        }

L’application gère une liste “jobs” de tous les jobs créés. Dans le Loaded de la page on voit la boucle qui créée 6 instances du userControl visualisateur d’activité et six instances de la classe Job.

Nous disposons maintenant d’une situation de départ simple : 6 instances de la classe Job existent en mémoire, chacune représentant un contexte d’exécution initialisé de façon propre et 6 instances de la classe JobView ont été créées et ajoutées au stack panel vertical qui occupe la partie droite de l’application. Chaque instance de Job est reliée au JobView lui correspondant. Ce lien existe plutôt comme membre du contexte de la tâche qu’en tant que véritable lien permettant aux deux instances de discuter ensemble. En tout cas dans notre exemple Job n’interviendra pas directement sur son JobView (il pourrait le faire).

Le dialogue avec l’application

Comme toujours il existe plusieurs façons d’arriver au but. Ainsi, les instances de Job doivent être capable (lorsque le job tourne) de rapporter leur activité à l’application principale. J’ai retenu deux événements : un reporting de l’activité (“je dors” / “je travaille”) et un reporting spécial indiquant la fin du job.

Tout cela pourrait fort bien être géré avec un seul point d’accès ou au contraire avec un point d’accès différent pour chaque possibilité. De même la classe Job pourrait définir des Events auxquels le programme principal serait abonné de façon classique.

Ici, histoire de changer, j’ai préféré déclarer deux propriétés Action<Job> dans la classe Job. Comme le montre le code d’initialisation des jobs ci-avant, JobDone et ReportJob pointent vers les méthodes OnJobDone et OnJobReport de la page principale.

Si vous revenez sur le code de la classe Job vous verrez qu’en effet, si des actions sont définies, elles sont directement appelées. Il n’y a guère de différence avec une gestion d’événements, c’est simplement une autre façon de faire.

Prenons la méthode la plus simple d’abord :

OnJobReport

Cette méthode (une Action<Job>) sera appelée par chaque job pour signaler son activité. voici son code :

   1: public void OnJobReport(Job job, JobState jobState)
   2:        {
   3:            if (job.View.Dispatcher.CheckAccess()) job.View.JobState = jobState;
   4:            else
   5:                job.View.Dispatcher.BeginInvoke(() => job.View.JobState = jobState);
   6:        }

Le but est ici de mettre à jour la led du JobView correspondant, donc de faire changer l’état du UserControl JobView associé à l’instance de Job. C’est pour garder ce lien que la propriété View existe dans la classe Job, cela évite de gérer un dictionnaire supplémentaire associant l’objet d’interface avec l’instance de Job. Bien entendu, puisque chaque Job connait son objet d’interface il pourrait agir directement dessus sans passer par la page principale. Mais il devrait le faire de la même façon ! Car attention, c’est le seul endroit où le multi-tâche commence à poser problème : le job veut mettre à jour un objet d’interface depuis un thread différent … et bien entendu cela n’est pas possible.

Que l’on passe par une Action gérée dans la page principale ou bien que la classe Job agisse directement sur l’objet d’interface il faut prendre en compte le fait que ce dernier a été créé dans un autre thread et qu’il ne peut pas être manipulé par un autre thread que celui où il a vu le jour.

Ceci explique le code de OnJobReport : On récupère l’instance de Job qui se signale dans le paramètre de la méthode (Action<Job> rend possible d’appeler une action possédant un paramètre de type Job), puis on récupère l’objet View associé et là on demande à ce dernier “CheckAccess()”. Si la méthode renvoie “true” c’est que l’appel a lieu depuis le même thread que celui qui contrôle l’objet JobView, sinon la méthode retourne “false”. Dans le 1er cas on peut se permettre d’agir directement sur l’objet d’interface, dans le second cas il faut passer par une invocation : BeginInvoke qui prend un delegate en paramètre, delegate que je remplace ici par une expression Lambda.

On notera que tel qu’est conçue l’application le premier cas n’arrivera jamais (le signalement d’un Job émane toujours d’un thread différent de celui de l’interface). C’est donc pour que l’exemple soit complet que j’ai ainsi structuré le code avec l’appel à CheckAccess().

Le travaille de BeginInvoke est de transférer le code à exécuté (le delegate remplacé ici par une expression Lambda) au thread principal afin que la modification de l’objet d’interface soit effectuée par ce dernier.

Imaginez un fleuve. D’un côté un troupeau de moutons qui courent dans tous les sens. De l’autre un camion qui attend pour les emmener pus loin. Le camion possède une passerelle qui ne peut accepter qu’un seul mouton à la fois. BeginInvoke est un pont construit sur la rivière qui ne laisse passer qu’un seul mouton à la fois mais qui peut en entasser plusieurs en file le temps que ceux qui sont en tête puissent monter, un par un, dans le camion.

L’avantage est que d’un côté cela peut sautiller dans tous les sens, mais que de l’autre on ne gère bien qu’un mouton à la fois. Le pont jouant à la fois le rôle de transporteur d’information d’un coté à l’autre, d’un thread à l’autre, et de zone tampon entre des tas moutons excités et le camion avec sa passerelle mono-mouton.

OnJobDone

Je vous laisse découvrir ce code dans le projet fourni. Rien de spécial, les mêmes stratégies sont utilisées. Pour savoir si tous les threads sont terminés OnJobDone utilise une requête Linq sur la liste des jobs et compte ceux dont la propriété Done est encore à “false”. Cela permet à la fois d’inscrire sous le bouton de lancement le nombre de threads restant en course, et de réactiver le bouton de démarrage lorsque plus aucun job ne tourne.

Le lancement des jobs

Lorsqu’on clique sur le bouton de démarrage c’est à ce moment que les threads doivent être créés et exécutés. Le code suivant effectue ce travail :

   1: private void btnStartJobs_Click(object sender, RoutedEventArgs e)
   2:         {
   3:             btnStartJobs.IsEnabled = false;
   4:             foreach (var job in jobs)
   5:             {
   6:                 var t = new Thread(job.DoJob);
   7:                 t.Start();
   8:             }
   9:             Thread.Sleep(100);
  10:             OnJobDone(null);
  11:         }

La liste des jobs créée et initialisée au chargement de l’application est balayée, un nouvel objet Thread est créé pour chaque Job. En paramètre lui est passé le nom de la méthode qui va effectuer le travail en multi-tâche “DoJob()” (de la classe Job). Le thread est ensuite démarré.

En fin de méthode on laisse une petite pause pour ensuite rafraichir l’affichage (le comptage des threads en cours d’exécution). S’agissant d’un exemple je n’ai pas trop chargé le code et je réutilise ici la méthode OnJobDone. Cela n’est pas à conseiller dans une application réelle pour plusieurs raisons. La principale étant le respect de la sémantique. Que l’application principale appelle “OnJobDone” alors qu’elle vient de démarrer les jobs est un contresens. De plus cette méthode est conçue pour être appelée depuis les jobs. Ce genre de mélange ne peut que rendre le code difficile à maintenir et à comprendre. Evitez ce genre tournure rapide… Ici il aurait fallu extraire ce qui est utilise à la fois aux threads et à l’application, le placer dans une autre méthode. Cette dernière aurait été appelée depuis OnJobDone et depuis le code ci-dessus. ma fainéantise a un prix exorbitant en plus  : il me faut cinquante fois plus de mots dans le billet pour expliquer et justifier ce mauvais code que de code propre écrit directement. Pan sur les doigts ! C’est une belle leçon… Faire propre tout de suite est toujours une économie !

Conclusion

Faire du multi-tâche sous Silverlight s’avère très simple pour peu qu’on utilise les bonnes patterns. Mais pas d’illusion non plus : ce billet ne traite pas de toutes les façons de faire du multi-tâche. Il existe des cas plus complexes, d’autres moyens (comme le background worker) et des situations plus difficile à gérer. Toutefois les bases posées ici devraient vous permettre de vous lancer dans ce mode de développement devenu obligatoire avec la stagnation des fréquences des processeurs et l’augmentation du nombre de cœurs en contrepartie… Et depuis SL3 il est possible de jouer avec toute la puissance de l’hôte pour des applications encore plus réactives et riches. Ne vous en privez pas !

Le code du projet :

blog comments powered by Disqus