Developpez.com - Microsoft DotNET
X

Choisissez d'abord la catégorieensuite la rubrique :


Introduction à la programmation parallèle avec .Net 4.0 et C#

Utilisation de la Task Parallel Library (TPL)

Date de publication : 21/07/2009 , Date de mise à jour : 21/07/2009

Par Ronald Vasseur (Autres articles) (Blog)
 

Dans cet article nous allons voir quelles sont les principales nouveautés apportées par le Framework .Net 4.0 en matière de parallélisassions des traitements. Nous verrons notamment comment utiliser la Task Parallel Library (TPL) qui permet de paralléliser facilement quasiment n'importe quel traitement, et nous verrons également comment utiliser les nouvelles boucles Parallel.For et Parallel.ForEach qui permettent d'effectuer les traitements de boucles For et ForEach sur l'ensemble des cours d'un processeur sans pour autant avoir à développer quoique ce soit de spécifique. Nous verrons également qu'elles peuvent êtres les limites de ces nouveaux mécanismes et dans quelles conditions précises ils peuvent êtres bénéfiques.

0. Introduction
I. Présentation de la Task Parallel Library (TPL)
I-A. Créer une Task
I-B. Utiliser les Task :
II. Présentation de Parallel.For et Parallel.ForEach
II-A. Parallel.For
II-B. Parallel.ForEach
III. Conclusion


0. Introduction

Jusqu'il y à encore un ou deux ans on mesurait la rapidité et la puissance de calcul d'une machine à la fréquence de son processeur. Cette fréquence, en Gigahertz, mesure le nombre d'opérations qu'un microprocesseur peut effectuer en une seule seconde. A l'heure actuelle cette fréquence se situe en moyenne aux alentours de 3 GHz, cela signifie 3 milliards d'opérations en une seconde, cela est certes énorme mais cette valeur a tendance à stagner pour des raisons principalement techniques. Les processeurs actuels, à base de silicium, ne peuvent pas pour le moment monter beaucoup plus haut à cause des problèmes de surchauffe et de dissipation thermique. Les fondeurs de puces, condamnés à innover dans ce marché hyperconcurrentiel, ont trouvé la parade, cela consiste à paralléliser les puces dans un même package pour ainsi augmenter la puissance théorique du processeur. Sur le papier cette solution est très prometteuse sauf que dans la réalité très peu d'applications savent tirer parti de microprocesseurs multi-cours, en effet, la quasi-totalité des applications est développée de telle manière qu'elles ne savent pas paralléliser leurs traitements sur plusieurs puces mais les exécutent de manière séquentielle. Mais avec l'apparition et la généralisation annoncée des puces multi-cours les développeurs sont désormais obligés de prendre ces données en compte dès que les applications qu'ils développent sont gourmandes en terme de calcul, ou doivent s'exécuter rapidement. Si les développeurs ne parallélisaient pas leurs traitements jusqu'à maintenant c'est que cela n'était premièrement pas nécessairement utile mais surtout car cela était beaucoup plus complexe à designer, développer, débugger.

.Net 4.0 dans sa version Beta 1 présentée il y à quelques semaines par Microsoft apporte des solutions à cette nouvelle problématique par le biais d'extension et de nouvelles classes permettant de paralléliser facilement des traitements que l'ont faisait jusqu'à maintenant de manière séquentielle. Ces extensions existaient déjà en .Net grâce aux « Parallels Extension » mais celles-ci n'étaient pas intégrées au Framework .Net et n'allaient pas aussi loin. Avec .Net 4.0 la barre est montée d'un cran et Visual Studio 2010 va rendre la tache beaucoup plus aisée aux développeurs pour ce qui concerne le debugging.

Voyons sans plus attendre ce que nous apporte le Framework .Net 4.0 en matière de parallélisassions des traitements, tout particulièrement sur la TPL, la « Task Parallel Library ».


I. Présentation de la Task Parallel Library (TPL)

Pour faire simple, cette librairie est un ensemble de classes permettant aux développeurs de paralléliser des traitements de manière simple. Ces classes permettent d'exécuter des traitements en parallèle sur les multiples cours de la machine exécutant le code de votre application. Cela signifie que de manière transparente votre application, ou du moins les traitements effectués au travers de la TPL, se feront sur 2, 4, 8 ou n cours en même temps et cela dynamiquement. Il ne sera pas nécessaire de recompiler votre application pour effectuer des traitements parallèles sur 2 cours, puis plus tard sur 16, quand les machines adéquates seront disponibles, cela donne donc une grande évolutivité et capacité de montée en charge à votre application. Montée en charge qui sera cette fois-ci presque linéaire avec l'évolution de la puissance brute de vos machines. A ceci prêt qu'il y quand même un surcoût, en terme de capacités de traitement, à l'utilisation de ces mécanismes de haut niveau, et de plus tout ne peut pas être parallélisé dans une application.

Jusqu'à maintenant lorsque l'on voulait faire des traitements en parallèle il fallait soit gérer ses threads manuellement, soit utiliser le ThreadPool, ce dernier, qui bien que très efficace n'était pas très simple d'utilisation. Le Framework .Net 4.0 nous rend la vie beaucoup plus facile, en effet une surcouche à été ajoutée au ThreadPool, il est ainsi possible de l'utiliser grâce au namespace System.Threading.Tasks.

System.Threading.Tasks est un namespace contenant 8 classes permettant la création et la gestion de Tasks. Pour faire simple, une Task est le conteneur d'une opération qui sera parallélisée à d'autres et exécutée dans le Thread Pool. Il suffit donc de créer une Task, de lui assigner un traitement à réaliser et de l'exécuter. Le runtime .Net détermine automatiquement combien de Tasks (ou tâches) il est possible d'exécuter simultanément (cela dépendra du nombre de cours disponibles) et les gère au travers du ThreadPool. Il est possible d'exécuter les Tasks dans un ordre donné, d'attendre la fin d'une Task pour en excéuter un autre, ou encore une Task peut retourner ou non un résultat, etc etc. Il est possible de couvrir de très nombreux scénarios grâce à ce namespace. Voici des exemples concrets de ce qu'il est possible de faire :


I-A. Créer une Task

Il existe deux manières de créer une Task, voyons comment procéder.
Déclaration des Tasks
            // Différentes manières de déclarer des Tasks

            // Avec le constrcuteur
            Task taskA = new Task(MethodA);
            Task taskB = new Task(MethodB);

            taskA.Start();
            taskB.Start();

            // Avec la TaskFactory
            Task taskC = Task.Factory.StartNew(MethodC);

            // Attendre la fin des traitements des tâches en paramètres
            Task.WaitAll(taskA, taskB, taskC);

I-B. Utiliser les Task :

Une Task ne retournant pas de résultat

Pour une Task ne retournant pas de résultat il faut utiliser la Classe Task. Les méthodes qui lui seront passées en paramètres auront un type de retour void.
Task ne retournant pas de résultat
    Task taskC = Task.Factory.StartNew(MethodC);

Une Task retournant un résultat

Si notre Task doit retourner un résultat il suffit d'utiliser la classe générique Task<TResult> en spécifiant le type du résultat. Voici un exemple d'une Task retournant une chaîne de caractères :
Task retournant un résultat (de type double)
       // Déclaration d'un tableau de Tasks
            Task<double>[] tasks = new Task<double>[] {
            Task<double>.Factory.StartNew(() => AddSqrt(1200)),
            Task<double>.Factory.StartNew(() => AddSqrt(5000))
            };
Attendre l'exécution de toutes les Task pour poursuivre l'exécution de l'application :

Sans appeler la méthode WaitAll() le Thread ayant appeler l'exécution des Task va poursuivre sans attendre l'exécution des Tasks et alors il ne sera pas possible de savoir si elles se sont terminées et de connaître le résultat. Pour pouvoir obtenir ces précieuses informations il suffit d'appeler la méthode WaitAll() et de spécifier en paramètres la liste des Task dont l'on attend la fin.
Task.WaitAll(tasks);
Attendre le résultat d'une Task pour en démarrer une autre :

Parfois l'on ne souhaite pas exécuter toutes nos Task en parallèle car une Task dépend du résultat ou de l'exécution d'une autre Task, dans ce cas l'on utilise la méthode WaitAny() ou lieu de la méthode WaitAll() pour alors spécifier de quelle Task nous attentons le résultat. Voici un exemple de l'exécution de trois Task : A, B et C. B ne pouvant être exécuté sans obtenir le résultat de A.
Ordonancement des Tasks
            Task taskA = Task.Factory.StartNew(MethodA);

            Task.WaitAny(taskA);

            Task taskB = Task.Factory.StartNew(MethodB);
            Task taskC = Task.Factory.StartNew(MethodC);

            Task.WaitAny(taskB, taskC);

II. Présentation de Parallel.For et Parallel.ForEach

Une chose qui par nature est séquentielle est l'exécution des For et ForEach, en effet on boucle élément après éléments sur une collection. .Net 4.0 apporte une nouveauté, les Parallel.For et Parallel.ForEach qui fonctionnent de la même façon que leurs ainés mais ils sont capables de d'effectuer plusieurs boucles simultanément sur des machines possédant de multiples cours. Voyons maintenant comment utiliser ces boucles :


II-A. Parallel.For

Je pense que tous le monde sait comment utiliser une boucle For, je ne vais donc pas m'étendre sur l'utilisation d'un For classique, cependant voici un mocreau de code qui nous permettra de comparér les performances avec le Parallel.For un petit peu plus tard. Il y aura un code dit « simple » et un dit « complexe », le code complexe offre juste plus de code à exécuter et des opérations qui prennent plus de temps comme un « Parse » ou un calcul de racine carrée.
Exemples de For classiques
        // Exemples de For classiques
       
        // Utilisation d'un For classique avec du code 'simple'
        private static void TestClassicForSimple()
        {
            int nbr = 0;
            for (int i = 0; i < 15000000; i++)
            {
                nbr = i * 2;
            }
        }

        // Utilisation du For classique avec du code 'complexe'
        private static void TestClassicForComplex()
        {
            int nbr = 0;
            double d = 0;
            for (int i = 0; i < 1000000; i++)
            {
                nbr = i * 2;
                d = ((Math.Sqrt(double.Parse("9999")) 
                    * Math.Sqrt(double.Parse("5000")) 
                    * Math.Sqrt(double.Parse("7777"))) 
                    - Math.Sqrt(20000) 
                    * Math.Sqrt(double.Parse("1000000")) 
                    + Math.Sqrt(double.Parse("88888")) 
                    * Math.Sqrt(double.Parse(nbr.ToString())));
            }
        }

Voyons maintenant ce qui nous intéresse réellement, comment utiliser les nouveaux Parallel.For :
Exemples de Parallel.For
        // Exemples de Parallel.For


        // Utilisation du Parallel.For avec du code 'simple'
        private static void TestParallelForSimple()
        {
            int nbr = 0;
            Parallel.For(0, 15000000, i =>
            {
                nbr = i * 2;
            });
        }

        // Utilisation du Parallel.For avec du code 'complexe'
        private static void TestParallelForComplex()
        {
            int nbr = 0;
            double d = 0;
            Parallel.For(0, 1000000, i =>
            {
                nbr = i * 2;
                d = ((Math.Sqrt(double.Parse("9999")) 
                    * Math.Sqrt(double.Parse("5000")) 
                    * Math.Sqrt(double.Parse("7777"))) 
                    - Math.Sqrt(20000) 
                    * Math.Sqrt(double.Parse("1000000")) 
                    + Math.Sqrt(double.Parse("88888")) 
                    * Math.Sqrt(double.Parse(nbr.ToString())));
            });
        }

Sans plus attendre passons aux résultats des tests. Comme vous pouviez vous y attendre les temps d'exécution dans le tableau ci-dessous sont presque deux fois plus courts avec Parallel.For que lorsque l'on exécute le For classique de manière séquentielle. Mais il y à cependant un résultat à prendre en compte, lorsque le code exécuté dans le For est simple, alors le For classique s'avère plus performant que le Parallel.For, cela s'explique par le fait qu'il à un « coût » à utiliser la mécanique qui va gérer la parallélisassions des traitements, il faut donc utiliser cette boucle parallèle uniquement quand le traitement en vaut la peine, si c'est uniquement pour une addition il vaut mieux laisser tomber et se contenter d'un For classique. Par contre dès que les traitements sont un peu plus complexes on voir nettement l'avantage de la parallélisassions avec un temps d'exécution quasiment réduit de moitié sur une machine DualCore.

Résultats des tests :

  For Parallel.For
Simple code 68ms 253ms
Complex code 2039ms 1108ms

II-B. Parallel.ForEach

Le Parallel.ForEach présente les mêmes caractéristiques que le Parallel.For, c'est-à-dire que son utilisation à un cout qu'il faut prendre en compte avant de l'utiliser. Mais dans de bonnes conditions il s'avère presque deux fois plus rapide qu'un ForEach classique sur une machine classique. Voici les syntaxes qui permettent de l'utiliser :


Avec un delegate :
Parallel.ForEach avec un delegate
     Parallel.ForEach<Guid>(list, delegate(Guid g)
            {
                double d = double.Parse(Math.Sqrt(g.ToString().Length).ToString());

                double f = Math.Sqrt(d) 
                    * double.Parse("155") 
                    - Math.Sqrt(double.Parse("555"));

                double h = double.Parse(f.ToString());
            });
Avec une lambda expression :
Parallel.ForEach avec une lambda expression
               Parallel.ForEach(list, g =>
                {
                    double d = double.Parse(Math.Sqrt(g.ToString().Length).ToString());
                    double f = Math.Sqrt(d) * double.Parse("155") - Math.Sqrt(double.Parse("555"));
                    double h = double.Parse(f.ToString());
                }
            );
Tableau de bench 

  ForEach Parallel.ForEach
Time 2280ms 1297 ms

III. Conclusion

Ce tutoriel n'était qu'une introduction quand à la programmation parallèle avec .Net 4.0, a ce titre il ne prétend pas être exhaustif, loin de là. Cependant j'espère vous avoir fait découvrir rapidement les nouveautés apportées par le nouveau Framework, et les gains de performance que l'on peut obtenir très simplement en modifiant que très légèrement notre code. A l'heure où les microprocesseurs multi-cores sont légion il était vraiment temps de pouvoir utiliser cette puissance disponible facilement, j'ai envie de qu'il était temps, Microsoft arrive vraiment au bon moment. En effet il devenait de plus en plus difficile d'expliquer aux DSI que nos applications ne s'exécutent pas plus vite sur un Quadri core que sur le vieux Dual Core à la même fréquence d'il y à quatre ans. De plus, le surcout de développement et surtout l'accroissement de la complexité du code était un frein à l'utilisation des solutions existantes en .Net 3.0 par exemple (sans tenir compte des Parallel Extensions à l'origine de ces nouveautés de .Net 4.0). Je dois dire que personnellement je trouve que Microsoft à vraiment fait un travail appréciable et arrivant au bon moment pour enfin nous permettre de tirer partie de nos machines multi-cores et cela sans pour autant devoir coder des usines à gaz, et enfin le debugger de Visual Studio 2010 est un très agréable à utiliser pour débugger du code « parallélisé » ce qui est absolument indispensable pour adopter largement ces nouvelles façon de développer.



Valid XHTML 1.0 TransitionalValid CSS!

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2009 Ronald Vasseur. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Responsable bénévole de la rubrique Microsoft DotNET : Hinault Romaric -