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);
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.
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é...
accept()
)
read()
)
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.
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.
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 :
SelectableChannel
renvoyé par la méthode key.channel()
;
key.interestOps()
;key.interestOps(int ops)
;
Map
gratuite) : on y accède par key.attachment()
; key.attach(Object o)
.
Idée :
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é)
select()
sur le sélecteur auprès duquel ils sont enregistrés,
Nécessite de toujours considérer qu'un traitement peut échouer car on n'attendra pas (une acceptation, une lecture, une écriture...).
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.
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()
?
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));
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.