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 :
ResourceRequest
(« RRQ(4)
» sur la figure) vers son
père et se détache de l'arbre pour se préparer à devenir la
nouvelle racine. L'algorithme remonte le
message ResourceRequest
jusqu'à la racine et tous
les nœuds sur ce chemin prennent pour père l'émetteur
du ResourceRequest
(ici le nœud 4). Sur le
deuxième schéma le RRQ(4)
est arrivé dans la file
d'attente TCP du nœud 2, mais n'est pas encore
traité par le nœud 2 ;
ResourceRequest
à son ancien père, et
ensuite a pris le nœud 4 pour père ;
ResourceRequest
est arrivé à la racine,
qui est aussi le dernier de la liste. Ce nœud cède ces deux
rôles en prenant pour père le nœud 4 et en se
positionnant l'avant dernier dans la liste chaînée (il ajoute
le nœud 4 à la fin de la liste) ;
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) :
Token
) au suivant sur la liste. Il perd
donc aussi l'étiquette T. Après cette action, le
nœud 2 n'est plus dans la liste chaînée, c'est-à-dire
qu'il ne réclame plus la ressource. Notez aussi que plus
aucun nœud ne possède le jeton (pas d'étiquette T),
car il est actuellement dans le message Token
dans le file d'attente TCP du nœud 3 ;
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.
Vous disposer de 3h pour réaliser ce TP. Vous avez le droit aux documents suivants :
JAVA ≥ 17
.
Pour installer le sujet :
En fin de TP :
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 Node
s (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 Node
s.
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 :
tsp.csc4509.tpnote.Node
: classe représentant un
nœud. L'essentiel du code à écrire pour ce TP se trouve dans
cette classe ;
tsp.csc4509.tpnote.TaskServer
: classe
représentant la ressource critique à partager. Cette classe
doit être écrite en quasi totalité ;
tsp.csc4509.appli.Console
: classe
contrôlant chaque nœud pour
réaliser les tests d'intégration. Le code de cette classe est
totalement fourni.
La liste des types des messages se trouve dans
l'énumération tsp.csc4509.tpnote.MessType
. On y
trouve :
TASKREQUESTTYPE
: le type des messages
transportant un objet TaskRequest
qui est
envoyé par la Console
au Node
, et ensuite, lorsque
le Node
obtient le jeton,
du Node
au TaskServer
;
RESOURCEREQUESTTYPE
: le type des messages
transportant un objet ResourceRequest
qui
est transmis de Node
en Node
jusqu'au nœud racine, comme nous
l'expliquons dans l'introduction de ce sujet ;
TOKENTYPE
: le type des messages
transportant un objet Token
envoyé par
un Node
vers le Node
suivant de la liste chaînée quand il libère la
ressource ;
TASKRESPONSETYPE
: le type des messages
transportant un objet TaskResponse
émis
par le TaskServer
vers
un Node
une fois la tâche terminée, et qui
est ensuite transmis par le Node
à
sa Console
.
Chaque Node
de l'arbre est identifié grace à
un NodeId
(unique donc pour
chaque Node
). Ce NodeId
est aussi
le paramètre des ResourceRequest
s pour
identifier le Node
émetteur des requêtes. La
classe NodeId
possède deux attributs :
int num
: le numéro
du Node
;
InetSocketAddress addr
:
l'adresse TCP/IP
du Node
. Lorsqu'un ResourceRequest
est reçu par un Node
, il doit utiliser
cette adresse pour se connecter au Node
émetteur.
Le sujet offre les classes d'application déjà écrites :
AppliNode
: cette classe démarre
un Node
. Elle a deux usages, un pour
le Node
racine, et un autre pour tous les
autres Node
s ;
AppliTaskServer
: cette classe démarre
le TaskServer
;
Console
: cette classe est sa propre classe
d'application.
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.
Il y a 9 questions :
TaskServer
;
Node
.
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 :
Node
;
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.
TaskServer
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 :
ServerSocketChannel listenChannel
:
le ServerSocketChannel
du serveur qui sert à
accepter les clients ;
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.
TaskServer
Le constructeur de TaskServer
possède un seul paramètre :
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.
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 :
FullDuplexMsgWorker
pour cette
connexion ;
TaskRequest
;
resquestNumber
;
Thread.sleep(1000 *
taskRequest.getTaskDelay());
(en supposant
que taskRequest
est la référence sur
le TaskRequest
reçu) ;
TaskResponse
construit par
l'instruction new
TaskResponse(resquestNumber)
;
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.
Node
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 :
NodeId id
: l'identifiant
du Node
qui contient son numéro ainsi que
son adresse TCP/IP ;
NodeId father
et FullDuplexMsgWorker
fatherConnexion
: l'identifiant
du Node
père et la connexion vers
ce Node
. Ces deux attributs sont
à null
pour le Node
racine. Ces deux attributs doivent toujours évoluer de pair
(c'est-à-dire, en même temps) ;
NodeId next
et FullDuplexMsgWorker
nextConnexion
: l'identifiant
du Node
suivant dans la liste d'attente,
et la connexion vers ce Node
. Ces deux
attributs sont à null
si
le Node
n'attend pas la ressource ou
s'il est le dernier de la liste d'attente. Ces deux
attributs doivent toujours évoluer de pair
(c'est-à-dire, en même temps) ;
boolean havingToken
: booléan à vrai pour
le Node
ayant le jeton, et à faux pour tous les
autres ;
boolean requesting
: booléan à vrai pour
les Node
s réclamant la ressource et à faux pour
les autres ;
ServerSocketChannel listenChannel
: canal
qui sert à accepter toutes les connexions
(des Node
fils, du Node
précédent,
et de la Console
). Ce canal est géré en
asynchrone ;
Selector selector
: Selector
gérant les évènements asynchrones du Node
;
Map<SelectionKey, FullDuplexMsgWorker>
inConnexions
: dictionnaire gérant toutes les
connexions où une lecture asynchrone peut se produire
(message venant d'un Node
fils,
du Node
précédent, de
la Console
ou
du TaskServer
) ;
TaskRequest taskRequest
: attribut pour
mémoriser le TaskRequest
entre le moment où
le Node
le reçoit de
la Console
et le moment où il l'envoie
au TaskServer
;
InetSocketAddress serverAddr
:
adresse TCP/IP
du TaskServer
;
FullDuplexMsgWorker console
: connexion vers
la Console
.
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.
Node::sendTaskToServer()
Cette méthode doit :
Node::serverAddr
;
Node::selector
(déjà
initialisé par les constructeurs déjà écrits) ;
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
;
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.
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.
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.
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.
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.
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.
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 :
select()
du selector
(attribut de Node
, déjà créé par les
constructeurs déjà écrits) ;
inConnexions
(attribut de Node
, déjà créé et
initialisé par les constructeurs déjà écrits) ;
RESOURCEREQUESTTYPE
,
appelez la méthode receivingResourceRequest()
de la question 4 ;
TOKENTYPE
, appelez la
méthode receivingToken()
de la
question 5 ;
TASKREQUESTTYPE
:
console
avec
le FullDuplexMsgWorker
utilisé pour lire
ce message ;
requestingResource()
de la question 6.
TASKRESPONSETYPE
:
releasingResource()
de
la question 7 ;
TASKRESPONSETYPE
à
la Console
.
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 :
./TestInteg1.sh
./TestInteg2.sh
./TestInteg3.sh
./TestInteg4.sh
./TestInteg5.sh
./TestInteg6.sh
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 :
Node
s ;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
:
Node
. Les Node
s
sans père pointent sur X (cela peut concerner
plusieurs Node
s, car
lorsqu'un ResourceRequest
circule, un nouvel
arbre est en construction) ;
Node
suivant pour
chaque Node
.
Les Node
s sans suivant pointent
sur X ;
Node
s
indiquent l'état de leurs booléens.
T est indiqué pour les Node
s
avec havingToken
à vrai, et
R est indiqué pour les Node
s
avec requesting
à vrai.
Pour la lecture de ce log :
Node
1, racine, puis
le Node
2, fils de la racine) ;
TaskRequest
pour le début du changement de
l'arbre (le Node
2 se détache de
l'arbre) ;
Node
ne le possède) ;
Node
2 possède la ressource et
peut faire sa requête.
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.
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 !