Serveurs TCP, implémentations de la concurrence

Préambule

Dans ce TD, nous allons développer des serveurs TCP bloquants de plus en plus performants pour le protocole LongSum vu précédemment.

Protocole LongSum

  1. Le client se connecte au serveur ;
  2. le client envoie un 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 ;
  3. le client lit ensuite la réponse du serveur qui est un long en Big Endian correspondant à la somme des opérandes ;
  4. le serveur ne ferme pas la connexion qui peut donc être utilisée pour faire une autre somme en repartant à l'étape 2 ;
  5. c'est le client qui ferme la connexion.

Serveur itératif

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.

Tests

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.

Serveurs concurrents à la demande

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.

Vous pourrez utiliser la classe 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.

Serveur concurrent avec threads pré-démarrés

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 ?

Serveur concurrent avec des threads virtuels (Java 19+)

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 ?

Gestion des 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 :

Pour résoudre ces deux problèmes, vous allez implémenter une classe thread-safe ThreadData offrant les méthodes suivantes :

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 2000
Si le timeout de votre serveur est de 2000 millisecondes, le premier ClientLongSumVerbose devrait s'arrêter et le second non.

Console

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.