TP Noté 2024 : mise en œuvre de l'algorithme réparti de Naimi-Tréhel

1. Contexte et consignes

1.1. Introduction

Dans ce sujet, nous mettons en œuvre l'algorithme de Naimi-Tréhel qui assure un mécanisme d'exclusion mutuelle dans un système réparti. Pour un système de N éléments, la demande de la ressource critique est gérée avec en moyenne Log(N) messages. C'est un algorithme à jeton qui suppose aucune défaillance (aucune perte de message, aucune défaillance des N éléments du système). Pour écrire ce programme, vous n'êtes pas obligé de comprendre les détails de l'algorithme (l'énoncé détaille chacune des étapes de l'algorithme lorsqu'il demande de les mettre en œuvre), mais une compréhension générale de l'architecture globale du système est très utile.

Les N éléments forment deux structures qui vont évoluer au cours des échanges de messages :

Toutes les fois qu'un nœud veut obtenir la ressource en exclusion mutuelle, l'algorithme transforme l'arbre et la liste de façon à ce que ce nœud devienne la nouvelle racine de l'arbre ainsi que le dernier élément de la liste. Pour illustrer le principe de l'algorithme, regardons ce qui se passe lorsqu'un nœud réclame la ressource à partir de la situation initiale de la figure 1 ci-dessous :

Figure 1 : Évolution de l'arbre et de la liste lors d'un ResourceRequest.
Évolution de l'arbre et de la liste lors d'un <ResourceRequest

Donc, en résumé, la demande de ressource émise par le nœud 4 a placé ce dernier à la racine de l'arbre et en dernier sur la liste. Le jeton n'a pas changé de propriétaire. Voyons maintenant la partie de l'algorithme qui gère ce changement du propriétaire du jeton.

Lorsque le nœud possédant le jeton n'a plus besoin de la ressource, il transmet le jeton au suivant de la liste chaînée et quitte la liste chaînée (cf. figure 2) :

Figure 2 : Évolution de la liste lors d'une transmission de Token.
Évolution de la liste lors d'une transmission de Token

Enfin, regardons le cas particulier où la liste est réduite à un seul nœud : il est donc à la fois le premier de la liste (il possède le jeton), et le dernier de la liste (il est la racine de l'arbre). Lorsque ce nœud libère la ressource, il n'y a plus de nœud suivant sur la liste à qui transmettre le jeton. Dans ce cas, le nœud conserve le jeton pour le transmettre au nœud émetteur du premier message ResourceRequest qu'il recevra. Cela maintient la troisième propriété énoncée plus haut : si aucun nœud n'utilise la ressource, c'est le nœud racine qui conserve le jeton.

1.2 Consignes

Vous disposer de 3h pour réaliser ce TP. Vous avez le droit aux documents suivants :

Pour installer le sujet :

En fin de TP :

  1. si ce n'est pas déjà fait, ajoutez les informations à destination des correcteurs dans le fichier readme.txt ;
  2. supprimez tous les fichiers générés avec un « mvn clean » ;
  3. faites une archive zip ou tgz :
  4. déposez le fichier sur Moodle comme devoir ;
  5. avant de quitter la salle, vérifiez avec l'enseignant présent dans la salle que le fichier téléchargé sur Moodle est correct. L'enseignant charge votre projet dans Eclipse pour faire cette vérification.

2. Présentation du sujet

2.1. Architecture et messages

La classe représentant un nœud est la classe tsp.csc4509.tpnote.Node. Dans la suite de ce sujet, nous désignons les nœuds par le terme Node. Chaque Node du réseau est un élément de l'arbre, et éventuellement, s'il demande la ressource, un élément de la liste chaînée. Du point de vue de la programmation réseau JAVA NIO, un Node est un serveur TCP de tous ses fils dans l'arbre et un serveur TCP de son prédécesseur dans la liste chaînée. Et, par symétrie, il est un client TCP de son père dans l'arbre et un client TCP du suivant dans la liste chaînée.

Chaque Node est piloté par une Console, une différente pour chaque Node. Elle sert à le contrôler (quand réclame-t-il la ressource et pour combien de temps ?). Du point de vue du réseau, la Console est un client TCP du Node.

Enfin, pour simuler la ressource, un gestionnaire de ressource TaskServer (unique) est prêt à accepter la connexion des Nodes (mais une seule à un instant donné). Le TaskServer réalise une tâche qui, pour ce TP, se résume à l'appel de Thread.sleep(). Le TaskServer est donc un serveur TCP des Nodes.

Tous les messages échangés entre ces éléments sont des objets Java sérialisés, et nous utilisons la classe FullDuplexMsgWorker écrite et utilisée durant les TPs pour faire ces échanges.

La liste des classes composants cette architecture réseau est donc :

La liste des types des messages se trouve dans l'énumération tsp.csc4509.tpnote.MessType. On y trouve :

Chaque Node de l'arbre est identifié grace à un NodeId (unique donc pour chaque Node). Ce NodeId est aussi le paramètre des ResourceRequests pour identifier le Node émetteur des requêtes. La classe NodeId possède deux attributs :

Le sujet offre les classes d'application déjà écrites :

Le sujet ne vous demande pas de les utiliser directement. Mais ces trois classes sont utilisées en fin de sujet dans les tests d'intégrations déjà écrits.

3. Les questions du sujet

3.1. Introduction

Il y a 9 questions :

Le barème est donné à titre d'indication.

Vous pouvez faire les questions dans l'ordre de votre choix, avec la contrainte que la dernière intègre toutes les autres et ne peut être testée après les autres testées comme correctes.

Cependant, certaines questions possèdent des thématiques communes :

Attention : ce sujet porte sur des échanges réseaux, et le comportement des échanges ne seront corrects que si vous pensez à fermer les connexions quand elles ne servent plus. La plupart du temps, le sujet n'en fera pas mention, et c'est donc à vous d'être vigilant sur cet aspect.

Des tests unitaires sont fournis pour les questions 1—8. Suivez les consignes qui vous sont données pour chacune de ses questions pour les utiliser.

3.2. Écriture du TaskServer

3.2.1. Introduction

Cette classe, qui est dans le paquetage tsp.csc4509.tpnote, fournit le code du serveur de tâches. Lorsqu'un Node obtient le jeton, il se connecte à ce serveur, lui envoie un TaskRequest et attend en retour un TaskResponse. Le serveur TaskRequest ne gère qu'un seul Node à la fois, et donc reste en mode synchrone.

Cette classe possède deux attributs :

  1. ServerSocketChannel listenChannel : le ServerSocketChannel du serveur qui sert à accepter les clients ;
  2. int resquestNumber : le compteur des tâches reçues par le serveur. Il sera aussi le résultat à placer dans le TaskResponse pour notre simulation de tâches.

3.2.2. Question 1 : écriture du constructeur de TaskServer

Le constructeur de TaskServer possède un seul paramètre :

  1. int port : le numéro du port occupé par le serveur de tâches.

Écrivez le constructeur de TaskServer pour initialiser totalement l'attribut listenChannel pour qu'il soit prêt pour réaliser des accept().

Les tests :

Une fois votre méthode écrite, vous pouvez la tester. Pour cela, retirez l'annotation @Disabled devant la méthode testTaskServer() de la classe de test TestTaskServer et lancez ce test.

3.2.3. Question 2 : écriture de la méthode TaskServer::loop()

Écrivez la méthode TaskServer::loop() qui réalise la boucle du serveur de tâches. Cette boucle (infinie, sauf en cas d'exception, que vous ne devez pas gérer) doit :

Les tests :

Une fois votre méthode écrite, vous pouvez la tester. Pour cela, retirez l'annotation @Disabled devant la méthode testLoop() de la classe de test TestTaskServer et lancez ce test.

3.3. Écriture de la classe Node

3.3.1. Introduction

La classe Node, qui est dans le paquetage tsp.csc4509.tpnote, réalise tout l'algorithme de Naimi-Tréhel. Elle a de nombreux attributs :

Attention : il faut vous rappelez que father et fatherConnexion doivent évoluer ensemble. De même, next et nextConnexion doivent évoluer ensemble. Si, par exemple, l'instruction dit father = node i, il faut comprendre qu'il faut changer father avec le NodeId du Node i mais aussi connecter fatherConnexion au Node i.

Les deux constructeurs de la classe sont déjà écrits et ont correctement initialisé le Node pour le placer dans l'arbre (soit à la racine, pour le premier constructeur, soit en fils d'un autre Node pour le second constructeur). Les attributs inConnexions, serverAddr et selector sont initialisés par ces constructeurs.

La classe fournit aussi deux versions de la méthode FullDuplexMsgWorker connectTo(), qui ouvre une connexion TCP synchrone et construit un FullDuplexMsgWorker pour gérer les échanges. La première version utilise une adresse TCP/IP (InetSocketAddress) déjà initialisée, et la seconde fonctionne avec le couple (hostName,portNumber). N'hésitez pas les utiliser pour les connexions qui ne demandent aucune lecture, et où le mode synchrone ne pose donc pas de problème. 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.

Il reste 7 méthodes à écrire, qui forment les 7 questions restantes du sujet. Vous pouvez faire les 6 premières dans l'ordre que vous voulez, elles ne dépendent pas les unes des autres. La 7è intégre tout ce qui a été écrit auparavant et ne peut être testée qu'une fois les précédentes terminées et testées. Vous pouvez cependant décider de l'écrire quand vous le voulez.

3.3.2. Question 3 : écriture de la méthode Node::sendTaskToServer()

Cette méthode doit :

  1. se connecter au serveur de tâches. L'adresse TCP/IP de ce serveur est mémorisée dans l'attribut Node::serverAddr ;
  2. passer cette connexion en mode asynchrone et l'enregistrer dans l'attribut Node::selector (déjà initialisé par les constructeurs déjà écrits) ;
  3. créer un FullDuplexMsgWorker pour cette connexion et l'ajouter à l'attribut dictionnaire Node::inConnexions (déjà initialisé par les constructeurs déjà écrits) pour préparer la réception asynchrone du TaskResponse que va envoyer le TaskServer ;
  4. envoyer le TaskRequest au serveur de tâches (c'est l'attribut Node::taskRequest, qui lors de l'appel de la méthode Node::sendTaskToServer(), a déjà été initialisé).

Écrivez la méthode sendTaskToServer().

Les tests :

Une fois votre méthode écrite, vous pouvez la tester. Pour cela, retirez l'annotation @Disabled devant la méthode testSendTaskToServer() de la classe de test TestNode et lancez ce test.

3.3.3. Question 4 : écriture de la méthode Node::receivingResourceRequest()

Cette méthode réalise les actions d'un Node lorsqu'il reçoit un ResourceRequest d'un Node fils. En voici l'algorithme en langage libre :

Pour rappel, resourceRequest.getNodeId() vous donne l'id du Node émetteur du ResourceRequest, et resourceRequest.getNodeId().getAddr() son adresse TCP/IP. De plus, dans l'arbre, aucun message ne circule dans le sens père vers fils, donc la connexion vers le père ne sert qu'à faire des écritures et peut rester synchrone.

Pour rappel, attention : father et fatherConnexion doivent évoluer ensemble. De même, next et nextConnexion doivent évoluer ensemble. Si, par exemple, l'instruction dit « father = node i », il faut comprendre qu'il faut changer father avec le NodeId du Node i mais aussi connecter fatherConnexion au Node i.

Écrivez la méthode receivingResourceRequest().

Les tests :

Une fois votre méthode écrite, vous pouvez la tester. Pour cela, retirez l'annotation @Disabled devant les méthodes testReceivingResourceRequest1(), testReceivingResourceRequest2() et testReceivingResourceRequest3() de la classe de test TestNode et lancez ces tests.

3.3.4. Question 5 : écriture de la méthode Node::requestingResource()

Cette méthode réalise les actions d'un Node lorsqu'il reçoit un TaskRequest de sa Console. En voici l'algorithme en langage libre :

Écrivez la méthode requestingResource().

Les tests :

Une fois votre méthode écrite, vous pouvez la tester. Pour cela, retirez l'annotation @Disabled devant les méthodes testRequestingResource1() et testRequestingResource2() de la classe de test TestNode et lancez ces tests.

3.3.5. Question 6 : écriture de la méthode Node::receivingToken()

Cette méthode réalise les actions d'un Node lorsqu'il reçoit le Token du Node précédent dans la liste chaînée. En voici l'algorithme en langage libre :

Écrivez la méthode receivingToken().

Les tests :

Une fois votre méthode écrite, vous pouvez la tester. Pour cela, retirez l'annotation @Disabled devant la méthode testReceivingToken() de la classe de test TestNode et lancez ce test.

3.3.6. Question 7 : écriture de la méthode Node::releasingResource()

Cette méthode réalise les actions du Node qui détient le jeton lorsqu'il libère la ressource. En voici l'algorithme en langage libre :

Écrivez la méthode releasingResource().

Les tests :

Une fois votre méthode écrite, vous pouvez la tester. Pour cela, retirez l'annotation @Disabled devant les méthodes testReleasingResource1() et testReleasingResource2() de la classe de test TestNode et lancez ces tests.

3.3.7. Question 8 : écriture de la méthode Node::openListenChannel()

Cette méthode réalise l'ouverture du ServerSocketChannel listenChannel du Node.

Écrivez la méthode openListenChannel() qui initialise l'attribut listenChannel du Node pour que ce ServerSocketChannel soit prêt à faire des accept() en asynchrone sur le port myPort passé en paramètre. Le nouveau canal (listenChannel) est enregistré auprès du Selector du Node (attribut selector déjà créé et initialisé par les constructeurs déjà écrits).

Les tests :

Une fois votre méthode écrite, vous pouvez la tester. Pour cela, retirez l'annotation @Disabled devant la méthode testOpenListenChannel() de la classe de test TestNode et lancez ce test.

3.3.8. Question 9 : écriture de la méthode Node::algoNaimiTrehel()

Cette méthode réalise l'algorithme de Naimi-Tréhel en intégrant dans la boucle d'un serveur asynchrone les méthodes écrites jusqu'ici.

Écrivez la boucle (infinie, sauf en cas d'exceptions que vous n'avez pas à gérer) d'un serveur asynchrone.

Cette boucle doit :

La boucle doit aussi réaliser toutes les actions que l'on attend pour une boucle de lectures asynchrones (gestion des close(), des clear(), etc.)

Les tests :

Pour réaliser les tests d'intégration, nous ajoutons dans le code du Node une sonde qui va envoyer à un programme de log de toutes les évolutions de l'état de chaque Node. Pour cela il faut ajouter dans votre code, dans la boucle juste avant l'instruction selector.select() la ligne suivante :

Ensuite 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 réussissent.

Sinon, vous pouvez exploiter le log pour comprendre l'erreur. Pour cela, il faut comprendre 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 Log, 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 à :

La différence à noter entre les deux fichiers est l'absence des deux dernières lignes dans le fichier du test en échec (à gauche). Pour information, l'erreur vient du fait que, dans releasingResource, on a oublié de mettre requesting à faux.

Une nouvelle ligne de log est générée à chaque changement d'un Node :

Pour la lecture de ce log :

Le log s'arrête avec le Node 2, toujours étiqueté R, alors que toutes les requêtes devraient avoir été traitées. Il y a donc un bug.

Lorsque le test échoue, il indique aussi le fichier de référence. Vous pouvez le consulter pour voir où le log diffère de cette référence.

4. Conclusion

Ce TP noté est terminé et vous avez écrit le code permettant de mettre en œuvre l'algorithme réparti de Naimi-Tréhel.

Félicitations !