UDP Non-bloquant

Cours 5

UDP non-bloquant

Une des difficultés que nous avons rencontrées jusqu'à présent est que, par défaut, les méthodes datagramChannel.receive() et datagramChannel.send() sont bloquantes.

Pour gérer le fait qu'il est possible de rester bloqué dans un appel à datagramChannel.receive(), nous avons utilisé des threads.

On peut changer ce comportement et rendre non-bloquantes les deux méthodes send() et receive().

DatagramChannel dc = DatagramChannel.open();
dc.configureBlocking(false);           

Receive non-bloquant

En mode non-bloquant, l'appel à datagramChannel.receive() retourne immédiatement même si aucun paquet n'a été reçu par le système.

  • si un paquet est présent dans le buffer système, les données sont copiées dans la zone de travail de buffer et l'adresse de l'expéditeur est renvoyée.
  • s'il n'y a pas de paquet immédiatement disponible, le buffer n'est pas modifé et l'appel renvoie null.
var dc = DatagramChannel.open();
dc.configureBlocking(false); 

var sender = dc.receive(buffer);
if (sender != null){
    System.out.println("Paquet reçu !");
} else {
    System.out.println("Pas de paquet");
}

Send non-bloquant

En non-bloquant, l'appel à datagramChannel.send() retourne immédiatement, même si on ne peut pas envoyer le paquet sans délai dans le buffer système (parce qu'il n'y a pas assez de place).

  • s'il y a assez de place, toutes les données de la zone de travail de buffer sont consommées et envoyées dans le buffer système;
  • s'il n'y a pas assez de place, aucune donnée n'est envoyée et le buffer n'est pas modifé.
DatagramChannel dc = DatagramChannel.open();
dc.configureBlocking(false); 
...
dc.send(buffer, sender);
if (!buffer.hasRemaining()){
    System.out.println("Paquet envoyé !");
} else {
    System.out.println("Le paquet n'a pas été envoyé");
}

receive bloquant ?

Peut on se mettre en attente de recevoir des données lorsqu'on est en mode non-bloquant ?

var dc = DatagramChannel.open();
dc.configureBlocking(false); 

var buffer = ByteBuffer.allocate(BUFFER_SIZE);
InetSocketAddress sender = null;
while(sender == null) {            // Argh : attente active !!!
  sender = dc.receive(buffer);	 
}

À ne jamais faire !!! C'est de l'attente active.

On doit utiliser un nouveau mécanisme appelé sélecteur qui va nous prévenir quand un paquet est arrivé (ou quand l'envoi est possible).

Principe du sélecteur (1/2)

Un sélecteur est un mécanisme permettant d'être notifié lorsqu'un paquet est arrivé et/ou lorsque l'on peut envoyer un paquet.

Un sélecteur surveille un certain nombre de DatagramChannel qui sont enregistrés auprès de ce sélecteur.

Pour chaque DatagramChannel enregistré sur un sélecteur, on peut demander à être notifié de l'arrivée d'un paquet et/ou de la possibilité d'en envoyer un.

Quand on l'interroge, le sélecteur renvoie l'ensemble de tous les DatagramChannel sur lesquels au moins une opération est possible.

Principe du sélecteur (2/2)

En Java : java.nio.channels.Selector

Un Selector est construit avec la factory Selector.open().

var selector = Selector.open();

Un Selector utilise la classe SelectionKey pour stocker les informations relatives à un DatagramChannel qu'il surveille.

Le Selector maintient deux ensembles de SelectionKey :

  • les clés surveillées : selector.keys()
  • les clés sélectionnées : selector.selectedKeys(), sur lesquelles un évenement surveillé a été détecté.

SelectionKey

Chaque SelectionKey contient :

  • un Channel qui correspond au DatagramChannel enregistré pour cette clé, accessible en lecture par l'accesseur selectionKey.channel() ;
  • un int qui code les opérations pour lesquelles on souhaite être notifié, consultable par l'accesseur interestOps() :

    • SelectionKey.OP_READ pour la réception seulement
    • SelectionKey.OP_WRITE pour l'envoi seulement
    • SelectionKey.OP_READ|SelectionKey.OP_WRITE pour l'envoi ET la réception.

    On peut modifier cette valeur avec la méthode selectionKey.interestOps(int interestOps).

Enregistrement

Pour enregistrer un DatagramChannel auprès du sélecteur, il faut que le DatagramChannel soit configuré en non-bloquant.

var selector = Selector.open();
var dc = DatagramChannel.open();

dc.configureBlocking(false);

// enregistrement de dc auprès de selector pour la réception
dc.register(selector, SelectionKey.OP_READ);

L'appel dc.register(selector, SelectionKey.OP_READ) crée une SelectionKey qui est ajoutée à l'ensemble des clés selector.keys(), pour que le sélecteur surveille les opérations de réception sur dc.

Selector (2/2)

Boucle de sélection (1/2)

Lorsqu'un ou des DatagramChannel sont enregistrés auprès d'un sélecteur, avec leurs opérations d'intérêt (représentées par l'ensemble selector.keys()), il suffit d'interroger le sélecteur grâce à la méthode selector.select().

On répète en boucle :

  • un appel à selector.select() qui place dans l'ensemble des clés sélectionnées selector.selectedKeys() les clés des DatagramChannel pour lesquels au moins une opération d'intérêt est disponible,
  • on traite chacune des clés de cet ensemble selector.selectedKeys(),
  • on vide cet ensemble selector.selectedKeys().

Boucle de sélection (2/2)

var selectedKeys = selector.selectedKeys();
while (!Thread.interrupted()) {
   selector.select();
   for (var key : selectedKeys) {
       if (key.isValid() && key.isWritable()) {
            doWrite(key);
       }
       if (key.isValid() && key.isReadable()) {
            doRead(key);
       }
    }
    selectedKeys.clear();
}                

Selector hints

Le test key.isValid() && key.isWritable() est évalué à vrai si le DatagramChannel est prêt pour un envoi de packet.

Le test key.isValid() && key.isReadable() est évalué à vrai si un paquet est prêt à être reçu sur le DatagramChannel.

Attention, même avec l'aide du sélecteur, il n'y a toujours pas de garantie que les appels à datagramChannel.send() et datagramChannel.receive() réussissent.

Nouvelle boucle de sélection

Depuis Java 11, Selector offre une variante de select utilisant une lambda.

  Selector.select(Consumer<SelectionKey> action)

Le Consumer action est appliqué à chaque clé pour laquelle une opération est prête. On n'a plus besoin de gérer selectedKeys avec une boucle !

Et, a priori, cette méthode est plus efficace que de faire la boucle de sélection.

Boucle de sélection

var selectedKeys = selector.selectedKeys();

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

private void treatKey(SelectionKey key) {
    try{
        if (key.isValid() && key.isWritable()) {
            doWrite(key);
        }
        if (key.isValid() && key.isReadable()) {
            doRead(key);
        }
    } catch (IOException e) {
        ...
    }
}

Comment gérer les IOException ?

Gestion des exceptions (1/2)

Il faut réfléchir à ce que signale l'exception.

Dans le cas d'un client/serveur UDP, cela signale en général une erreur irrécupérable. Il faut donc logger l'exception et la propager.

Comme on est dans une lambda, il faut la caster en une UncheckedIOException pour pouvoir la relancer.

Gestion des exceptions (2/2)

public void launch() throws IOException {
    while(!Thread.interrupted()) {
        try {
            selector.select(this::treatKey);
        } catch (UncheckedIOException tunneled) {
            throw tunneled.getCause();
        }
    }
}

private void treatKey(SelectionKey key) {
    try {
        if (key.isValid() && key.isWritable()) {
                ...
        }
        if (key.isValid() && key.isReadable()) {
                ...
        }
    } catch (IOException ioe) {
        throw new UncheckedIOException(ioe);
    }
}