Dans cet énoncé, nous mettons en place une architecture réseau qui s'inspire de l'anneau à jeton, en anglais token ring, qui permet le partage équitable d'une ressource sur un réseau. Tous les nœuds (node) qui souhaitent accéder à cette ressource sont placés dans un anneau où un unique jeton (token) circule de nœud en nœud. Celui qui possède le jeton possède la ressource. Il cède ensuite ce jeton au nœud suivant, le jeton tournant ainsi sur l'anneau.
Pour ceux qui l'ont suivi, le module CSC3102 de première année « Introduction aux systèmes d’exploitation » vous a déjà demandé de mettre en œuvre cette architecture lors du TP appelé « La Boite à Meuh » ; c'est donc « le retour de la Boite à Meuh ». Durant le TP de première année, les nœuds étaient des processus obligatoirement exécutés en local sur la même machine et communiquant par des tubes ; dans cet énoncé, les nœuds sont des processus pouvant être répartis sur les hôtes d'un réseau local et qui communiquent par des connexions TCP. Par ailleurs, durant le TP de première année, l'anneau était fixe : tous les nœuds de l'anneau étaient présents dès le départ ; dans cet énoncé, nous permettons l'arrivée de nouveaux nœuds dans l'anneau et à tout moment. Par contre, nous ne gérons les défaillances qu'a minima : dès que nous détectons qu'un nœud de l'anneau est défaillant (nous dirons « parti »), nous effondrons l'anneau en arrêtant tous les autres nœuds.
Mis à part le moment passager pendant lequel un nouveau nœud s'insère dans l'anneau, chaque nœud possède deux connexions ouvertes :
Pour fixer le sens de circulation du jeton, nous convenons que la première connexion établie désigne le nœud précédent, et que la connexion acceptée désigne le nœud suivant.
Comme dessiné dans la figure qui suit, l'initialisation de l'anneau est effectuée par un premier nœud qui se connecte à lui même pour former un anneau à lui tout seul.
Pour simplifier le sujet, nous considérons que les nœuds après le premier s'insèrent dans l'anneau en se connectant à un nœud déjà totalement inséré. Ainsi, pour insérer un nouveau nœud C entre deux nœuds A et B déjà sur présents sur l'anneau, il faut procéder au 7 étapes suivantes (notez que ces étapes restent valides pour l'anneau initial, où A et B sont alors le même nœud) :
En plus de la gestion des arrivées de nouveaux nœuds, l'anneau fait circuler de nœud en nœud un jeton, qui est créé par le tout premier nœud de l'anneau.
Vous disposer de 3h pour réaliser ce TP. Vous avez le droit aux documents suivants (liste exhaustive) :
Pour installer le sujet :
En fin de TP :
Le programme à écrire se compose de quatre classes et une énumération, toutes présentes dans le package tsp.csc4509.tpnote :
Vous n'avez pas besoin d'aller lire les autres classes présentes dans ce projet pour comprendre et écrire ce TP.
L'énoncé comporte 9 questions, toutes dans la classe Node.
Le barème est donné à titre d'indication, et pourra évoluer lors de la correction des copies.
Attention : Le symptôme d'une défaillance dans l'anneau est la détection d'une connexion TCP/IP perdue. Cette détection se fait donc en constatant une fermeture de connexion non prévue par l'algorithme. Il faut donc absolument que toutes les déconnexions prévues par l'algorithme soit totalement gérées : fermeture du FullDuplexMsgWorker concerné, retrait de ce FullDuplexMsgWorker des Map où il est présent, et désinscription auprès des Selector où il est enregistré.
Des tests unitaires sont fournis pour les questions 1—6. Des tests d'intégration sont fournis pour les questions 7—9. Suivez les consignes qui vous sont données pour chacune de ses questions pour les utiliser.
La classe Node, qui est dans le paquetage tsp.csc4509.tpnote, réalise tout l'algorithme qui gère l'anneau. Elle possède 5 attributs :
Pour rappel : un émetteur d'un message peut très bien faire ses envois en mode synchrone, tandis que le récepteur en face gère les lectures en mode asynchrone.
Les deux constructeurs et les cinq méthodes de la classe restent à écrire. C'est le sujet des sept premières questions. Vous pouvez décider d'écrire la réponse à une question quand vous le voulez, mais vous devez retenir que :
La classe Node utilise deux constructeurs. Une grande partie de leur code est commun. La méthode Node::initNode() définit ce code commun.
Cette méthode possède quatre paramètres :
Cette méthode initialise les attributs de la classe et connecte le nouveau Node en construction. Pour ce faire, elle doit :
La connexion ainsi ouverte est le lien vers le Node précédent de l'anneau.
Écrivez la méthode initNode().
Les tests :
Une fois cette méthode écrite, placez en commentaire l'annotation @Disabled du test testInitNode() de la classe de tests TestNode. Exécutez ce test et corrigez les éventuelles erreurs.
Le niveau d'affichage des logs se fixe dans la méthode initTest de la classe de test, lignes 76 à 78. Ils sont actuellement réglés au niveau WARN.
Si vous n'arrivez pas à corriger tous les problèmes, vous pouvez quand même passer aux questions suivantes, mais les tests des quesions 2 et 3 ne pourront pas passer.
Le premier constructeur de Node sert à construire le premier Node de l'anneau, celui qui se connecte à lui-même pour former à lui seul l'anneau initial (cf. la figure 1 dans l'introduction du sujet).
Ce constructeur possède deux paramètres :
Ce constructeur doit :
Donc, la partie spécifique de ce constructeur crée le lien avec le Node suivant (lui-même), et initialise l'anneau en injectant le jeton dans les échanges.
Écrivez ce premier constructeur.
Les tests :
Une fois cette méthode écrite, placez en commentaire l'annotation @Disabled du test testContructeur1() de la classe de tests TestNode. Exécutez ce test, et corrigez les éventuelles erreurs.
Si vous n'arrivez pas à corriger tous les problèmes, vous pouvez passer aux questions suivantes, mais vous ne pourrez pas passer les tests d'intégration de la quesion 7.
Le second constructeur de Node sert à construire un Node qui rejoint un anneau déjà initialisé. Il réalise toutes les actions du nouveau Node C dans la figure 2 de l'introduction du sujet. Dans la liste des actions à réaliser par ce constructeur, nous utilisons les numéros des étapes pour décrire l'avancement de la construction. Mais attention, ce code ne concerne que le point de vue du nouveau Node en construction. Les actions des Nodes déjà présents dans l'anneau (A et B) seront écrites dans d'autres méthodes.
Ce constructeur possède quatre paramètres :
Ce constructeur doit :
Donc, la partie spécifique de ce constructeur envoie les InformationsDeConnexion au Node précédent (A), et crée le lien avec le Node suivant (B).
Écrivez ce second constructeur.
Les tests :
Une fois cette méthode écrite, placez en commentaire l'annotation @Disabled du test testContructeur2() de la classe de tests TestNode. Exécutez ce test, et corrigez les éventuelles erreurs.
Si vous n'arrivez pas à corriger tous les problèmes, vous pouvez passer aux questions suivantes, mais vous ne pourrez pas passer les tests d'intégration de la quesion 7.
Cette méthode est appelée lorsque le Node reçoit le jeton. Elle marque le jeton avec l'identifiant id du Node (pour faciliter les tests, le jeton transporte son historique d'usage), simule une tâche longue (en s'endormant), et transmet le jeton sur l'anneau à son Node suivant.
Cette méthode possède un paramètre :
Attention : Vous noterez que le prototype de cette méthode indique ne lever aucune exception. À terme, cette méthode est appelée dans la méthode run() d'un Runnable, qui ne peut pas lever d'exception. C'est pourquoi vous devez attraper et traiter toutes les exceptions qui peuvent se produire dans le traitement du jeton. Une exception dans cette méthode signifie la perte du jeton. L'anneau n'est alors donc plus fonctionnel. Pour effondrer cet anneau, le traitement des exceptions consiste à fermer le workerSuivant (attribut de la classe). Le code de la question 7 demande de détecter cette fermeture pour que chaque Node de l'anneau propage cet effondrement en cascade.
Cette méthode doit :
Écrivez la méthode traiterJeton().
Les tests :
Une fois cette méthode écrite, placez en commentaire l'annotation @Disabled du test testTraiterJeton() de la classe de tests TestNode. Exécutez ce test, et corrigez les éventuelles erreurs.
Si vous n'arrivez pas à corriger tous les problèmes, vous pouvez passer aux questions suivantes, mais vous ne pourrez pas passer les tests d'intégration de la quesion 7.
Cette méthode est appelée lorsqu'un Node déjà présent dans l'anneau a accepté la demande de connexion d'un nouveau Node et a totalement reçu les InformationsDeConnexion de celui-ci. Elle réalise les actions du Node A de la figure 2 de l'introduction du sujet, après l'étape 3. Dans la liste des actions à réaliser par cette méthode, nous utilisons les numéros des étapes pour décrire son avancement. Mais attention, ce code ne concerne que le point de vue du Node A. Les actions des Node B et C sont écrites dans d'autres méthodes.
Cette méthode possède trois paramètres :
Cette méthode décrit les actions du Node A à partir de l'étape 3. Mais, lors de l'étape 1 (traitée dans la boucle de la question 7), ce Node a accepté la connexion du Node C, et a passé cette connexion en asynchrone pour préparer la réception du message infosConnexion que nous traitons dans cette question. À partir de maintenant, le Node A ne fait plus aucune lecture sur cette connexion. Donc, la première chose à faire dans cette méthode est de repasser cette connexion en mode synchrone.
Cette méthode doit :
Écrivez la méthode changerSuivant().
Les tests :
Une fois cette méthode écrite, placez en commentaire l'annotation @Disabled du test changerSuivant() de la classe de tests TestNode. Passez ce test, et corrigez les éventuelles erreurs.
Si vous n'arrivez pas à corriger tous les problèmes, vous pouvez passer aux questions suivantes, mais vous ne pourrez pas passer les tests d'intégration de la quesion 7.
Cette méthode est appelée lorsqu'un Node déjà présent dans l'anneau reçoit de son Node précédent le message NouvelArrivant. Elle réalise les actions du Node B de la figure 2 de l'introduction du sujet, lors de l'étape 5.
Cette méthode possède trois paramètres :
Cette méthode doit :
Écrivez la méthode changerPrecedent().
Les tests :
Une fois cette méthode écrite, placez en commentaire l'annotation @Disabled du test changerPrecedent() de la classe de tests TestNode. Exécutez ce test, et corrigez les éventuelles erreurs.
Si vous n'arrivez pas à corriger tous les problèmes, vous pouvez passer à la question suivante, mais vous ne pourrez pas passer les tests d'intégration de la quesion 7.
Cette méthode gère tous les échanges asynchrones du Node : les nouvelles connexions, les déconnexions et les réceptions de messages du Node précédent. Il s'agit donc de la boucle classique avec un Selector.
Cette méthode doit boucler tant que l'anneau ne s'effondre pas. Si la moindre lecture de cette boucle se termine à cause d'une déconnexion avant que le message ne soit complet, vous devez considérer que l'anneau s'effondre.
Vous devez écrire une boucle (pas infinie) qui doit :
Écrivez la méthode boucler().
Les tests :
Pour réaliser les tests d'intégration, nous ajoutons dans le code
du Node une sonde qui envoie à un programme
de log toutes les évolutions de l'état de
chaque Node. Pour ce faire, il faut ajouter dans votre
code, dans la boucle et juste avant
l'instruction selector.select() la ligne suivante :
Cette même sonde est déjà présente dans le code de la classe Token. Ainsi, le programme de log enregistre chaque modification de l'anneau ou du jeton. Les tests d'intégration consistent à comparer le log généré par votre code avec un log de référence.
Pour voir vos propres logs durant ces tests, vous pouvez changer leurs niveaux d'affichage. Les niveaux d'affichage des logs se fixent dans la classe tsp.csc4509.appli.AppliNode lignes 72 et 73. Ils sont actuellement réglés au niveau WARN.Pour passer les tests, lancez depuis la racine du projet (le répertoire où il y a le fichier pom.xml) les scripts suivants :
Les tests de terminent par la ligne « Test OK » lorsqu'ils passent.
Sinon, vous pouvez exploiter le log généré par DebugTools pour comprendre l'erreur. Pour cela, il faut comprendre le fonctionnement de ces tests.
Les tests d'intégration :
Si le log correspond au fichier de référence, le test réussit, sinon le test échoue. Si votre code contient des affichages de logs, ils sont affichés durant tout le déroulement. Mais, dans tous les cas, le test termine son affichage par un résumé et son statut (échec, ou réussi). Voici, par exemple, la fin d'un test qui échoue :
Les fichiers de log ressemblent à :
La différence à noter entre les deux fichiers est l'absence de toute activité sur l'anneau après le second usage du jeton. Pour information, le code erroné pense bien à générer le jeton (premier usage), pense bien à le recevoir une première fois (second usage), mais oublie de le reposter sur l'anneau (fin de l'activité de l'anneau).
Une nouvelle ligne de log est générée à chaque événement sur un Node (connexion, déconnexion, ou réception d'un message) et à chaque modification du jeton.
Les événements sur les Nodes génèrent une ligne débutant
par « Ring » qui affiche l'anneau avec
les Nodes dans l'ordre alphabétique (pas dans l'ordre de
l'anneau). Exemple de ligne Ring :
Sur cet anneau, le Node B est entre C et A, donc cette ligne décrit l'anneau "A -> C -> B -> A".
Les modifications du jetons génèrent une ligne débutant par
« Token ». Elle affiche le nombre total
d'usages, l'identifiant du
Node qui en a l'usage ainsi que la liste des derniers
utilisateurs : en tête de liste, le plus ancien usage et en
queue de liste le Node qui en a l'usage
actuellement. Cette liste ne conserve que les 10 derniers usages.
Exemple de ligne Token :
Ce jeton a été utilisé 10 fois. Au début, seul le Node A
était présent dans l'anneau. Il était donc le seul à utiliser le jeton.
Ensuite les Node B et C sont entrés dans l'anneau.
Enfin, le jeton est actuellement possédé par C qui l'a reçu de A.
Lorsqu'un nouveau Node s'insère dans l'anneau, il y a une période transitoire pendant laquelle certains liens sont rompus. Dans ce cas, le Node suivant ou précédent est indiqué par la mention « AUCUN ». L'activité de l'anneau peut cependant continuer. Tant que le jeton est utilisé par un Node qui a encore un lien vers un Node suivant, il peut circuler. Dans le scénario ci-dessous, un nouveau Node C s'insère entre B et A, mais à aucun moment le lien allant de A vers B n'est rompu. Donc, A qui possédait le jeton a pu le poster à B :
Si vous avez réalisé les tests d'intégration de la question 7, vous avez dû constater que l'insertion d'un nouveau Node dans l'anneau peut parfois prendre beaucoup de temps. En effet, lorsqu'un Node possède le jeton, il est occupé par ses activités (dans notre code, une pause de 5 secondes placée dans traiterJeton), et ne fait rien d'autre. Donc, si un nouveau Node (C) veut s'insérer dans l'anneau et que le Node ayant le jeton est concerné (que ce soit A ou B), l'insertion ne peut pas se terminer avant la fin de traiterJeton().
En déportant l'appel de la méthode traiterJeton() dans un nouveau thread nous résolvons ce problème.
Modifiez le code de la méthode boucler() pour que l'appel de la méthode traiterJeton() soit placé dans un nouveau Thread. Privilégiez la solution avec une expression lambda. Mais, si vous ne savais pas l'écrire, vous pouvez utiliser une classe anonyme ou écrire complètement la classe qui met en œuvre le Runnable.
Les tests :
Pour passer les tests, lancez depuis la racine du projet (le répertoire où il y a le fichier pom.xml), le script ./TestInteg5.sh
Il s'agit exactement du même scénario que le TestInteg3.sh : création de l'anneau avec le NodeA, connexion du NodeB sur A, et puis ensuite connexion du NodeC sur A. Mais, le log est différent. L'insertion des deux nouveaux Nodes est totalement finie avant que le Node A ait fini le traitement du jeton.
Notez qu'avec ces modifications les log de référence des tests 1 à 4 ne sont plus correctes, et qu'il est donc normal que dorénavant ces 4 tests échouent.
Une fois l'appel de la méthode traiterJeton() placé dans un thread, workerSuivant est utilisé dans deux threads différents. Il est aisé d'imaginer des scénarios où cette situation provoquerait des problèmes. En voici par exemple un :
Pour vérifier que les corrections apportées au code
réglent bien ce problème, nous ajoutons une modification
pour rendre le risque de conflit certain s'il n'est pas protégé.
Pour cela, ajoutez dans la méthode changerSuivant
les 4 lignes suivantes juste après l'envoi du message
NouvelArrivant :
Ensuite, exécutez le test ./TestSectionCritique.sh
À ce stade, ce test doit échouer. Il doit réussir après l'écriture des corrections demandées par cette question.
Ce test réalise le scénario décrit plus haut, mais sur un anneau minimal : il insère un Node B sur un anneau ne contenant qu'un seul Node A. Mais la situation est bien celle décrite : le Node A fait tourner deux threads en même temps, l'un exécutant traiterJeton et l'autre exécutant changerPrecedent. Le log de ce test, exécuté dans l'état actuel du code, montre qu'il n'y a plus trace du jeton à partir du moment où le Node B s'insère dans l'anneau. Ce jeton a été perdu.
Sachant qu'une fois le Node construit, l'attribut workerSuivant n'est utilisé que dans deux méthodes : traiterJeton et changerPrecedent, identifiez les sections critiques à placer en exclusion mutuelle, et utilisez les blocs synchronisés pour les protéger. Vous veillerez à ne bloquer la ressource que le temps nécessaire.
Les tests :
Pour passer les tests, lancez depuis la racine du projet (le répertoire où il y a le fichier pom.xml), le script ./TestSectionCritique.sh
Il doit maintenant réussir.
Une fois cette vérification faite, vous pouvez retirer le Thread.sleep(6000) de la méthode changerSuivant. Mais une fois cette méthode nettoyée de ces lignes, le test ./TestSectionCritique.sh échoue de nouveau, non pas parce que le code est bugué, mais parce que l'anneau insère le Node beaucoup plus rapidement et le log de référence n'est plus conforme.
Ce TP noté est terminé et vous avez écrit une première version opérationnelle du code permettant de mettre en œuvre la gestion d'un Token Ring.
Félicitations !