Serveurs TCP non bloquants

Principe de TCP non-bloquant (1/3)

En Java, la méthode accept() de ServerSocketChannel est bloquante par défaut : elle bloque l'exécution du thread jusqu'à l'acceptation d'une connexion.
Même chose pour les méthodes read() et write() de la classe SocketChannel.

Les ServerSocketChannel et SocketChannel peuvent être configurées pour que les méthodes accept(), read() et write() soient non-bloquantes.

 socket.configureBlocking(false);

Principe de TCP non-bloquant (2/3)

En mode non-bloquant, les méthodes accept(), read() et write() retournent immédiatement.

  • accept() renvoie null s'il n'y a aucune connexion pendante.
  • read() renvoie 0 s'il n'y a pas de données à lire.
  • write() ne garantit plus l'écriture de la totalité de la zone de travail du buffer : elle peut n'en écrire qu'une partie, voire rien du tout.

Principe de TCP non-bloquant (3/3)

Comment accepter une connexion depuis une ServerSocketChannel configurée en mode non-bloquant ?

var ssc = ServerSocketChannel.open();
ssc.bind(new InetSocketAddress(7777));
ssc.configureBlocking(false);

Mauvaise idée : faire un accept() inconditionnel ?

var sc = ssc.accept(); // NON : sc peut être null !

Mauvaise idée: le faire tant que sc vaut null ?

SocketChannel sc;
do {
  sc = ssc.accept();
} while(sc == null);  // NON : c'est de l'attente active

Bonne idée : utiliser un sélecteur pour être prévenu quand une connexion est pendante.

Selector

Mécanisme permettant de surveiller plusieurs "types" de channels pour différentes opérations d'intérêt, et en particulier d'être notifié...

  • ... qu'une connexion pendante est disponible (pour accept())
  • ... de la présence de données à lire (pour read())
  • ... ou de la possibilité d'écrire des données (pour write()).

Ainsi, pour un serveur TCP, la ServerSocketChannel d'acceptation et toutes les SocketChannel des clients, en lecture et en écriture, peuvent être surveillée par le même sélecteur.

Enregistrement de la socket serveur

Au départ, on a juste une socket serveur à surveiller...

var selector = Selector.open();

var ssc = ServerSocketChannel.open();
ssc.bind(new InetSocketAddress(7777));
ssc.configureBlocking(false);

// register to selector for accepts 
var serverKey = ssc.register(selector, SelectionKey.OP_ACCEPT); 

L'enregistrement se fait par register() sur la socket, en précisant les opérations que l'on veut surveiller : OP_ACCEPT pour la socket serveur notifie si une connexion pendante est disponible.

Enregistrement d'une socket cliente

Lorsqu'un client est disponible, il faut l'accepter et l'enregistrer pour surveiller ce qu'on peut en faire...

SocketChannel sc = ssc.accept();
if (sc == null) { 
  // toujours vérifier si la tentative a échoué !
  // auquel cas il faut attendre d'être à nouveau notifié
} else {
  sc.configureBlocking(false);
  var clientKey = sc.register(selector, SelectionKey.OP_READ);
}

La socket cliente est enregistrée auprès du même sélecteur, en précisant les opérations que l'on veut surveiller :
OP_READ : notifie lorsqu'il y a des données à lire disponibles ;
OP_WRITE : notifie lorsqu'il est possible d'écrire des données ;
OP_READ | OP_WRITE : notifie dans l'un ou l'autre des cas.

SelectionKey

Les différents types de sockets (client ou serveur) gérées par le sélecteur sont représentées par des SelectionKey.

Chaque clé key regroupe trois informations :

  • le canal associé à la clé : un SelectableChannel renvoyé par la méthode key.channel() ;
  • un entier représentant les opérations d'intérêt actuellement surveillées pour ce canal, obtenu par key.interestOps() ;
    il peut être changé par key.interestOps(int ops) ;
  • un objet attaché associé à cette clé (une sorte de Map gratuite) : on y accède par key.attachment() ;
    il peut être attaché par key.attach(Object o).

Utilisation du sélecteur (1/2)

Idée :

  • remplacer les différents appels bloquants à accept(), read() ou write() sur plusieurs canaux (nécessitant chacun un thread pour pouvoir être traités simultanément sans imposer un ordre d'exécution fixé)
  • par un seul appel à select() sur le sélecteur auprès duquel ils sont enregistrés,
  • pour ensuite traiter successivement les opérations ainsi sélectionnées par des opérations non bloquantes.

Nécessite de toujours considérer qu'un traitement peut échouer car on n'attendra pas (une acceptation, une lecture, une écriture...).

Utilisation du sélecteur (2/2)

L'ensemble des clés selector.keys() contient toutes les clés représentant les canaux enregistrés auprès du sélecteur.

La méthode selector.select() est bloquante ; elle retourne dès qu'au moins une opération surveillée semble disponible.

Elle copie dans l'ensemble des clés sélectionnées selector.selectedKeys() toutes les clés des canaux sur lesquels au moins une opération surveillée semble disponible.

Il en existe une version de selector.select() avec un timeout.

Exemple (00/15)

Exemple (01/15)

Exemple (02/15)

Exemple (03/15)

Exemple (04/15)

Exemple (05/15)

Exemple (06/15)

Exemple (07/15)

Exemple (08/15)

Exemple (09/15)

Exemple (10/15)

Exemple (11/15)

Exemple (12/15)

Exemple (13/15)

Exemple (14/15)

Exemple (15/15)

Boucle de sélection

var selectedKeys = selector.selectedKeys();
while (!Thread.interrupted()) {
   selector.select();
   processSelectedKeys(); 
   selectedKeys.clear();
}                

processSelectedKeys()

    for (var key : selectedKeys) {
       if (key.isValid() && key.isAcceptable()) {
          doAccept(key);
       }
       if (key.isValid() && key.isWritable()) {
          doWrite(key);
       }
       if (key.isValid() && key.isReadable()) {
          doRead(key);
       }
    }           

Hum... il faudrait peut être faire attention à la manière dont les exceptions sont propagées.
Que signifierait une levée d'exception par doAccept() ?
Et par doWrite() ou doRead() ?

Boucle de sélection (depuis Java 11)

while (!Thread.interrupted()) {
   selector.select(this::treatKey);
}                

avec

void treatKey(SelectionKey key) {
    if (key.isValid() && key.isAcceptable()) {
        doAccept(key);
    }
    if (key.isValid() && key.isWritable()) {
        doWrite(key);
    }
    if (key.isValid() && key.isReadable()) {
        doRead(key);
    }
}           

Attention aux exceptions...

doAccept

private void doAccept(SelectionKey key) throws IOException {
   // only the ServerSocketChannel is registered in OP_ACCEPT 
   ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
   SocketChannel sc = ssc.accept();
   if (sc == null) {
      return; // the selector gave a bad hint
   }
   sc.configureBlocking(false);
   sc.register(selector, SelectionKey.OP_READ);
}              

Lorsqu'on souhaite associer une structure de données à la clé représentant le canal pour le sélecteur (un attachement), on peut le faire lors de l'enregistrement, par exemple :

sc.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(SIZE));

Comparaison au mode bloquant

Dans un serveur bloquant, on avait un thread par connexion active. Dans un serveur non-bloquant, il n'y a qu'un seul thread pour gérer toutes les connexions.

On peut gérer beaucoup plus de connexions en même temps mais la réactivité du serveur diminue avec le nombre de connexions actives.