Dot.Blog

C#, XAML, WinUI, WPF, Android, MAUI, IoT, IA, ChatGPT, Prompt Engineering

Thread vs Parallélisme

[new:30/03/2014]Beaucoup n’ont pas encore infléchi leur style de programmation vers le multitâche pourtant devenu indispensable. Certains l’ont fait et pensent que jouer avec les Threads est suffisant. En réalité le Threading n’est pas forcément équivalent à du parallélisme. Faisons le point !

Monotâche, mutitâche, parallèlisme…

Il existe plusieurs façons de faire tourner un code. Dans sa version la plus simple c’est le mode monotâche qui est utilisé. Peu importe le niveau technologique de l’ordinateur faisant tourner ce code, le développeur se concentre sur l’écriture d’un code linéaire, exactement comme on le faisait avec l’assembleur Z80 il y a de cela bien longtemps.

Dans la pratique la machine ne consacrera qu’un seule cœur à l’exécution d’un tel code et encore sera-t-il peut-être partagé entre plusieurs processus différents. Autant dire que les performances ne seront pas aux rendez-vous.

Une autre façon de faire du tourner du code est d’utiliser le multitâche. Ici on utilise des classes comme System.Threading.Thread par exemple. Mais un Thread n’est jamais que du partage de temps sur un cœur rien de plus… Il faut en exécuter plusieurs à la fois sur un OS qui sait les distribuer sur les différents cœurs pour obtenir le résultat escompté. Et cela beaucoup de développeurs l’oublient…

Enfin, la façon la plus moderne de faire tourner un code, moderne non pas par gout excessif d’une certaine modernité creuse et vide de sens basée sur l’apparence mais moderne parce que plus efficace, est d’utiliser le parallélisme. Ici le code sera exécuté simultanément sur plusieurs cœurs pour exploiter au mieux les capacités de la machine.

Loin est mon intention ici de faire un cours détaillé sur tout cela. Mon objectif est de rappeler au lecteur la différence essentielle entre ces trois modes d’exécution et surtout d’éviter comme je le vois trop souvent qu’ils prennent des vessies pour lanternes c’est à dire du threading pour du parallélisme…

Threading <> Parallelisme

La confusion la plus terrible qu’on puisse voir ces derniers temps est celle qui est faite entre programmation multitâche utilisant des Threads et programmation parallèle utilisant d’autres procédés bien plus sophistiqués.

Certes est-il possible d’exploiter les cœurs d’une machine en jouant avec des threads. Mais encore faut-il savoir combien de cœurs expose-t-elle… Car lancer 4 threads sur une machine dual core n’a que peu de sens, chaque cœur fera tourner deux threads et passera ainsi une partie non négligeable de son temps à effectuer ce qu’on appelle du “time slicing” et du “context switching”. Entendez simplement par là que devant exécuter plusieurs tâches à la fois (ce qui n’est pas possible), chaque cœur tentera de simuler la simultanéité en exécutant chaque thread l’un après l’autre en alternance, cette dernière étant assez rapide pour tromper l’humain et lui donner l’impression de la simultanéité.

Mais il ne s’agit que de “time slicing”, de partage de temps, de découpage de temps. De charcutage de temps. Et quand on coupe les cheveux en quatre, on n’obtient pas quatre cheveux mais le même cheveu en quatre morceaux quatre fois plus petits que l’original… On ne gagne donc rien en termes de performance. On peut gagner en impression de fluidité, mais ce n’est pas ce que nous cherchons ici.

Passer d’un thread à l’autre n’est pas un job si facile pour un processeur (ou un cœur de processeur multi-cœur). Il lui en effet mémoriser le contexte d’exécution du thread qui va être abandonné avant de passer au suivant afin de pouvoir recharger ce contexte pour reprendre le premier thread… C’est le “context switching”. Les fondeurs ont certes fait de gros progrès dans l’implémentation de ces possibilités dans leurs puces. Mais malgré tous les efforts, 1+1 fait toujours deux, voire même un peu plus, mais jamais moins ! Un peu plus car le temps d’exécution de deux threads sur un même cœur est augmenté du temps des context switching… De faits exécuter deux threads identiques sur un même cœurs ne durera pas deux fois plus longtemps mais légèrement plus.

On perd du temps, on n’en gagne jamais à ce jeu là donc…

Le parallélisme lui est basé sur une autre approche : on sait combien il y a de cœurs disponibles et on essaye de les charger au maximum (mais pas trop) en découpant habilement un code à exécuter pour que chaque “tranche” de ce dernier puisse s’exécuter indépendamment. Si on dispose de bons algorithmes pour découper le code original et de “n” cœurs disponibles il est donc possible de diviser le temps d’exécution du code original par “n”.

Bien entendu dans ce mode parallèle il y a aussi un peu de gestion à prévoir, ce qui consommera du temps. On ne divisera donc pas réellement le temps initial par “n”, la réalité sera légèrement en dessous. Mais plus “n” est grand, plus le gain est faramineux !

.NET et les tâches

Le Framework .NET a su au fil du temps s’améliorer dans de telles proportions qu’on se demande bien quel besoin il y aurait de créer une nouvelle plateforme. Ceci explique peut-être l’engouement modéré des développeurs pour WinRT. Quand on a déjà ce qui se fait de mieux, pourquoi aller chercher plus loin…

Parmi les améliorations que .NET a su porter depuis sa création on trouve tout un ensemble d’ajouts liés au multitâches et au parallélisme.

La notion de Thread, de ThreadPool, de Lock, etc, existent déjà depuis longtemps. Mais d’autres modes ont été ajoutés pour traiter plus spécifiquement du parallélisme. C’est notamment la fameuse TPL, Task Parallism Library.

Cette bibliothèque de code est basée non plus sur le concept de Thread mais sur celui de Task (tâche) qui représente une opération asynchrone. D’un certain point de vue les tâches ressemblent bien entendu aux Threads ou aux ThreadPools mais en se situant à un niveau d’abstraction bien supérieur.

La TPL a d’abord été présentée comme une librairie à part, longtemps en test (la CPT des Parallel FX était déjà disponible en 2008). D’où son nom de “TPL” avec un L comme Library. Un ajout donc. Mais à partir de .NET 4.0 cette bibliothèque a été intégrée au framework. Il ne s’agit plus d’un ajout plus ou moins expérimental mais bien du Framework .NET lui-même !

Cet ensemble se divise en deux parties, PLINQ (Parallel Linq) qui ajoute la parallélisation aux requêtes LINQ et la Task Paralel Library qui s’occupe plus directement du parallélisme au sein du code traditionnel.

Bien que cet ajout fut essentiel, peu de développeurs se sont intéressés à PLINQ et TPL. C’est un tort !

Et sans entrer dans un grand cours académique, et comme je l’indiquais plus haut, mon ambition du jour est fort humble : juste vous rappeler l’existence de tout cela et vous montrer rapidement par l’exemple les principales différences entre tout ces modes d’exécution.

J’avais déjà abordé le sujet de façon plus ou moins directe dans quelques billets, il s’agit donc d’en remettre une petite couche pour vous inciter à regarder tout cela de plus près. A force j’y arriverais !

Pour information vous trouverez sur Dot.Blog :

 

Cela fait donc environ 4 ans que régulièrement je viens sonner la petite cloche du parallélisme pour attirer votre attention… Certains l’ont bien entendu teinter, pour d’autres j’espère que cette fois-ci son son mélodieux arrivera à vos délicates oreilles pour remonter votre nerf auditif et enfin réveiller certains neurones qui devraient déjà bosser sur le sujet depuis un moment ! Sourire

Un exemple simple

J’aime les exemples, ils parlent souvent mieux que de longs discours. Et les exemples simples sont ceux que je préfère par dessus tout…

Pour vous faire sentir la différence entre tous les modes d’exécution évoqués ici, je vous propose ainsi un petit exécutable en mode console qui va utiliser trois façons différentes de faire tourner la même séquence. Chaque mode sera chronométré et j’accompagnerai l’exécution de chacun d’un cliché issu du moniteur de performance de Windows pour que vous puissiez voir la différence d’occupation des cœurs.

Le principe

J’ai dit simple… Donc une routine toute bête qui s’amuse à ajouter un million de fois un “x” à une chaîne de caractères. De la façon la plus bête, la moins subtile qu’il soit histoire que cela prenne assez de temps pour mesurer le temps d’exécution et afficher PERFMON de Windows, le remettre à zéro et lancer CAPTURE pour prendre un cliché de la fenêtre…

Le visuel

On est loin de mes grands discours sur l’UI et l’UX ici ! Un simple projet console avec un menu “à l’ancienne” comme on le faisait il y a 30 ans :

image

On dispose donc de trois choix, le monde standard, le mode threadé et le mode parallèle (plus une possibilité de quitter le programme).

Commençons par le commencement…

Mode Standard

Le mode standard c’est la “programmation à papa”. Je fais une boucle FOR et j’ajoute un million de fois un “x” à la chaîne de caractères.

Sin on fait abstraction de la non utilisation d’un StringBuilder (c’est fait exprès pour que le test dure plus longtemps), c’est un code simple, comme on en voit partout, donc 99% du code écrit encore aujourd’hui :

        public static void Run1Million()
        {
            var s = "";
            for (var i = 0; i < 1000000; i++)
                s = s + "x";
        }

 

J’avais prévenu, c’est pas de la haute voltige !

Le moniteur des performances non montre quelque chose de ce genre durant l’exécution de ce merveilleux bout de code :

image

La machine étant occupée à d’autre petites choses (musique, caméras de surveillance, etc), ses huit coeurs ne sont pas totalement au repos, le bleu et le violet bossent à mi-temps sans trop se fouler, les autres roupilles au fond du diagramme, et on voit le cœur 0 qui s’agite tout seul (le trait rouge) faisant un travail pas trop fatigant (jamais il ne monte à 100%) mais constant alors que tout le monde se roule les pouces. Le garbage collector de .NET doit être responsable d’un des deux autres threads qui travaillent un peu, car ma séquence oblige à créer 1 million de string qui sont abandonnées à chaque fois (les string sont immuables en .NET, rappelez-vous… faire “x=x+”y”” oblige en fait à créer une nouvelle string x et à disposer l’ancienne. On s’imagine bien à quel point cela peut stresser le GC !).

Ce diagramme des performances c’est un peu comme dans notre métier, il y a un développeur stagiaire qui bosse, un chef de projet qui discute à la machine à café, un directeur de projets qu’on cherche car il doit être ‘quelque part’ dans le bâtiment, un DSI qui est ‘à l’extérieur’ et un big boss qui est au golf.

Au bout de 5 minutes 27 et quelques millisecondes dont je vous fais grâce, ce manège d’esclavagiste prend fin et le cœur 0 peut enfin prend un repos mérité sous les yeux réprobateurs des autres qui se disent que ce n’est pas normal qu’un stagiaire sortent des bureaux aussi “tôt” - même s’il est déjà 21h45.

L’efficacité de notre programme est à l’exemple de celle des sociétés qui fonctionnent comme ma petite caricature : elle est nulle.

Mode threadé

Armé de bonnes intentions le stagiaire veut faire voir qu’il a bossé un peu, et il se dit qu’il va utiliser un thread pour améliorer les choses.

Ce qui donne ce code-ci :

        public static void Run1MillionInThread()
        {
            var t = new Thread(Run1Million);
            t.Start();
        }

Un thread est créé et activé pour exécuter le code précédent.

image

On a bien gagné en “fluidité”, le thread principal de notre programme console a été libéré tout de suite et affiche déjà les résultats alors que le travail vient à peine de commencer… Bien entendu la durée affichée est fausse.

Quant au moniteur de performances…

image

 

Avant le lancement de la méthode, tous les cœurs sont au repos, au moment de l’activation c’est le grand branle-bas de combat tout le monde s’affole, et ensuite on obtient un diagramme très proche de l’exécution précédente, c’est à dire un cœur qui travaille (le bleu) et les autres qui flemmardent.

Bref, monotâche ou threading, ça revient au même point de vue performances. Ca serait presque pire avec le code exécuté dans un thread. Le seul gain véritable ici est d’avoir libéré le thread principal ce qui permet à la fenêtre de notre application de rester fonctionnelle et réactive. C’est déjà pas mal. Mais ce n’est pas ce qu’on vise ici. Dommage.

Mais si on vise la performance pure, il faudrait découper notre boucle de 1 million en plusieurs threads exécutés en même temps sur des cœurs différents puis concaténer le résultat. Ca va devenir du travail à écrire tout ça !!!

Le mode parallèle

Heureusement il n’y aura rien à écrire, en tout cas pas de ce genre là. En utilisant les possibilités du Framework .NET, nous allons profiter des algorithmes de ce dernier pour écrire quelque chose de fort simple mais de redoutablement efficace…

Le code ressemble maintenant à celui-là :

         public static void Run1MillionParallel()
        {
            var s = "";
            Parallel.For(0, 1000000, x => s = s + "x");
        }

 

C’est très court et très efficace comme vous allez le voir.

Redoutablement efficace… Le moniteur de performances nous montre ceci :

image

Huit cœurs qui démarrent et qui bossent enfin ! Tous unis pour résoudre un même problème avec comme volonté de le faire le plus vite possible.

Le chrono est sans appel : 24 secondes et quelques millisecondes.

24 secondes au lieu de 5 minutes et 27 secondes pour le code standard !!!

24 au lieu de 327 secondes… 13, 625 fois moins de temps alors que n’utilisons pas 14 cœurs mais seulement 8 qui sont malgré tout un peu occupé à tout le reste (comme je le disais, musique, caméras, et plein d’autres petites choses) !

Conclusion

Comme je l’ai annoncé, ce billet ne sera pas un cours sur TPL ou le threading, il existe une tonne de docs sur le sujet et je pense en plus que j’y reviendrais en détail prochainement tellement je le pense nécessaire.

Jute un rappel : 24 secondes au lieu de 327, pour un simple échange dans notre code d’une boucle for classique par une Parallel.For().

C’est à vous de voir…

Mais Stay Tuned !

blog comments powered by Disqus