Introduction à la Programmation Parallèle avec .Net 4.0

Ronald Vasseur

- Titre : Introduction à la Programmation Parallèle avec .Net 4.0

- Auteur : Ronald Vasseur

- Parution : 19 juillet 2009

- Mise à jour : 19 juillet 2009

- Licence : 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.

Sommaire

- 0. Introduction

- I. Présentation de la Task Parallel Library

- 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. A l'heure actuelle cette fréquence se situe en moyenne aux alentours des 3 GHz, cela est certes énorme mais cette valeur a tendance à stagner pour des raisons principalement techniques. Les processeurs actuels, à base de silicium, ne peuvent pour le moment pas monter beaucoup plus haut à cause de problèmes de dissipation thermique. Les fondeurs de puces condamnés à innover dans un marché hyperconcurrentiel ont trouvé la parade, cela consiste à paralléliser les puces dans un même package pour ainsi augmenter la puissance théorique de la puce. 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 sont développées 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. 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 calculs. 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, présenté il y à quelques semaines par Microsoft apporte des solutions à cette nouvelle problématique par le biais d'extensions et de nouvelles classes permettant de paralléliser facilement des traitements que l'ont faisait jusqu'à maintenant de manière séquentielle. Rien de fondamentalement nouveau sur le principe puisque cela existait déjà en .Net 3.0 grâce aux « Parallels Extension » mais celles-ci n'étaient pas intégrées au Framework .Net. Avec .Net 4.0 la barre monte 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 .Net 4.0 en matière de traitements parallèles, tout particulièrement la TPL, la « Task Parallel Library ».

I - Présentation de la Task Parallel Library

Pour faire simple, cette librairie regroupe des classes permettant aux développeurs de paralléliser des traitements. Ces classes permettent d'exécuter des traitements en parallèle sur les cours multiples 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 parallèlement sur tous les cours. Il n'est pas nécessaire de recompiler votre application pour effectuer des traitements parallèles sur des machines ayant un nombre de cours différent, cela offre à vos applications une certaine capacité de montée en charge pour peu que vous preniez le soin d'utiliser les possibilité offertes.

Jusqu'à maintenant lorsque l'on voulait faire des traitements en parallèle il fallait soit gérer ses threads manuellement, soit utiliser le ThreadPool qui bien que très efficace n'était pas très simple d'utilisation. .Net 4.0 nous rend la vie beaucoup plus facile grâce à une surcouche qui à é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 Task. Une Task est en fait le conteneur d'une opération qui sera exécutée de façon asynchrone. 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 Task (ou tâche) il est possible d'exécuter simultanément (le nombre de cours disponibles) et les gère au travers du ThreadPool qui est le « scheduler » par défaut de le la TPL. Il est possible d'exécuter les Tasks dans un ordre donné, d'attendre la fin d'une Task pour en exécuter une autre, une Task peut retourner ou non un résultat, etc etc. Il est ainsi possible de couvrir de très nombreux scénarios grâce à ce namespace. Passons à la pratique.

I-A - Créer une task :

Voyons tous d'abord comment instancier une Task dans notre code, il existe deux manières pour arriver à cela.

En utilisant le constructeur de la classe Task et en appelant la méthode Start() :

Task aTask = new Task(MethodA);
aTask.Start();

En utilisant la TaskFactory :

Task taskA = Task.Factory.StartNew(MethodA);

Libre à vous d'utiliser la méthode que vous préférez, en effet la TaskFactory ne fait ni plus ni moins que ce que vous faites manuellement en appelant la méthode Start() de la classe Task. La TaskFactory a été en partie créée pour offrir une classe statique contenant les méthodes permettant de gérer les Tasks, auparavant StartNew() par exemple était déjà présent dans la classe Task mais elle est maintenant plus visible dans la TaskFactory.

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 taskA = Task.Factory.StartNew(MethodA);
Task.WaitAny(taskA);

private static void MethodA()
{
// Do something...
}

Une Task retournant un résultat

Si notre Task doit retourner un résultat il suffit d'utiliser la version 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 :

// La method OneMethod() retourne une chaîne de caractères
Task<string> aTask = new Task<string>(OneMethod);
aTask.Start();

Task.WaitAll();

string result = aTask.Result;

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 il ne sera pas possible de savoir si elles se sont terminées et de connaître le résultat si résultat il y a. 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 taskA = Task.Factory.StartNew(MethodA);

Task.WaitAny(taskA);

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

Task.WaitAll();

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 peut dépendre du résultat ou de l'exécution d'une autre, dans ce cas l'on utilise la méthode WaitAny() au 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 et C ne pouvant être exécutés sans que A soit terminée.

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);

Remarque : il est également possible d'utilisé ContinueWith qui permet de réaliser la même chose, à savoir attendre la fin d'une tâche pour en débuter un autre. Cette méthode appelée sur une première tâche prend à paramètre la deuxième tâche à exécuter.

Voici d'autres exemples d'utilisation de la classe Task :

// 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))
};

Task.WaitAll(tasks);

// Task retournant un résultat
Task<string> aTask = Task<string>.Factory.StartNew(delegate { return DateTime.Now.ToLongTimeString(); });

Task.WaitAny(aTask);

Console.WriteLine(string.Format("Task retournant un résultat : Heure courante : {0}", aTask.Result));
Console.WriteLine(string.Empty);

Nous en resterons là pour la présentation de la classe Task, j'espère que vous en aurez saisi l'utilité et compris les grandes lignes de leur utilisation. Passons maintenant aux boucles For et ForEach en version parallèles.

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

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

II-A - Parallel.For :

// Utilisation du Parallel.For (avec une lamba expression)
private static void TestParallelFor ()
{
int nbr = 0;
Parallel.For(0, 150000, i =>
{
// Do something.
nbr = i * 2;
});
}

Après avoir réalisé quelques petits bench sur une machine Dual Core, comme je pouvais m'y attendre les temps d'exécution sont presque deux fois plus courts que lorsque l'on exécute le même code de manière séquentielle. Mais il y a 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, la raison est simple à comprendre, il y a 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, s'il s'agit juste de faire une addition il vaut mieux laisser oublier le For parallèle et se contenter d'un For classique. Par contre, dès que les traitements sont un peu plus complexes on voit nettement l'avantage de la parallélisassions avec un temps d'exécution quasiment réduit de moitié sur une machine DualCore.

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 à double coeur. Voici les syntaxes qui permettent de l'utiliser :

Avec une lambda expression :

Parallel.ForEach(list, g =>
{
// Do something.
}
);

Avec un delegate :

Parallel.ForEach<Guid>(list, delegate(Guid g)
{
// Do something.
});

Les méthodes For et ForEach présentées ci-dessus offrent quelques surcharges dont une prenant en paramètre un objet ParallelOptions, il s'agit d'une nouvelle classe qui a été ajoutée pour porter les paramètres que l'on peut appliquer aux boucles parallèles. En procédant de la sorte cette classe permet d'éviter d'avoir de trop nombreuses surcharges, puisque les paramètres de la boucle sont tous passés dans le même objet. Les paramètres les plus intéressant seront certainement MaxDegreeOfParallelism et TaskScheduler. Le premier correspond au nombre maximum d'opérations qu'une boucle pourra réaliser en parallèle, si la valeur est positionnée à -1 cela signifiera qu'il n'y à aucune limite imposée ; le deuxième permet d'indiquer quel scheduler de tâche que la boucle devra utiliser pour s'exécuter, en effet la TPL propose par défaut le ThreadPool comme scheduler, mais si nécessaire il est possible d'utiliser un « scheduler custom » en dérivant de la classe abstraite TaskScheduler.

III - Conclusion :

Ce tutoriel n'était qu'une introduction quand à la programmation parallèle avec .Net 4.0, à 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 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 dire qu'il était temps. En effet il devenait de plus en plus difficile d'expliquer que nos applications ne s'exécutent pas plus vite sur un Quad Core que sur le vieux Dual Core d'il y a quatre ans. De plus, le surcout de développement et 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 en .Net 4.0). Microsoft me semble t'il à fait un travail appréciable pour enfin nous permettre de tirer partie de nos machines multi-cores en se focalisant sur l'aspect fonctionnel sans pour autant devoir coder des usines à gaz.