Méthodes de crawl asynchrones: zoom sur reactor / spring

Qu’est ce que la programmation “asynchrone” ?

La programmation “asynchrone” s’oppose, par définition, à la programmation impérative ou fonctionnelle classique qui est, par nature, “synchrone”. Pour comprendre ce qui distingue ces deux approches, rappelons qu’un programme n’est qu’un ensemble d’instructions, dont certaines sont dites “bloquantes”. Une instruction est dite “bloquante” dès lors qu’elle nécessite d’attendre explicitement la disponibilité d’une ressource (un fichier, une connexion réseau, un mutex, …). On peut considérer qu’un programme qui a été spécialement pensé pour réduire le nombre d’instructions bloquantes le constituant est issu d’une forme de programmation asynchrone.

Traditionnellement, pour éviter d’avoir à recourir à des instructions bloquantes, on utilise deux types d’approches:

Python
  • les CallbacksOn remplace l’instruction bloquante par une instruction non-bloquante à laquelle on attache une fonction à exécuter lors de l’acquisition de la ressource tout en continuant à exécuter la série d’instructions suivantes.
  • les FuturesOn remplace l’instruction bloquante par une instruction non-bloquante nous fournissant les moyens de savoir à tout moment dans la suite du programme si la ressource est enfin disponible.

Pourquoi utiliser la programmation asynchrone ?

La programmation asynchrone permet, par extension, d’exécuter plusieurs tâches asynchrones de manière concurrente à partir d’un même thread. Elle fait partie des techniques utilisées pour améliorer l’expérience utilisateur en rendant les applications plus réactives, en particulier les applications web. Au lieu d’attendre la réponse à une requête faite à un service web, le programme peut désormais envoyer une autre requête, traiter la réponse à une précédente requête ou fournir un retour utilisateur quand à l’évolution du programme.

Celle-ci permet également d’accentuer l’accent porté sur la gestion des ressources. En effet, il existe un coût implicite lié à la détention d’un thread par une instruction bloquante. Un thread est, en réalité, une ressource finie directement ou indirectement liée à la quantité de mémoire disponible et dont l’utilisation a, elle-même, un coût en terme ordonnancement que ce soit au niveau d’une machine virtuelle ou du noyau système.

Dans un monde idéal, il est important de rappeler qu’il est tout a fait inutile, et même complètement inefficient, d’utiliser plus de threads qu’il n’y a de coeurs disponibles sur la machine qui exécute une (ou plusieurs) application(s). On peut même parler, plus communément, de gaspillage de ressources. La programmation asynchrone et la programmation multi-thread ne répondent en effet pas aux mêmes besoins mais ne sont néanmoins pas nécessairement incompatibles.

Pourquoi ne pas utiliser la programmation asynchrone ?

La programmation asynchrone introduit une surcouche de complexité dans la manière dont est écrit le code d’un programme, voir de son architecture, à tel point qu’il est parfois difficile de retracer l’exécution d’un programme pourtant simple. Or, il est malheureusement souvent nécessaire d’adhérer complètement à la programmation asynchrone lors de l’écriture d’un programme pour que celle-ci soit réellement intéressante. D’ailleurs, la plupart des langages fournissant pourtant du sucre syntaxique dédié à la programmation asynchrone, tel que Python ou C# avec les mots clés async / await, nécessitent souvent une réécriture complète du code.

Enfin, certaines contraintes de synchronisation de ressources rendent d’autant plus compliqué l’écriture de ce type de programme. Une période de formation s’avère généralement nécessaire pour quiconque se lance dans la programmation asynchrone pour la première fois. En effet, bien qu’elle paraisse plutôt abordable de prime abord, celle-ci comporte certains pièges à éviter qui peuvent potentiellement remettre en question le code alors déjà écrit.

A propos de Reactor (Spring WebFlux)?

Reactor est une implémentation Java, langage dans lequel est écrit le crawler de Babbar, du paradigme de la programmation dite “réactive” ou « événementielle ». La programmation réactive est une forme de programmation asynchrone où l’accent est porté sur les flux de données et la propagation d’événements (changements d’états, entrées utilisateur, …). Elle permet également de réduire la complexité posée par la programmation asynchrone tel que le « callback hell » en s’appuyant sur des techniques parfois plus proches de la programmation fonctionnelle.

La programmation réactive a été introduite par Microsoft dans l’écosystème .NET avec les extensions Rx (et ses interfaces Observable / Observer) étendues à l’univers Java avec RxJava (cf. https://reactivex.io/), pour enfin être directement introduite au sein de Java 9 avec l’implémentation des Reactive Streams (cf. https://www.reactive-streams.org/). On en retrouve aujourd’hui des implémentations pour de nombreux langages, surtout s’ils supportent d’ores et déjà la programmation asynchrone, tels que RxJS, RxScala, RxClojure, RxGo, … Elle se prête aussi bien aux langages adoptant une approche impérative que fonctionnelle.

La programmation réactive est basée sur le design pattern Observer, ou plus communément appelé publisher / subscriber (observable / observateur), une approche de type “push”, contrairement à un pattern plus classique du type Iterator (incluant donc les streams java de java.util.Stream), une approche de type “pull”. Cette distinction permet d’obtenir plus de contrôle sur le flux de données (ou événements) et la manière dont il est traité tout au long de la chaîne. Ainsi les erreurs rencontrées durant la chaîne de calcul peuvent être par exemple explicitement propagées et traitées de manière contrôlée.

Le plus gros défi des bibliothèques réactives est d’offrir à la fois la puissance de fonctionnalités asynchrones avancées et les moyens de réutiliser et/ou combiner ces méthodes simplement et efficacement. Reactor fournit, par exemple, via le concept d’”opérateur” les moyens de combiner et réutiliser les transformations apportées à des flux de données / événements implémentant l’interface Publisher, comme les classes Flux ou Mono.

La plupart des opérations effectuées dans la bibliothèque Reactor sur les classes de type Flux ne sont pas asynchrones en tant que telles. Seuls quelques opérateurs permettent réellement de tirer partie du caractère asynchrone de la bibliothèque (c’est le cas par exemple des opérateurs du type Flux.flatMap utilisés potentiellement avec Flux.deferred / Flux.fromCallable et Flux.subscribeOn / Flux.publishOn). La parallélisation des opérations, à distinguer des tâches asynchrones souscrites de manière concurrentes citées précédemment, est disponible via les opérateurs Flux.parallel et Flux.runOn.

Grâce au design publisher / subscriber (observable / observateur), la programmation réactive permet également d’implémenter des solutions efficaces à des problèmes trop souvent rencontrés avec la programmation asynchrone comme la gestion de la back-pressure. Il est, en effet, important dans ce type d’applications de régler le plus finement possible l’offre (observable) et la demande (observateur) pour maximiser l’utilisation des ressources disponibles.

Enfin, la plupart des bibliothèques de programmation réactive implémentent d’ores et déjà des concepts avancés et dédiés intéressants tels que les flux de données continus, la composition d’événements , la gestion du caching, le broadcasting, la synchronisation des flux, … Reactor se concentre ici sur les fondamentaux de la programmation réactive alors que Spring WebFlux (et potentiellement Spring MVC) repose sur ce dernier pour implémenter un framework dédié aux services web de haute scalabilité tout en maintenant une faible consommation de ressources.

A propos des Virtual Threads

Les virtual threads permettent d’éviter le gaspillage de ressources engendré par l’utilisation d’instructions potentiellement bloquantes (en les rendant tout simplement indirectement non bloquantes) et permettent donc de maximiser le rapport performance / ressource simplement sans avoir à réécrire le code des applications concernées. Malgré encore quelques limitations (aujourd’hui en partie en cours de résolution) ces derniers rendent ainsi la programmation asynchrone obsolète.

Bien que les principes introduits par la programmation réactive par rapport à la programmation asynchrone classique soient encore généralement pertinents, il existe fréquemment des approches plus conventionnelles pour les aborder. Ainsi, choisir la programmation réactive peut souvent se révéler plus laborieux, en raison de la nécessité toujours présente d’une phase de formation incompressible.