Dot.Blog

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

C# et .NET : Threads vs Parallélisme, toujours pas clair ?

Toujours trop peu de développeurs ont infléchi leur style de programmation vers le multitâche et le parallélisme pourtant devenus indispensables. 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. Il est temps d’en “remettre une couche” !

Un match toujours à jouer

Oui, c’est un sujet dont je parle depuis des années mais quand je vois le code que j’audite ou même quand je discute avec des développeurs je suis bien obligé de m’apercevoir que le message n’est pas vraiment passé. Alors certes je n’agis qu’à mon modeste niveau d’expert un peu connu et il est fort possible que ma voix ne porte pas assez fort et loin pour convaincre les foules. Humblement alors je vais ainsi rejouer le match encore une fois comptant sur la répétition plus que sur l’effet de l’argument d’autorité pour que petit à petit tout cela devienne plus clair pour certains. Gagner de nouveaux adeptes à chaque fois est une victoire, même limitée.

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 8080 il y a de cela bien longtemps.

Dans la pratique la machine ne consacrera qu’un seul 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 mais on sera au maximum de ce que peut fournir un cœur de la machine.

Une autre façon de faire 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, c’est un sujet que j’ai abordé plusieurs fois (dont une conférence que vous trouverez sur YouTube : Voyage au pays de l’Asynchronisme qui reprend tout cela en présentant l’asynchrone, méthode encore plus moderne). Mon objectif est de rappeler au lecteur la différence essentielle entre ces trois modes d’exécution (asynchronisme à part donc, voir la vidéo) 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 & Parallélisme

La confusion la plus terrible qu’on puisse encore 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” (ou time sharing) 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/sharing”, de partage de temps, de découpage de temps. De charcutage de temps.

Quand on coupe un cheveu en quatre, on n’obtient pas quatre cheveux mais le même cheveu en quatre morceaux quatre fois plus petits que l’original… (s’ajoute le temps nécessaire à les découper en plus).

On ne gagne donc rien en termes de performance. On peut gagner en impression de fluidité, mais ce n’est pas ce que nous recherchons ici (même si c’est un point ergonomique essentiel si on veut soigner son UX).

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 est en effet nécessaire de mémoriser le contexte d’exécution du thread qui va être abandonné avant de passer au suivant afin de pouvoir le recharger quand il reviendra à ce 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 ! D’où l’intérêt d’avoir de nombreux cœurs dans une machine mais aussi de disposer de logiciels développés correctement !

.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 même au travers de UWP et du come back de .NET avec la version 5 et de .NET Core. Quand on a déjà ce qui se fait de mieux, pourquoi aller chercher plus loin… Pendant longtemps Microsoft n’a pas voulu mettre en avant ce qu’ils avaient de mieux et a perdu son temps à forcer des environnements plus restrictifs donc moins attrayants. Heureusement les années passant, .NET s’incrustant, le revirement de l’annonce de .NET 5 remet un peu les choses en place en mettant en valeur ce framework si bien pensé dès le départ.

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, vous avez donc 12 ans de retard au moment où j’écris, ça va ? pas trop la honte ? Smile ). 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 ! Et je ne donnerai pas la date de sortie de .NET 4.0 pour ne pas vous enfoncer plus encore… (je suis bon, trop bon).

Cet ensemble se divise en deux parties, PLINQ (Parallel Linq) qui ajoute la parallélisation aux requêtes LINQ et la Task Parallel 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 aujourd’hui c’est carrément une faute professionnelle.

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 Rx Extensions, TPL et Async CTP : L’asynchronisme arrive en parallèle ! (un papier de fin 2011)
ou Parallel FX, P-Linq et maintenant les Reactive Extensions… (écrit en juillet 2010)

Cela fait donc environ 10 ans (une paille !) que régulièrement je viens sonner la petite cloche du parallélisme pour attirer votre attention… Certains l’ont bien entendu tinter et n’ont plus besoin de ces rappels, je les remercie d’avance de zapper ce post et d’attendre le prochain, pour les autres j’espère cette fois-ci que le 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 !

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… En XAML ou pour du code MVVM c’est toujours difficile de trouver des exemples simples, mais là nous sommes dans les entrailles de la machine et un bon vieux programme Console sera bien suffisant ! ça repose Winking smile 

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 pour que vous puissiez voir la différence d’occupation des cœurs. Les tests ont été fait sur une machine assez ancienne (avec un vieux Windows 7) pour éviter qu’on puisse mettre les gains de performances sur les 12 cœurs de mon Alienware watercoolé et overclocké, et puis surtout vous verrez que 8 cœurs sur les graphiques c’est autrement plus pratique à lire que 12… On remarquera d’ailleurs que ce sont des cœurs particuliers la machine n’en ayant que 4 réels, le doublage étant malgré tout réalisé en hardware par l’architecture Intel. On le prendra donc comme 8 cœurs réels.

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 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 40 ans (j’aurais du mettre l’affichage en vert pour faire plus vintage !)

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.

Si 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 (vieux) moniteur des performances non montre quelque chose de ce genre durant l’exécution de ce merveilleux bout de code :

La machine étant occupée à d’autre petites choses (musique, caméras de surveillance, etc), ses huit cœurs 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 façon confiné en chômage technique, 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 strings 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 une SSII, 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 prendre un repos syndical 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.

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…

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 et c’est l’un des objectifs d’une programmation moderne. 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 =&gt; 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 :

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. la machine est à 100% mais c’est le but.

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 12 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 dont ma propre prose et mes vidéos. Juste 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 !

Faites des heureux, PARTAGEZ l'article !