TD de Programmation sur les threads

Dans ce TD, nous allons jouer un peu avec les threads java, essentiellement en observant leur comportement et en mettant en oeuvre les principes de synchronisation vus en cours.

1 - Observation des threads dans leur milieu naturel

Commencez par récupérer le petit programme fourni ici et exécutez le. Vous pouvez constater que ce simple programme se contente de créer deux threads qui affichent un message en boucle. Vous pouvez constater que les messages des deux threads sont entrelacés. Généralement (mais cela est dépendant du système et des autres processus) l'alternance entre threads se fait lors d'actions bloquantes (entrées/sorties, appel de la méthode sleep, ...) ou au bout d'un certain temps.

Exercice(s)

2 - Des problèmes de cohérence mémoire

Récupérer maintenant le programme suivant : il manipule un compteur d'horloge qui va être incrémenté en concurrence par plusieurs threads. Exécutez le et constatez les problèmes de cohérence mémoire. Le problème vient du fait qu'un ou plusieurs threads lisent l'état du compteur tandis que d'autres le modifient : ce sont des sections critiques qui ne devrait pas s'entrelacer.

Exercices

Remarque : à propos de JavaFX, au démarrage d'une application graphique, dans la méthode launch, un nouveau thread est crée par la bibliothèque elle-même (de manière transparente pour l'utilisateur). Ce thread sera chargé de l'exécution de toute la partie graphique, en particulier c'est lui qui doit exécuter les creations d'objets graphiques et les réactions aux évènements.

Si un autre thread que le thread créé par JavaFX exécute du code graphique (création d'un composant, fonction de réaction, dessin, ...) alors la cohérence mémoire n'est plus garantie : on dit que JavaFX n'est pas thread-safe. Pour éviter ce genre de problème, ses concepteurs auraient pu ajouter de la synchronisation dans JavaFX mais cela n'est pas vraiment raisonnable : vu le modèle évènementiel d'une bibliothèque graphique, fondé sur des fonctions de réaction rapides et fréquentes, ajouter de la synchronisation induirait un surcoût inacceptable. Il est donc préférable limiter l'accès à la partie graphique à un unique thread. Dans nos exemples cela signifie donc qu'il ne faut rien mettre concernant le graphique dans le main.

Remarquons toutefois que si du code graphique est malgré tout placé dans le main, l'application fonctionnera généralement correctement : les incohérences mémoire sont rares (autrement dit les sections critiques sont de toutes petites parties du code de JavaFX). Cela ne doit cependant pas dispenser l'utilisateur d'écrire son programme correctement.

3 - Un jeu multithreadé

Pour rendre un programme parallèle, et gagner en performance, il faut trouver comment le découper en parties d'importance équivalente, pour bien équilibrer le travail entre les threads, et telles que le regroupement des résultats ne soit pas trop couteux, pour ne pas perdre ce qu'on a gagné en découpant. Pour cela, les deux principales approches sont de découper un programme en tâches relativement indépendantes ou de découper les données sur lesquelles un programme travaille en morceaux traitables comme des sous problèmes plus ou moins indépendants.

La première approche, le parallélisme de tâche, est la première chose à mettre en \oe uvre pour gagner en performance. En effet, il est généralement aisé de trouver dans un programme plusieurs morceaux effectuant des traitements distincts qu'il sera possible d'exécuter en concurrence. Par exemple, dans notre jeu, l'affichage et le calcul des collisions sont relativement indépendants. Bien entendu, l'affichage utilise le résultat du calcul des collisions pour déterminer ce qu'il doit dessiner à l'écran, mais le calcul des mouvements et collisions pour l'image suivante peut se faire durant l'affichage de l'image en cours.

Le parallélisme de tâches étant souvent limité à quelques gros blocs aisément identifiables, il devient rapidement nécessaire de chercher du parallélisme ailleurs afin d'exploiter toutes les ressources de calcul. Le parallélisme de données consiste à découper les données sur lesquelles travaille le programme en parties destinées à être traitées par différents threads. Par exemple, dans notre jeu, les collisions relatives à un objet peuvent être calculées en même temps que les collisions relatives à un autre objet. Il faut toutefois être un peu plus attentif au potentielles incohérences mémoire car les objets sont accédés en lecture par certains threads et en écriture par d'autres.

Enfin, il existe des approches mixtes, voire dynamiques qui mélangent les différentes sortes de parallélisme et adaptent le découpage à l'évolution du calcul. Toutefois, nous ne détaillerons pas d'avantage ce sujet qui sera plutôt l'objet de certains cours de master. Pour notre jeu, nous nous contenterons de mettre en place un parallélisation sur deux threads en séparant la gestion de l'affichage du calcul des mouvements et collisions.

Exercice(s)

Toutes les classes que nous allons créer ici peuvent être placées dans un paquetage séparé du reste, nommé par exemple Modele.Multithreading.

4 - Gestion du modèle séparée

Nous allons devoir modifier un tout petit peu l'organisation de notre jeu afin de séparer les deux morceaux que nous souhaitons exécuter en parallèle. A l'heure actuelle les deux morceaux communiquent de la manière suivante :

Exercices

Pour mettre en place ces modifications, commencez par créer une classe JeuMultiThreads qui hérite de Jeu et la modifie de la manière suivante :

5 - Correction des incohérences

Si vous avez suivi scrupuleusement les étapes précédentes, sans aucun effort supplémentaire, vous devriez voir un déplacement de vos objets en jeu qui n'est pas satisfaisant : il donnent l'impression de saccader même en l'absence de baisse de framerate. Cela est du au fait que nos deux threads travaillent sans synchronisation sur un même objet en mémoire, le niveau. L'un deux effectue l'affichage tandis que l'autre effectue le déplacement des objets. En fonctions des aléas de l'exécution, le thread qui affiche va donc se retrouver à afficher des objets mis à jour :

Cela ne compromet pas le bon fonctionnement du programme car un seul thread modifie le niveau. Les données qu'il contient finissent donc toujours par être cohérentes. En revanche, le thread chargé de l'affichage voit des niveaux parfois partiellement mis à jour. Pour résoudre ce problème, nous allons simplement passer au thread chargé de l'affichage une copie du niveau en cours afin que les deux threads travaillent que des objets réellement indépendants. Effectuer cette copie a bien entendu un coût mais il reste linéaire et, comparé au coût de calcul des collisions qui est quadratique, à partir d'une certaine échelle, il devient moindre que le gain obtenu par la parallélisation.

Effectuer une copie d'un objet est une tâche difficile car elle requiert la résolution de plusieurs problèmes afin de tenir compte correctement de la structure de données copiée et de la hiérarchie de classes en place ou future. Un premier problème vient du fait que les valeurs contenues dans les attributs peuvent être des références à d'autres objets. Pour que la copie se fasse sur l'intégralité de la structure de données il faut donc copier, typiquement de manière récursive, les objets référencés dans les attributs. On parle alors de copie profonde. Certains langages implémentent en interne ce mécanisme de copie profonde mais ce n'est pas le cas de Java. La motivation principale est que, parfois, la copie n'a pas besoin d'être complète, certaines références pouvant être partagées par plusieurs objets. Ainsi, pour lui laisser plus de contrôle, il est préférable de laisser l'utilisateur écrire la méthode réalisant la copie profonde et décider des parties de l'objet qu'elle concerne.

Un autre problème vient du polymorphisme : un objet d'une classe B héritant de A manipulé via une référence à A doit malgré tout être copié comme un objet de la classe B et non A. Intuitivement, la copie devrait commencer par appeler la méthode de copie de A pour ensuite, dans B, modifier les parties de l'objet qui ont été spécialisées par B. Malheureusement, il n'est pas possible d'allouer le nouvel objet dans A où le type de B n'est pas connu. Il faut donc séparer la copie en deux parties distinctes :

Cela représente donc deux méthodes à écrire par classe, appelées l'une après l'autre dans la méthode de copie qui peut être la même pour tout le monde. Pour éviter cette mise en place pénible, Java dispose d'une méthode magique, appelée clone, gérée de manière particulière par le langage et définie dans la classe Object.

Lorsqu'elle est appelée dans Object, la méthode clone commence par examiner le type réel de l'objet à partir duquel elle est appelée. Si celui-ci implémente Cloneable, alors un clone correspondant à son type est crée en mémoire et la valeur de chaque attribut de l'objet cloné est copiée dans le clone. Cette copie est donc une copie "à plat" dans laquelle toutes les références sont partagées entre l'objet original et son clone. Pour effectuer une copie profonde, il faut redéfinir la méthode clone et y placer les appels récursifs permettant de copier les parties de la structure de données connues par référence. Attention, pour être correcte, cette méthode devra démarrer par un appel à la méthode clone de sa classe parent qui finira, dans Object, par créer la copie à plat de l'objet dont on pourra modifier les attributs pour réaliser la copie profonde. Notons au passage d'Object n'implémente pas Cloneable. Cela est fait ainsi pour éviter que toute classe ne soit clonable de par la présence d'Object dans son ascendance. Un appel à clone à partir d'un objet non Cloneable se solde par une CloneNotSupportedException.

Exercices

Et voilà !