Chiron (9/11) : Contrat réactif

9 views
Skip to first unread message

Laurent Caillette

unread,
Feb 21, 2018, 12:10:04 AM2/21/18
to tec...@googlegroups.com

Le "contrat réactif"
définit une chaîne de traitements de messages où un producteur de messages n'alimente un consommateur que si ce dernier a signalé sa disponibilité. "Project Reactor"
est une bibliothèque pour assembler des composants selon le contrat réactif. Le contrat réactif fait maintenant partie de `Java 9` (dans la classe "Flow"
).

Pour des transmissions par `TCP/IP`, le contrat réactif consiste à ne pas envoyer d'accusé de réception ("ACK") tant qu'il y a trop de données en attente. Dans la documentation de Netty le contrat réactif porte le nom de retour de pression TCP ("backpressure").

Chiron encourage le style réactif en modélisant tous les événements qui vont affecter l'état du système sous forme d'objets ``Command`` (déjà présentés). Une application bâtie sur Chiron est un empilement de traitements incluant la persistance, l'alimentation de la logique applicative, et l'envoi d'autres objets ``Command`` à titre de réponses vers les Downends. Les traitements peuvent être synchrones ou asynchrones. Tout cela rentre merveilleusement bien dans les composants de Project Reactor.


=== Project Reactor

Project Reactor est LA bibliothèque que tout développeur Java doit connaître en 2018. Pour chaîner des traitements à travers plusieurs fils d'exécution, Project Reactor fournit probablement les meilleures abstraction et l'implantation la plus performante. On peut l'utiliser comme l'épine dorsale d'un serveur d'application maison, dans un connecteur à une source de données, ou comme facilité syntaxique pour manipuler des collections. Pas besoin de tout casser, on peut se familiariser avec Project Reactor sur un projet existant où il arrive comme une bibliothèque de plus.

Pour ceux qui avaient jeté un coup d'œil à la version 2 et n'avaient rien compris, je recommande de regarder la version 3. L'interface programmatique a été complètement repensée pour atteindre une clarté stupéfiante. La documentation couvre maintenant tous les sujets importants. 


=== Pas de contrat réactif avec Netty

La couche Netty de Chiron se contente de pousser des objets ``Command`` sans savoir s'ils sont attendus. Donc ça commence mal : tout le boulot fait avec Netty débouche sur une complète violation du contrat réactif. Ça n'empêche pas que dans l'application utilisant Chiron, une fois la commande insérée, celle-ci traverse une chaîne de traitements complètement conforme au contrat réactif.

On mesure des performances vraiment sympa, car le robot de test de charge attend la réponse pour chaque commande avant d'envoyer la suivante (il y a plusieurs trains de commandes en parallèle). Donc on peut dire que lors de ces tests, le contrat réactif est globalement respecté, mais pas au niveau TCP.

Quelques chiffres, pour ce que ça vaut. Sur un serveur octocœur fréquencé à `3,7 GHz` je mesure `4.000 commandes/s` en entrée et `400.000` en sortie. Le processeur tourne alors à `760 %` (sur `800 %`). Tout cela fonctionne avec la configuration par défaut du ramasse-miette ("Garbage Collector"). VisualVM montre que l'activité du ramasse-miette est toujours proche de `0 %` parce que la plupart des objets sont libérés quand ils sont encore dans l'Eden. Ça faisait longtemps qu'on n'avait pas organisé un pot de départ, là c'est le tour de l'expert en réglage fin ("tuning") de la JVM. 

Les tests c'est bien beau mais comment se comportera l'application en production si des utilisateurs la noient sous un déluge de requêtes ? Comme des humains interviennent en temps réel, une variation trop rapide des données ne sert à rien, ce qui justifie un limiteur de débit. Le ``SessionScopedThrottler``, dans **Chiron-middle**, tourne sur le Downend //et// sur l'Upend. Même si les utilisateurs se connectent avec un programme de leur crû (en violation de la license d'utilisation), les commandes excédentaires seront rejetées par l'Upend avant d'atteindre la logique applicative. Bon d'accord, c'est de la triche. Mais au moment de la migration de Jetty et CometD vers Netty je n'allais pas régler tous les problèmes du monde non plus. 

Si on veut voir comment coder le retour de pression avec Netty il suffit de regarder le ``AbstractTcpTransitServer`` qui est le parent du ``HttpProxy`` dans **Chiron-fixture**. On en dira plus sur cette bestiole dans l'épisode suivant. 

Il y a déjà eu un travail d'intégration de Project Reactor avec Netty. À l'époque où j'ai regardé, Reactor Netty était encore expérimental, et ça semblait vraiment compliqué de faire rentrer les fonctionnalités de Chiron. Mais ça serait intéressant d'étudier le sujet de nouveau, ne serait-ce que pour piquer des idées.


=== Rejeu

Le sourçage d'événements repose sur la reconstruction de l'état de l'application en rejouant un journal d'événements. S'il y a beaucoup d'événements, il faut donc tout rejouer très vite si on veut démarrer dans des délais raisonnables. Et là, Project Reactor a offert une aide inestimable.

Aujourd'hui l'application qui utilise Chiron rejoue 29 millions de messages en 10 minutes. 29 millions de messages c'est ce que produisent 100 utilisateurs à raison de 10 mises à jour par utilisateur et par seconde pendant 8 heures. Une fois le robot de test mis au point, il a fallu moins de 2 jours pour corriger quelques points chauds vite mis en évidence par le profileur. Je ne sais pas quel volumes traitent d'autres applications comparables, mais en traitement par lot, avec près de 3 millions de messages par minute sur une seule machine, on ne doit pas être parmi les plus mauvais. Et il reste encore des possibilités d'optimisation. 

Attention j'ai bien dit qu'il s'agissait de rejeu, là on ne fait rien avec le réseau ; surtout pour un message entrant il n'y a pas une mise à jour à envoyer par utilisateur. Pour le rejeu il faut lire un fichier très vite, désérialiser les objets ``Command``, alimenter la logique applicative et c'est tout.

Lire un gros fichier très vite ce n'est pas complètement trivial, surtout si on veut extraire des enregistrements de taille variable séparés par des délimiteurs. 

Le premier truc à connaître, c'est le fonctionnement du fichier projeté en mémoire ("memory-mapped file"). On demande au système d'exploitation d'utiliser son mécanisme de mémoire virtuelle pour rendre un fichier accessible comme une zone de mémoire vive. Cette zone de mémoire est représentée par un ``MappedByteBuffer`` dans le monde Java. Il y moyen de projeter un ``MappedByteBuffer`` vers un ``ByteBuf`` Netty. Après on utilise une primitive de parcours rapide du ``ByteBuf`` pour trouver les délimiteurs. Ensuite on projette le gros ``ByteBuf`` sur de petits ``ByteBuf`` donc chacun représente un enregistrement. Pourquoi des ``ByteBuf`` ? Pour réutiliser le code de désérialisation qui fonctionne avec Netty. Bon pour faire fonctionner ça il y a un peu de boulot, par exemple pour le cas d'un enregistrement qui continue après le ``MappedByteBuffer`` qu'on vient de lire. 

Du fait de l'algorithme de lecture, la longueur d'un enregistrement ne peut pas excéder la moitié de la longueur maximale du ``MappedByteBuffer`` (qui est 2^64 octets). On obtient les meilleures performances en limitant la taille d'un enregistrement à `1 Mo`, ce qui est confortable dans beaucoup de cas. 

Tout cela est codé dans le "``FileSlicer``"
de **Chiron-flow**. Sur un serveur avec un disque dur rotatif on mesure un débit soutenu de `300 Mo/s`. Le seul problème, c'est que sur cette machine, des outils système mesurent `150 Mo/s` (c'est détaillé dans la Javadoc du ``FileSlicer``). 

Mais on ne va pas pinailler sur une marge d'erreur de `100 %` vu que ce sont les autres traitements qui prennent du temps. Le ``JournalFileReader`` se comporte comme un iterateur qui va fournir à la demande des enregistrements sous forme de ``FileSlice``. Le merveilleux ``Flux`` de Project Reactor va appliquer le contrat réactif sur l'itérateur, et mouliner les ``FileSlice`` dans un autre fil d'exécution pour désérialiser les objets ``Command``, qui sont finalement traités par la logique applicative, encore dans un autre fil d'exécution. Le ``Flux`` fournit des primitives pour envoyer les commandes par lots, de façon à limiter la communication entre fils d'exécution.


=== Code applicatif

Le code câblant les composants avec des ``Flux`` n'est pas publié. Je laisse le lecteur imaginer un graphe avec les traitements dans des pavés qui généralement s'effectuent dans un fil d'exécution dédié. L'aiguillage des objets ``Command`` implique pas mal de trucs purement applicatifs. Je peux en expliquer deux qui sont assez simples.

Quand un utilisateur modifie son mot de passe, la commande qu'il envoie contient le mot de passe en clair, mais elle n'est pas persistée dans le journal. La logique applicative envoie une commande spéciale redirigée vers un étage de traitement qui tourne dans son propre fil d'exécution. Une fois le hachage effectué, une commande de mise à jour effective est réinjectée en entrée du graphe. La logique applicative met à jour l'utilisateur, et quand la commande est persistée, on ne voit que le résultat du hachage. 

Un autre cas sympa c'est la bascule, qui implique la création d'un nouveau fichier journal. Mais il faut s'assurer d'abord que toutes les commandes en cours ont bien été traitées. Donc on injecte une commande spéciale qui bloque le fils d'exécution de l'étage de traitement qui l'exécute. Quand tous les étages ont été bloqués on peut faire tout ce qu'on veut de façon synchrone vu qu'il n'y a plus aucun risque de concurrence d'accès. Oui c'est le grand verrou de la mort dont tous les architectes ont du rêver un jour, avant de remiser l'idée au placard parce qu'ils avaient trop de processus différents ou de machines différentes. Une des difficultés rencontrées a été d'ordre psychologique puisque la documentation de Project Reactor n'arrête pas de répéter qu'il ne faut pas bloquer les fils d'exécution. Et c'est un bon conseil, mais il n'est destiné qu'à maximiser les performances. Au moment de la bascule si un utilisateur insomniaque est toujours en train d'envoyer des données il se prendra juste une latence d'une ou deux secondes.

J'ai conscience que tout ça peut sembler assez abstrait, surtout si on ne s'est pas plongé dans la doc de Project Reactor et qu'on n'a pas une vision claire des spécificités d'une application avec des mises à jour impromptues. S'il y a un truc à retenir, c'est que Project Reactor rend génialement simple l'assemblage de divers étages de traitements, synchrones ou asynchrones, en assurant des débits fantastiques grâce au contrat réactif. 


=== Parenthèse : les Streams Java

L'interface programmatique du ``Flux`` peut rappeler celle des "Streams"
apparus dans `Java 8` pour définir des chaînes de traitements. Mais les possibilités de parallélisation du Fork-Join Framework sont juste "minables"
et de toutes façons l'ambition du Fork-Join Framework est bien en-dessous de celle de Project Reactor. Un serveur d'application basé sur le Fork-Join Framework, ça ressemble à une plaisanterie de stagiaire qui a bu un coup de trop, lors d'un des nombreux pots de départs consécutifs à l'adoption de Chiron.


=== Parenthèse : Apache Kafka

Apache "Kafka"
est un concentrateur-diffuseur de messages distribué. Il peut persister les messages, notamment à des fins de rejeu. Quand on lit la doc de Kafka on retrouve certains des choix de Chiron (qui lui-même a pompé sans vergogne sur LMAX). D'ailleurs Kafka mentionne le sourçage d'événements comme cas d'utilisation supporté.

Kafka est un produit taillé pour des volumes énormes et supporte donc la distribution sur plusieurs machines. La coordination est assurée par une grappe ZooKeeper déployée séparément. Ce n'est pas la philosophie de Chiron qui promeut une architecture mono-machine, avec un contrôle étroit de l'état du journal qui se réduit à un fichier dans la mémoire de masse. Mais on peut sûrement utiliser Chiron pour la modélisation applicative, en délègant la persistance à Kafka.

Kafka est un produit très impressionnant. Je recommande la lecture de sa documentation.

Reply all
Reply to author
Forward
0 new messages