Tech Days 2011 : Développer efficacement vos applications parallèles en C# 4.0

Après avoir encore fait la queue pendant 20 minutes et avoir tout de même failli passer la session assis par terre (alors qu’il y a toujours un rang réservé complètement libre devant), on va enfin commencer la conférence. C’est Bruno Boucard, MVP indépendant, qui s’y colle. http://blogs.msdn.com/devpara/ pour plus d’information.

Avant de commencer, petit sondage pour savoir qui utilise TPL ou Parallel Linq, et comme souvent, très peu de gens utilisent : une dizaine maximum sur une salle de 300 environ. C’est plutôt une constante, et du coup certainement une justification aux grands moyens que Microsoft met dans les TechDays ; les technologies de pointe sont bien connues, mais 90% des développements utilisent la techno n-1, quand ce n’est pas la n-2.

B.B. prévient que ce n’est pas une session d’introduction, mais plutôt des bonnes pratiques sur la parallélisation, même si visiblement peu de gens en font vraiment.

Pourquoi doit-on abandonner les threads ?

La division du travail pour préparer la parallélisation nécessite un surcoût, et si on a beaucoup de cores, on finit par prendre plus de temps à diviser qu’à calculer de manière effective. Le framework 4.0 a été pensé pour optimiser ces mécanismes même avec de nombreux cores.

En .NET 3.5, on avait un système de pool de threads. A chaque fois qu’on ajoute un thread dans la file, on locke le gestionnaire. Du coup, si des threads en créent beaucoup d’autres, on est limité par la pile globale. Il y a une détection de famine et de threads inactifs, mais c’est tout.

En .NET 4.0, on a une file sans lock pour les threads demandés, mais en plus des queues locales pour chacun des cores, qui vont prendre en charge les threads spawnés sous forme de fils par les threads. Ces piles de second niveau sont LIFO pour utiliser au mieux le mécanisme de cache des processeurs. Une file peut faire du vol de tâche, pour occuper au mieux les processeurs. Il y a apprentissage par le mécanisme en .NET 4.0.

Les résultats sont présents, avec un exemple de passage de 10 à 1,6 secondes en passant simplement d’une version 3.5 à une version 4.0, même si B.B. reconnait que le code est fait exprès pour montrer la différence.

Des exemples plus complets vont être fait sur les threads, puis le ThreadPool, puis l’API Tasks (qui est encore plus évoluée). L’emprunt mémoire d’un thread est en gros de 1 Mo. Du coup, si on crée ses threads à la main, on en a 1000 dans l’exemple donnée, alors qu’on n’utilise que 17 Mo de RAM pour le ThreadPool et l’API Tasks qui se base dessus. Attention, il faut faire un WaitOne dans le contenu pour pouvoir mesurer l’empreinte mémoire.

Si on veut composer, annuler, attendre des items, il est beaucoup plus facile d’utiliser l’API Tasks. Par contre, si on veut simplement faire du Fire & Forget, le ThreadPool suffit.

Outils pour la parallélisation

Collections ThreadSafe, Coordination (Barrier, BlockingCollection<T>, etc.), Partition (découpage des données pour plus de performance), Lazy initialization (Lazy<T>, ThreadLocal<T> pour éviter de partager des données), Verrou (…Slim pour la version optimisée, et …Spin lorsqu’on sait que l’attente sur une ressource sera très faible : il reste alors en attente active et non pas sur le noyau, donc plus efficient), Annulation centralisé dans .NET 4.0 (CancellationToken), Gestion des exceptions (AggregateException pour traiter le cas des tâches qui émettent des exceptions simultanées pour rendre compte au global en fin de traitement)

On continue sur une autre démo, qui montre un cas particulier d’utilisation des tâches avec l’énumération LongRunning, qui signifie qu’on souhaite utiliser un thread. FromCurrentSynchronizationContext permet de dire qu’on veut que la tâche se déroule sur le thread de la GUI, pour éviter les problèmes de modification des contrôles cross-threading. Par contre, il ne faut évidemment pas le faire si on est sur une boucle, pour ne pas bloquer la GUI.

Comment repérer les anti-patterns en parallélisation ?

Première méthode montrée ; utiliser le CPU Sampling pour voir déjà ce qui prend le plus de ressource. Il est essentiel, comme pour l’optimisation, de partir de mesures précises de ce qui prend le plus de CPU, et ne pas se fier à son intuition.

On voit dans l’exemple que c’est sur un traitement d’image qu’on prend le plus de temps. Du coût, c’est dans cette partie du pipeline qu’on va mettre en place une parallélisation. Dans un premier temps, on va faire une liste de Tasks, et on va boucler sur Environment.ProcessorCount, en lançant toutes les tâches d’un coup, et en utilisant ensuite une instruction ContinueWhenAll sur la liste pour spécifier qu’on veut que toutes les tâches soit finies pour clore notre buffer.

Une autre méthode est ensuite montrée. Si on attend une tâche et qu’elle est annulée, on va avoir une exception au niveau de l’attente. Par contre, si on ne l’attend pas, l’exception sera levée, mais au niveau du finaliseur. Sur le TaskScheduler, il y a un événement UnobservedTaskException, équivalent du UnhandledException sur un AppDomain.

Parallel Linq

La mise en place avec AsParallel() n’est pas obligatoire. Il faut bien vérifier ce qui se passe, car dès qu’une requête est complexe, le résultat ne sera pas nécessairement effectivement parallélisé.

WithCancellation et WithDegreeOfParallelism sont aussi disponibles en Linq.

Visualisation des problèmes

Visualisation des deadlocks intégrée dans Visual Studio, mais B.B. ne dit pas comment activer la fonctionnalité, où la trouver, si elle n’est que dans l’Ultimate, etc.

Oversubscription quand le nombre de tâches est trop elevé. Dans la visualisation du temps d’exécution des threads dans VS.NET, on ne doit pas voir trop de vert clair qui correspond à de la préemption.

Un ordonnancement dynamique permet de mieux utiliser les ressources, en ne mettant pas deux tâches lourdes sur un thread et deux petites sur une autre. Le graphe d’utilisation du CPU en escalier est révélateur de ce type de problème.

Le lock convoy se passe lorsqu’une ressource est lockée par une tâche seulement, et ce à chaque instant : du coup, toutes les tâches s’attendent et ne peuvent s’exécuter que lorsque les autres ne tournent pas. Du coup, c’est une erreur de design.

Profiling method = concurrency dans Visual Studio. Encore une fois, il faudra voir si le profileur existe dans la version Pro ou seulement dans l’Ultimate.

Pro .NET Parallel Programming in C# par Apress.

Patterns for Parallel Programming.

Guide “Parallel Programming with Microsoft .NET” dans la collection Patterns and Practices de Microsoft.

About JP Gouigoux

Jean-Philippe Gouigoux est Architecte Logiciel, MVP Connected Systems Developer. Il intervient régulièrement à l'Université de Bretagne Sud ainsi qu'à l'Agile Tour. Plus de détails sur la page "Curriculum Vitae" de ce blog.
This entry was posted in .NET, C#, Parallélisation, Retours. Bookmark the permalink.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Captcha Captcha Reload