Implémenter des serveurs performants avec Java NIO
Il y a environ un an, un des clients de la compagnie pour laquelle je travaillais nous a demandé de développer un routeur pour des protocoles de téléphonie (c.-à-d., des protocoles de communications utilisés entre un centre SMS et des applications externes).
Parmi les spécifications, il fallait qu’un seul routeur puisse traiter 3 000 connexions simultanées.
Il était clair que nous ne pouvions pas utiliser l’approche traditionnelle du pool de threads.
La plupart de ces méthodes traditionnelles ne montent pas très bien en charge, parce que le temps nécessaire au changement de contexte augmente de façon significative avec le nombre de threads.
Avec une centaine de threads, la majeure partie du temps CPU est consacrée au changement de contexte, laissant ainsi peu de temps pour effectuer le vrai travail.
Comme alternative à la méthode du pool de threads, nous avons décidé d’utilisé le multiplexage d’E/S.
Dans cette approche, un seul thread est utilisé pour traiter un nombre arbitraire de sockets.
Ceci permet au serveur de maintenir un petit nombre de threads, même quand il opère sur des milliers de sockets, améliorant significativement la montée en charge et les performances.
Malheureusement, il y a un prix à payer : une architecture basée sur le multiplexage d’E/S est plus compliquée à comprendre et implémenter que celle basée sur le pool de threads.
Le multiplexage d’E/S est une nouveauté de Java 1.4.
Il s’appuie sur deux fonctionnalités de l’API Java NIO (New I/O) : les sélecteurs et les E/S non bloquantes.
L’article Introducing Nonblocking Sockets propose une bonne introduction à ces deux fonctionnalités.
Dans cet article, nous décrivons les leçons que nous avons tirées lors de la conception et l’implémentation de notre routeur, en nous concentrant sur les problèmes d’architecture tels que la distribution d’évènements, la gestion des threads, la gestion des données clients et l’état du protocole.
Ceci n’est pas un article d’introduction ; le public visé est celui des développeurs ayant des connaissances de bases de multiplexage d’E/S et Java NIO, mais qui n’ont pas encore utilisé ces technologies sur un serveur à grande échelle.
L’article inclut le code source pour un client et un serveur écho fondés sur l’architecture décrite.
Le serveur et le client sont fonctionnels et peuvent être compilés et exécutés sans modification.
Le code source peut aussi être utilisé comme un point de départ pour développer un serveur complet.
Gestion des évènements d’E/S
L’architecture E/S de notre routeur a été très inspirée par le modèle de distribution d’évènements de Swing.
Dans Swing, les évènements générés par l’interface utilisateur sont reçus par la JVM et ajoutés à une file d’évènements.
A l’intérieur de la JVM, un thread de gestion d’évènement (implémenté dans la classe java.awt.EventQueue) contrôle cette file et distribue les évènements aux objets concerné.
C’est un exemple typique du pattern Observer.
Dans notre routeur, on trouve également un thread de gestion d’évènements, implémenté dans la classe SelectorThread.
Comme son nom le laisse suggérer, cette classe encapsule un sélecteur et un thread.
Le thread contrôle le sélecteur, dans l’attente d’évènements d’E/S et les distribue aux objets qui sont concerné.
La classe SelectorThread génère quatre types d’évènements, qui correspondent aux opérations définies dans java.nio.channels.SelectionKey : connecter, accepter, lire et écrire.
Les objets concernés (agents) s’enregistrent au niveau de la classe SelectorThread pour recevoir les évènements.
En fonction du type d’évènement qui les concernent, ils doivent implémenter l’une des interfaces suivantes :
- ConnectSelectorHandler : pour établir des connexions sortantes.
- AcceptSelectorHandler : pour établir des connexions entrantes.
- ReadWriteSelectorHandler : pour lire ou écrire des données sur une connexion.
La Figure 1 décrit la hiérarchie de classe de ces interfaces.
Nous avons décidé de ne pas définir une seule interface pour tous les évènements possibles parce qu’un agent ne sera en général concerné que par quelques évènements.
Par exemple, un agent qui accepte des connexions n’aura trè probablement pas besoin d’ en établir une.
Cette séparation permet aux agents d’implémenter uniquement les opérations dont ils ont besoin.

Figure 1. Hiérarchie de classe pour les agents de gestion d’E/S.
La vie d’un agent
Une différence importante par rapport au modèle un-thread-par-client est que toutes les opérations de lecture et d’écriture sont non-bloquantes, ce qui oblige le programmeur à gérer des lectures et écritures partielles.
Quand l’agent reçoit un évènement de lecture, cela signifie seulement qu’il y a des octets disponibles dans le buffer de la socket de lecture.
Ces données peuvent contenir un bout de paquet, un paquet complet ou plus d’un paquet.
Tous les cas doivent être pris en compte.
La situation est similaire pour l’écriture.
Il n’est possible d’écrire que dans la mesure de l’espace disponible dans le buffer de la socket d’écriture.
Un appel à l’écriture sera relancé aussitôt que le buffer sera libéré, sans se préoccuper de savoir si les données ont été complètement écrites ou non.
Ceci a un impact direct sur le cycle de vie des agents, qui ont besoin de traiter toutes ces situations.
Un agent est en gros une machine d’état qui réagit en fonction des évènements d’E/S.
Son cycle de vie est le suivant :
- Attente des donnéesL’agent s’intéresse à la lecture, mais pas à l’écriture, étant donné qu’il n’y a rien a envoyer au client.
Donc il se met en mode lecture et attend l’évènement de lecture. - LectureAprès avoir reçu l’évènement de lecture, l’agent récupère les données disponibles sur la socket et commence à réassembler les paquets.
Durant cet état, il ne s’intéresse à aucun autre type d’évènement.
Si le paquet est complètement réassemblé, il commence à le traiter (state 3 / état 3).
Sinon, il sauvegarde le paquet partiel et se remet en état d’attente de données (state 1 / état 1). - Traitement de la requêteL’agent entre dans cet état quand une requête est complètement réassemblée.
Là, l’agent ne s’intéresse ni à la lecture, ni à l’écriture (en considérant qu’il ne traite qu’une requête à la fois).
Tout intérêt pour les évènements d’E/S est désactivé. - EcritureQuand la réponse est prête, l’agent essaie de l’envoyer immédiatement en utilisant un mode d’écriture non-bloquant.
S’il n’y a pas assez d’espace dans le buffer d’écriture de la socket pour le paquet en entier, il sera nécessaire d’envoyer le reste plus tard (étape 5).
Sinon, le paquet est envoyé et l’agent peut réactiver son intérêt pour la lecture, en attente d’un autre paquet. - En attente d’écritureQuand une écriture non-bloquante s’arrête sans avoir terminé d’écrire toutes les données, l’agent active son intérêt pour les évènements d’écriture.
Plus tard, quand l’espace sera disponible dans le buffer d’écriture, l’évènement d’écriture sera généré et l’agent continuera à écrire le paquet.

Figure 2. Diagramme de transition d’état de l’agent.
Distribution des évènements d’E/S
La classe SelectorThread est responsable du cycle de vie des agents.
Pour cela, il gère les informations suivantes sur chaque agent :
- Un SelectableChannel : le canal à monitorer pour voir les évènements.
- L’agent lui-même : l’objet à notifier en cas d’évènement.
- Une interface d’intérêt : l’ensemble des opérations à contrôler.
Les agents doivent fournir ces éléments quand ils s’enregistrent.
Le SelectorThread enregistrera ensuite le canal avec le sélecteur interne, en utilisant son interface pour activer la surveillance des opérations d’E/S correspondantes.
L’agent est stocké comme une pièce attachée ce qui est une manière très pratique d’associer des données d’application avec le canal enregistré.
En interne, la méthode suivante est utilisée pour enregistrer un agent.
channel.register(selector, interestSet, handler);
Implémenter des serveurs performants avec Java NIO (suite)
Après leur enregistrement, les agents peuvent activer ou désactiver leur écoute sur les évènements d’E/S spécifiques en mettant à jour leur profil d’opérations.
En interne, la classe SelectorThread met à jour le profil correspondant à SelectionKey.
Il n’existe pas de support pour annuler l’enregistrement d’un canal, puisque cela est accompli lors de la fermeture de la socket.
Voici à quoi ressemble la classe SelectorThread :
public class SelectorThread implements Runnable {
/**
* Graceful shutdown.
*/
public void requestClose() {
...
}
/**
* Adds a new interest to the list of events
* where a channel is registered.
*/
public void addChannelInterestNow(
SelectableChannel channel,
int interest) throws IOException {
...
}
/**
* Like addChannelInterestNow(), but executed
* asynchronously on the selector thread.
*/
public void addChannelInterestLater(
SelectableChannel channel,
int interest,
CallbackErrorHandler errorHandler) {
...
}
/**
* Removes an interest from the list of events
* where a channel is registered.
*/
public void removeChannelInterestNow(
SelectableChannel channel,
int interest) throws IOException {
...
}
/**
* Like removeChannelInterestNow(), but executed
* asynchronously on the selector thread.
*/
public void removeChannelInterestLater(
SelectableChannel channel,
int interest,
CallbackErrorHandler errorHandler) {
...
}
/**
* Like registerChannelLater(), but executed
* asynchronously on the selector thread.
*/
public void registerChannelLater(
SelectableChannel channel,
int selectionKeys,
SelectorHandler handlerInfo,
CallbackErrorHandler errorHandler) {
...
}
/**
* Registers a SelectableChannel with this
* selector.
*/
public void registerChannelNow(
SelectableChannel channel,
int selectionKeys,
SelectorHandler handlerInfo)
throws IOException {
...
}
/**
* Executes the given task in the selector
* thread. Does not wait for its execution.
*/
public void invokeLater(Runnable run) {
...
}
/**
* Executes the given task synchronously in the
* selector thread, waiting for its execution.
*/
public void invokeAndWait(final Runnable task)
throws InterruptedException
{
...
}
/**
* Main cycle. This is where event processing
* and dispatching happens.
*/
public void run() {
...
}
}
Le but des méthodes invoke*() et des deux variantes (*Now() et *Later()) de la plupart des méthodes publiques sera expliqué plus tard.
Le threading dans un monde multiplexé
En théorie, avec le multiplexage d’E/S, il est possible d’avoir un seul thread pour effectuer tout le travail du serveur d’applications.
En pratique, ce n’est pas une très bonne idée.
En n’utilisant qu’un seul thread, il n’est pas possible de cacher les temps de latence des E/S disque (Java NIO n’offre pas d’opérations non-bloquantes sur fichier) ou de tirer partie d’un système multi-processeurs.
En règle générale, un serveur d’applications devrait avoir au moins 2*n threads, où n est le nombre d’unités d’exécution disponibles.
Nous devions donc implémenter une manière de diviser le travail entre les threads.
Obtenir un bon modèle de threading, était la partie la plus difficle de notre développement.
Nous avons pris en compte les architectures suivantes :
- M distributeurs/aucun consommateurPlusieurs threads de distribution d’évènements effectuent tout le travail (distribution des évènements, lecture, traitement des requêtes et écritures).
- 1 distributeur/N consommateursUn seul thread de distribution d’évènements et plusieurs threads de traitement.
- M distributeurs/N consommateursPlusieurs threads de distribution d’évènements et plusieurs threads de traitement.
Dans tous les cas, les connexions entrantes sont assignées à un thread de distribution d’évènements (un SelectorThread) pour la durée de leur vie.
Dans la première architecture, les évènements d’E/S sont complètement traités par le thread de distribution d’évènements.
Dans les deux autres, le traitement est délégué aux autres threads.
Implémenter des serveurs performants avec Java NIO (suite)
La solution compliquée
Notre approche initiale était basée sur une achitecture M-N.
Cela s’est révélé une mauvaise option.
Le principal problème était de ne pas avoir de problème avec le multithreading.
Il y avait beaucoup de points d’interaction entre les threads distributeurs et consommateurs, tous nécessitant beaucoup de synchronisation.
Un problème plus important encore est que le groupe formé par un sélecteur et les clés de sélection associées n’est pas protégé contre des accès concurrents de threads.
Si une clé de sélection est modifiée par un thread pendant qu’un autre thread appelle la méthode select() de son sélecteur, le résultat typique est un échec générant une horrible exception.
Ceci arrive souvent quand les threads consommateurs ferment les canaux et annulent indirectement les clés de sélection correspondantes.
Si select() est appelé à ce moment, il trouvera une clé de sélection qui a été annulée de façon impromptue et s’arrêtera en générant CancelledKeyException.
C’est probablement la leçon la plus importante que nous ayons apprise concernant le multiplexage d’E/S et Java NIO : Un sélecteur, ses clés de sélection et les canaux enregistrés ne devraient jamais être accessibles par plus d’un thread à la fois.
Nous avons essayé de mettre en application cette règle en déléguant au thread du sélecteur toutes les tâches concernant la structure du sélecteur.
Mais après quelques temps, l’architecture devenait très complexe, inefficace et source d’erreur.
Nous décidâmes qu’il était temps d’admettre notre défaite et de recommencer en utilisant une architecture simplifiée.
La solution qui marche
La façon la plus simple d’éviter les problèmes de concurrence entre threads distributeurs et consommateurs était de n’utiliser aucun thread consommateur et laisser les threads distributeurs effectuer tout le travail (l’architecture M distributeurs/aucun consommateur décrite plus haut).
Ce que nous perdions en flexibilité, nous le gagnions en simplicité.
Il n’y avait plus besoin de s’assurer de la synchronisation au niveau des threads de l’agent ou du sélecteur et il n’y avait plus besoin de déléguer les tâches entre threads.
La vie d’un thread de sélecteur est aussi simple que :
- Se bloquer sur le select.
En sortir seulement s’il y a des opérations prêtes à être exécuter. - Exécuter toutes les opérations qui sont prêtes.
Ceci inclut accepter ou établir des connexions, lire, écrire, enregistrer de nouvelles sockets au niveau du sélecteur, modifier l’ensemble des opérations monitorées, …etc… - Revenir au select.
Dans l’architecture M-N, les étapes 1 et 2 surviennent en même temps, ce qui crée de nombreux problèmes de concurrence.
Les effectuer dans le même thread évite les bugs de concurrence qui nous minaient dans notre première tentative.
Cependant, cette architecture présente également des problèmes qui doivent être considérés avec précaution.
Il est nécessaire d’assurer une bonne distribution du travail entre les threads.
Nous avons utilisé un simple algorithme de round-robin pour assigner les connexions entrantes aux threads de distribution d’évènements.
Jusqu’à présent, ceci a bien fonctionné.
Mais dans d’autres situations, il peut être nécessaire de prendre en considération d’autres facteurs tels que l’activité sur chaque thread.
Un autre problème est d’empêcher les threads de distribution d’évènements de se bloquer trop longtemps.
Ceci peut arriver si le traitement d’une requête nécessite des lectures ou écritures sur un disque ou une base de données.
Pour minimiser le problème, nous avons augmenté le ratio de threads de distribution d’événement par CPU (de quatre à cinq).
Ceci n’empêche pas un thread de se bloquer, mais quand cela arrive, il y a de fortes chances qu’un autre soit prêt à travailler.
Un dernier problème est qu’il est parfois nécessaire que des threads externes interagissent avec les objets gérés par les threads de distribution d’événements.
Par exemple, il pourrait y avoir un timer pour fermer les connexions inutilisées.
Mais le thread du timer ne doit pas directement fermer la connexion, pour les raisons que nous avons évoquées auparavant.
La solution est d’offrir une forme d’accès aux threads externes pour planifier les tâches à exécuter par le thread de distribution d’évènement du sélecteur.
Ceci est fait en appelant invokeLater() ou invokeAndWait() de la classe SelectorThread.
Si ces noms vous semblent familiers, c’est parce qu’ils existent dans la classe java.awt.EventQueue pour effectuer une fonction similaire en Swing.
Le cycle d’E/S principal
Maintenant que toutes les pièces sont en place, nous pouvons voir la boucle principale de distribution d’évènement :
public void run() {
while (true) {
// Exécute les taches en cours.
doInvocations();
// Il est temps de terminer ?
if (closeRequested) {
return;
}
int selectedKeys = selector.select();
if (selectedKeys == 0) {
// Retour au début de la boucle
continue;
}
// Dispatche tous les événements E/S prêts
Iterator it = selector.selectedKeys().
iterator();
while (it.hasNext()) {
SelectionKey sk = (SelectionKey)it.next();
it.remove();
// Obtient l'intérêt de la clé
int readyOps = sk.readyOps();
// Annule l'intérêt de l'opération
// qui est prête. Cela évite que le même
// événement soit traités plusieurs fois.
sk.interestOps(
sk.interestOps() & ~readyOps);
// Récupère le gestionnaire associé à cette clé
SelectorHandler handler =
(SelectorHandler) sk.attachment();
// Vérifie quels sont les intérêts qui sont
// actifs et dispatche l'événement à la méthode
// appropriée.
if (sk.isAcceptable()) {
// Une connexion est prête à être finalisée
((AcceptSelectorHandler)handler).
handleAccept();
} else if (sk.isConnectable()) {
// Une connexion est prête à être acceptée
((ConnectorSelectorHandler)handler).
handleConnect();
} else {
ReadWriteSelectorHandler rwHandler =
(ReadWriteSelectorHandler)handler;
// Lecture ou écriture ?
if (sk.isReadable()) {
// Lecture possible
rwHandler.handleRead();
}
// Vérifie que la clé est toujours valide,
// étant donnée qu'elle a pu être invalidée
// dans le gestionnaire de lecture (par exemple,
// le socket a pu être fermé)
if (sk.isValid() && sk.isWritable()) {
// Prêt à écrire
rwHandler.handleWrite();
}
}
}
}
}
Voici ce que la boucle fait :
- Exécuter les tâches planifiéesCe sont les tâches qui sont planifiées pour être exécutées sur le thread de distribution d’évènements par des threads externes (en appelant invokeLater() ou invokeAndWait()).
- Vérifier si la fermeture était demandéeVérifier s’il faut fermer le sélecteur.
Pour un arrêt en douceur. - Appeler select()Attente d’évènements d’E/S.
- Distribution d’évènements d’E/SObtenir l’ensemble des clés de sélection dont les opérations sont prêtes, les itérer et distribuer les opérations aux agents appropriés.
Quand toutes les clés de sélections prêtes sont distribuées, revenir en début de boucle et répéter le processus.
Conclusion
Développer un routeur complètement fonctionnel basé sur le multiplexage d’E/S ne fut pas simple.
Cela nous a pris trois mois pour obtenir la bonne architecture.
Une partie de ce temps nous a permis d’apprendre un nouveau modèle d’E/S peu familier.
L’autre partie fut passé à comprendre la complexité qui en découlait.
Enfin, nous étions assez contents du résultat.
Avec nos benchmarks, le routeur était capable de traiter 10 000 clients sans perte significative de débit.
Par comparaison, nous avons implémenté une version basée sur le modèle un-thread-par-client, qui ne pouvait pas servir 3 000 clients, avec une chute significative du débit au fur et à mesure de l’augmentation du nombre de clients.
Devez-vous utiliser le multiplexage d’E/S pour votre prochain serveur ?
Cela dépend.
Si le nombre de clients simultané n’excède pas cent ou deux cents, alors, utilisez un modèle à un mono-thread.
Mais si vous prévoyez d’avoir plusieurs centaines ou plusieurs même milliers de clients simultanés, vous devriez sérieusement considérer l’utilisation du multiplexage d’E/S.
Si vous l’utilisez, nous espérons que ce article et la code source qui l’accompagne vous aideront à éviter les problèmes rencontré lors de l’utilisation d’E/S multiplexées avec Java NIO.
Ressources
- Exemple de code pour cet article
- G. Naccarato, “Introducing Nonblocking Sockets,” O’Reilly Network, April 2002.
- R. Hitchens, “Top Ten New Things You Can Do with NIO,” O’Reilly Network, October 2002.
- J. Zukowski, “New I/O Functionality for Java 2 Standard Edition 1.4,” java.sun.com, December 2001.

width=”202″ height=”88″>
Textes originaux en anglais sur O’Reilly : Building Highly Scalable Servers with Java NIO par Nuno Santos
Chargement
Commentaires récents