Dans ce TD, nous allons développer des serveurs TCP bloquants de plus en plus performants pour le protocole LongSum
vu précédemment.
int
(en Big Endian) donnant le nombre d'opérandes de l'opération à réaliser,
puis chacun des opérandes qui sont des long
en Big Endian ;
long
en Big Endian correspondant à la somme des opérandes ;
IterativeLongSumServer
qui sert les clients les uns après les autres.OnDemandLongSumServer
qui démarre un nouveau thread pour chaque client qui se connecte.BoundedOnDemandLongSumServer
qui démarre nouveau thread pour chaque client qui se connecte, mais qui limite le nombre maximal de clients traitables simultanément.FixedPreStartedLongSumServer
qui pré-démarre un nombre fixé de threads ayant pour but de traiter des clients. Contrairement à l'exercice précédent, les threads sont réutilisés pour traiter successivement plusieurs clients.FixedPreStartedLongSumServer
.FixedPreStartedLongSumServer
avec une console interactive, la possibilité d'arrêter proprement le serveur et de déconnecter les clients inactifs.
La première version de ce serveur, IterativeLongSumServer
,
se contente d'accepter un client, de traiter ses requêtes (éventuellement plusieurs sommes successives),
et lorsque la connexion de ce client est fermée, il accepte un client suivant, et ainsi de suite.
Pour écrire ce serveur, vous vous baserez sur la trame
IterativeLongSumServer.java.
Tout le traitement de la connexion d'un client se fait dans la méthode serve(SocketChannel client)
. Les exceptions levées pendant l'exécution de la méthode serve
sont remontées et traitées dans la méthode launch
.
Compléter la méthode serve
.
Vous pouvez le tester avec votre client ClientLongSum
réalisé lors d'un TP précédent.
% java fr.upem.net.tcp.IterativeLongSumServer 7777 % java fr.upem.net.tcp.ClientLongSum localhost 7777
Vous testerez aussi en utilisant le jar ClientLongSumVerbose.jar, qui se connecte au serveur, et lui envoie successivement 5 salves d'opérandes à sommer, en attendant entre chaque salve un délai paramétrable sur la ligne de commande.
% java -jar ClientLongSumVerbose.jar localhost 7777 100
Pour l'instant vous n'avez testé votre serveur qu'avec un seul client. Ouvrez 4 terminaux. Dans l'un d'entre eux lancez votre serveur et dans les trois autres, lancez
ClientLongSumVerbose.jar
avec un délai d'attente suffisamment long (5000) comme dans l'image ci-dessous :
Expliquez comment il est possible que 3 clients puissent être connectés en même temps à votre serveur qui ne traite qu'un seul client à la fois.
On veut maintenant permettre à plusieurs clients d'être servis simultanément par le serveur. Pour cela, nous allons devoir créer plusieurs threads.
Une première méthode, assez facile à implémenter, consiste à créer un nouveau thread à chaque fois qu'un client est accepté, et à lui confier le service de ce client.
Sur le principe du serveur IterativeLongSumServer
précédent,
créez une nouvelle classe OnDemandConcurrentLongSumServer
, qui va simplement
créer et démarrer un nouveau thread à chaque nouveau client accepté.
Pour contrôler ce qui se passe au niveau de la JVM, vous allez "monitorer"
votre application en utilisant jconsole
pour observer le nombre de threads
utilisés par votre serveur, et démarrer des clients
de plus en plus nombreux qui se connectent à votre serveur.
Le problème de cette approche est que le nombre de threads démarrés simultanément n'est pas borné. Le serveur peut donc s'effondrer sous le poids des clients.
Pour borner le nombre de threads démarrés simultanément à maxClient
(une valeur passée sur la ligne de commande), vous allez écrire une autre classe
BoundedOnDemandConcurrentLongSumServer
qui vérifie, avant de démarrer
un nouveau thread pour traiter un nouveau client, que
le nombre de threads ne dépasse pas cette valeur maximum. Par ailleurs, lorsqu'un
client a fini d'être servi et que son thread s'arrête, il faut penser à
décrémenter le nombre de threads actifs.
Semaphore
. La classe Semaphore
est une classe thread-safe. Un objet de la classe Semaphore
est créé avec un certain nombre de permits (autorisations).
Semaphore semaphore = new Semaphore(nbPermits);La méthode
semaphore.acquire()
retire une autorisation de la Semaphore
s'il en reste au moins une. S'il n'y a plus d'autorisation, la méthode bloque jusqu'à ce qu'une autorisation se libère. La méthode semaphore.release()
rend une autorisation.
Implémentez la classe BoundedOnDemandConcurrentLongSumServer
.
Le principal problème de ces deux solutions est le temps nécessaire
au démarrage de chaque thread, qu'il faut attendre "après" que le client
ait été accepté et "avant" d'être en mesure de le servir.
L'idée est donc démarrer un nombre fixé de threads qui exécuteront, chacun, un serveur itératif.
Réalisez une implémentation à nombre de thread fixes (et pré-démarrés)
FixedPrestartedLongSumServer
qui
utilise des threads pré-démarrés qui attendent en concurrence de pouvoir réaliser
une acceptation de nouveau client. Pour éviter les problèmes de concurrence entre clients,
il suffit faire de l'exclusion mutuelle grâce aux appels à accept()
qui sont thread-safe.
Vérifiez son comportement avec jconsole
en testant avec de
multiples clients.
Que deviennent les clients qui tentent de se connecter lorsque
maxClient
sont actuellement en train d'être servis ?
A propos des threads virtuels
Java 19 introduit les thread virtuels (Thread.ofVirtual()
) qui sont beaucoup plus légers que les threads système (Thread.ofPlatform()
).
Alors que l'on peut lancer de l'ordre du millier de threads système, on peut démarrer de l'ordre du millions de threads virtuels. Les threads virtuels sont schedulés par la JVM et non par le système d'exploitation.
Un certain nombre de threads système sont démarrés avec pour mission d'exécuter les threads virtuels. L'idée étant que, quand un thread virtuel fait un appel bloquant, le thread système qui l'exécute va passer à un autre thread virtuel.
Ce comportement est exactement celui que l'on veut pour faire un serveur TCP. En effet, on peut démarrer un thread virtuel pour chaque client qui se connecte et à chaque fois qu'on attend des données du client (SocketChannel.read
), le thread virtuel va perdre la main (il est unpinned) pour que l'on puisse exécuter le code d'un autre thread virtuel.
Les threads système qui exécutent le threads virtuels sont appelés des threads carriers. Quand un thread virtuel est exécuté par un thread carrier, on dit que le thread virtuel est pinned à ce thread carrier. Quand le thread virtuel arrête d'être exécuté (au moment d'un appel bloquant), on dit qu'il est unpinned. La situation est résumée par la figure ci-dessous qui est empruntée de l'article de Jakob Jenkov sur le sujet.
Pour des raisons techniques, tous les appels bloquants ne vont pas faire que le thread virtuel soit unpinned. Pour l'instant, par exemple, c'est le cas d'un accès à un fichier : le thread virtuel n'est pas unpinned pour un accès fichier qui bloque et le thread carrier peut être bloqué et potentiellement empêcher d'exécuter d'autres threads virtuels. Par chance,
les appels réseau, ainsi que les appels bloquants sur une BlockingQueue
vont bien unpinned
les threads virtuels. Au moment de l'écriture de ce texte, Object.wait
ne va pas unpinned
le thread virtuel. Les threads virtuels sont en preview, donc ce comportement est susceptible de changer et de s'améliorer.
Réalisez une implémentation de
VirtualLongSumServer
qui utilise des threads virtuels. On voudrait borner le nombre de threads virtuels démarrés. Mais surtout, on veut éviter
d'appeler ServerSocketChannel.accept
si on n'est pas sûr qu'un thread virtuel pourra effectivement traiter la connexion renvoyée par ServerSocketChannel.accept
. En effet, si les threads carriers sont tous occupés à plein régime à exécuter des threads virtuels, on aura beau créer un thread virtuel pour traiter la nouvelle connexion, on n'a aucune garantie que la connexion soit traitée rapidement. Dans ce cas, on préfère laisser la connexion pending pour que l'OS la gère.
Vérifiez que le nombre de thread système n'augmente pas avec jconsole
en testant avec de
multiples clients.
Avec des threads virtuels, est-ce que l'on a des problèmes liés aux clients inactifs ?
Dans cet exercice, nous allons voir comment
gérer les clients qui sont inactifs dans un serveur de type FixedPreStarted
, sachant que ces clients bloquent les threads du serveur.
Le problème est que les méthodes socketChannel.read
et socketChannel.write
sont bloquantes et qu'elles ne prennent pas de timeout en paramètre. Il faudra donc lancer un nouveau thread (un seul pour tout le serveur) qui aura pour rôle de couper les connections des clients inactifs.
Il y a deux problèmes à résoudre :
timeout
. Pour résoudre ces deux problèmes, vous allez implémenter une classe thread-safe ThreadData
offrant les méthodes suivantes :
setSocketChannel(SocketChannel client)
qui change le client courant géré par ce thread ;tick()
qui indique que le client est actif au moment de l'appel à cette méthode ;closeIfInactive(int timeout)
qui déconnecte
le client s'il est inactif depuis plus de timeout
millisecondes.close()
qui déconnecte
le client.Il y aura un unique objet de la classe ThreadData
pour chaque thread du serveur. On créera un unique thread qui, toutes les timeout
millisecondes, appellera la méthode closeIfInactive(int timeout)
de chacun des objets ThreadData
.
La manière la plus simple pour coder la classe ThreadData
est d'avoir un champ pour stocker la SocketChannel
et un long
donnant le moment (obtenu par System.currentTimeMillis()
) de la dernière activité.
En partant de la classe FixedPrestartedLongSumServer
, écrivez une classe FixedPrestartedLongSumServerWithTimeout
qui met en place la gestion des clients inactifs.
Pour tester votre code, vous pouvez lancez:
% java -jar ClientLongSumVerbose.jar localhost 7777 10000 % java -jar ClientLongSumVerbose.jar localhost 7777 1000 % java fr/upem/net/tcp/FixedPreStartedLongSumServerWithTimeout 7777 2000Si le timeout de votre serveur est de 2000 millisecondes, le premier
ClientLongSumVerbose
devrait s'arrêter et le second non.
On souhaite maintenant rajouter une console au serveur : c'est-à-dire la possibilité de lire des instructions au clavier.
Votre console devra reconnaître trois instructions :
Rajouter une console à votre serveur
FixedPrestartedLongSumServerWithTimeout
de l'exercice précédent.