Durée de lecture : environ 7 minutes

En avril 2023, lorsque je travaillais pour un groupe hôtelier français, certains indicateurs métier nous ont amené à penser que la performance de nos APIs s’étaient dégradées. En me penchant sur le sujet, j’ai découvert plusieurs problèmes que je traiterai dans différents articles.
Aujourd’hui, je vais parler d’un point assez simple : comment paralléliser des appels indépendants à des dépendances externes.

Surveillance et mesures

Commençons par le commencement : les performances doivent être mesurées. Les sentiments peuvent constituer un point d’entrée intéressant puisqu’ils peuvent être le symptôme de réels problèmes. Mais ils ne peuvent pas ni ne doivent remplacer des mesures véritables. Il existe divers outils pour ce faire et dans mon cas j’ai utilisé Application Insights et des requêtes personnalisées dans Azure. J’ai aussi pris le temps d’écrire divers script k6 pour vérifier s’il y avait des régressions sur les 6 derniers mois (spoiler : il y en avait mais pas comme on le pensait à l’origine).

Premier problème détecté

Comme le laisse penser le titre de l’article, le premier problème que j’ai trouvé était que certains appels à nos dépendances étaient faits séquentiellement. Voici à quoi ressemblait le graphe d’appel dans Application Insights :

Un chart horizontal affiche la durée et les points de début/fin de 12 appels à 2 bases de données différentes durant un appel à un de nos services.
J’ai volontairement zoomé sur les appels aux bases pour cacher l’appel au service externe qui prenait prétendument 5.1 secondes et qui aurait caché le reste.

Ce n’est pas un problème en soi si ces appels sont dépendants les uns des autres mais ce n’était pas le cas ici. J’ai regardé le code et ai rapidement remarqué que la plupart de ces appels étaient indépendants.

Utilisons du code d’exemple !

Pour mieux illustrer ce que nous avions fait alors et éviter de compromettre le code de mon client, j’ai créé un projet d’exemple ici. Il s’agit d’une API incluse dans un projet Aspire, ce qui signifie qu’il y a de l’outillage natif (traçage, logs open telemetry…) et un container de base de données PostgreSQL.
Le but est de visualiser à quoi ressemblent des appels séquentiels puis les comparer avec du code faisant ces appels en parallèle. Pour cet article, la durée de ces appels est déterminée aléatoirement (voir le code source).

Version séquentielle

Voici à quoi ressemble le code de la version séquentielle dans la méthode GetAvailabilities() :

var availabilities = await availabilityProvider.GetAvailabilities(new HotelId(hotelIdAsString), startDate, endDate);

if (!availabilities.Any())
{
    return NoContent();
}

var roomIds = availabilities.Select(availability => availability.RoomId).ToArray();
var roomInformation = await roomInformationProvider.GetInformation(roomIds);
var roomPictures = await roomInformationProvider.GetPictures(roomIds);
var rateIds = availabilities.Select(availability => availability.RateId);
var rateInformation = await rateInformationProvider.GetInformation(rateIds);

var result = MapToAvailableRoomWithPriceList(availabilities, roomInformation, roomPictures, rateInformation);

return Ok(new AvailableRoomsWithPrices(result));

Il y a 4 appels à des dépendances :

  • Ligne 1 : appel principal pour récupérer les chambres disponibles de l’hôtel. Si on ne peut pas en trouver, on sort tout de suite (« early return ») ce qui est une bonne pratique d’un point de vue des performances (et de la lisibilité aussi si vous voulez mon avis).
  • Ligne 9 : appel pour récupérer des informations concernant les chambres disponibles.
  • Ligne 10 : un autre appel pour récupérer les URLs vers les images de ces chambres qui ne sont pas dans le même référentiel.
  • Ligne 12 : dernier appel pour récupérer des informations sur les tarifs de ces chambres (annulable, remboursable, mentions légales, etc…).

Tout est séquentiel. Dans le cas du premier appel c’est normal puisqu’on ne peut pas vraiment agir tant qu’on ne sait pas quelles chambres sont disponibles et à quel prix.
Ce code nous donne les traces suivantes dans Aspire :

Un chart horizontal montrant la durée d'un appel à une API y compris celle ses dépendances. On peut voir que la durée totale est de 1.06s, avec l'appel pour récupérer les disponibilités durant 336.69 ms, l'appel pour récupérer les informations des chambres 56.3 ms, l'appel pour récupérer les images 94.67 ms et celui pour récupérer les tarifs 125.44 ms.
Notez comme « rien » ne semble arriver pendant les 500 premières millisecondes environ et un peu à la fin aussi. C’est parce qu’il s’agit du tout premier appel à l’API depuis son démarrage.
Un chart horizontal montrant la durée d'un appel à une API y compris celle ses dépendances. On peut voir que la durée totale est de 646.64, avec l'appel pour récupérer les disponibilités durant 374.04 ms, l'appel pour récupérer les informations des chambres 72.33 ms, l'appel pour récupérer les images 57.08 ms et celui pour récupérer les tarifs 141.2 ms.
Un autre appel, cette fois avec l’API qui ets « rodée ». Il n’y a pratiquement aucun temps perdu avant et après les appels aux dépendances. C’est ce que nous voulons mesurer.

Les appels séquentiels sont faciles à repérer sur ce type de graphe. Quand vous vouyez ce type de « marches d’escalier », alors il peut être intéressant de regarder le code. Peut-être certains appels sont-ils indépendants les uns des autres et nous pourrons les paralléliser.

Version parallèle

OK donc maintenant on va pouvoir agir et améliorer le code. J’ai fait les modifications suivantes dans la méthode GetAvailabilitiesParallel() :

var availabilities = await availabilityProvider.GetAvailabilities(new HotelId(hotelIdAsString), startDate, endDate);

if (!availabilities.Any())
{
    return NoContent();
}

var roomIds = availabilities.Select(availability => availability.RoomId).ToArray();
var roomInformationTask = roomInformationProvider.GetInformation(roomIds);
var roomPicturesTask = roomInformationProvider.GetPictures(roomIds);
var rateIds = availabilities.Select(availability => availability.RateId);
var rateInformationTask = rateInformationProvider.GetInformation(rateIds);

var (roomInformation, roomPictures, rateInformation) = (await roomInformationTask, await roomPicturesTask, await rateInformationTask);
var result = MapToAvailableRoomWithPriceList(availabilities, roomInformation, roomPictures, rateInformation);

return Ok(new AvailableRoomsWithPrices(result));

Vous voyez comme les awaits ont bougé sur la même ligne et sont séparés des invocations de méthode async ? Je reviendrai plus tard sur cette syntaxe mais voici à quoi ressemble le code dans les traces d’Aspire :

Un chart horizontal montrant la durée d'un appel à une API y compris celle ses dépendances. On peut voir que la durée totale est de 456.01 ms, avec l'appel pour récupérer les disponibilités durant 295.06 ms, l'appel pour récupérer les informations des chambres 82.18 ms, l'appel pour récupérer les images 151.38 ms et celui pour récupérer les tarifs 69.29 ms. Cette fois, les 3 derniers appels sont "empilés", la durée totale étant égale à celle de l'appel le plus long.
Un chart horizontal montrant la durée d'un appel à une API y compris celle ses dépendances. On peut voir que la durée totale est de 432.29 ms, avec l'appel pour récupérer les disponibilités durant 322.03 ms, l'appel pour récupérer les informations des chambres 54.34 ms, l'appel pour récupérer les images 74.03 ms et celui pour récupérer les tarifs 107.04 ms. Cette fois, les 3 derniers appels sont "empilés", la durée totale étant égale à celle de l'appel le plus long.

C’est quand même mieux ! Il semble qu’on ait réussit à économiser près de 40% sur le temps de réponse ! Cependant, on va éviter de tirer des conclusions à partir de seulement 2 échantillons. Voyons comment nous pouvons en rassembler plus afin d’obtenir des chiffres plus fiables.

Automatiser les appels à notre API avec k6

En 2022, j’ai entendu parler d’un outil nommé k6. Je n’en avais pas eu besoin à l’époque mais quand les soucis de performances ont fait surface en 2023, je l’ai essayé.
Ce que j’ai fait est très similaire à ce que vous pouvez voir dans le script k6/perfs.js : effectuer N appels l’un après l’autre à chaque endpoint de l’API puis regarder les chiffres. Voici à quoi cela ressemble après 100 appels à chaque endpoint :

Impression d'écran d'un terminal contenant la sortie de k6 après 100 appels à chacun des 2 endpoints de l'API. Il y a 2 rectangles rouges.

Le premier encadre 2 lignes, 1 pour chaque scénario (parallèle et séquentiel).
Pour la partie parallèle, l'appel moyen a pris 425 ms, la médiane étant à 411.9 ms et le p(95) prenant 539.51 ms.
Pour le scénario séquentiel, l'appel moyen a pris 578.25 ms, la médiane est à 577.18  ms et le p(95) à 676.59 ms.

Le deuxième encadré contient la durée totale des 100 appels de chaque scénario. La durée pour le scénario parallèle est de 42.5 secondes, et celle pour le scénario séquentiel est de 57.9 secondes.

C’est un peu verbeux donc j’ai mis en évidence en rouge les chiffres qui nous intéressent ici. En gros, on peut voir que la durée moyenne est meilleure de 26%, la médiane de 28% et sur la p(95) de 20%. Gardez en tête que la latence des dépendances est simulé avec Random.Next() et suit sa distribution (qui doit être, je crois, uniforme mais je ne jouerais pas mon PEL là-dessus).
De plus, la durée totale est bien plus courte avec la version parallèle. Chaque gain se cumule pour finir par une amélioration de 26% qui correspond à l’amélioration sur la moyenne vue précédemment.

Conclusion

Bien évidemment, paralléliser les appels aux dépendances externes est souvent un « quick win ». Et on peut les repérer facilement avec un outil de surveillance basique. N’hésitez pas à jouer avec le code du repository car Aspire fournit un tel outil et permet aussi de simuler des dépendances ou d’inclure d’autres containers (ex : Redis, bases NoSQL, etc…).

Une dernière chose…

Je disais plus haut que nous reviendrions sur le code de la parallélisation, notamment cette partie :

var roomIds = availabilities.Select(availability => availability.RoomId).ToArray();
var roomInformationTask = roomInformationProvider.GetInformation(roomIds);
var roomPicturesTask = roomInformationProvider.GetPictures(roomIds);
var rateIds = availabilities.Select(availability => availability.RateId);
var rateInformationTask = rateInformationProvider.GetInformation(rateIds);

var (roomInformation, roomPictures, rateInformation) = (await roomInformationTask, await roomPicturesTask, await rateInformationTask);

Que se passe-t-il ici ? On peut voir que les appels asynchrones aux dépendances ne sont pas attendus (« awaited ») immédiatement. Le truc c’est que les tâches sont créées et lancées dès que les méthodes asynchrones sont appelées. C’est souvent une grande source de confusion parmis les développeurs .Net. La plupart pensent qu’un Task.WhenAll() est nécessaire pour que les tâches soient effectivement lancées. Non, elles sont lancées directement quand les méthodes async sont appelées.

Ligne 7, j’ai fait un await pour toutes les tâches à la manière d’une déconstruction pour éviter le code suivant :

var roomInformation = await roomInformationTask;
var roomPictures = await roomPicturesTask;
var rateInformation = await rateInformationTask;

Pour finir sur ce sujet, vous pouvez jeter un oeil à cette discussion sur Stack Overflow. Les esprits s’échauffent un peu ici et là mais gardez à l’esprit ce que j’ai dit : les tâches sont lancées dès que les méthodes async sont appelées. C’est aussi corroboré par le comportement et les graphes de notre API ci-dessus. Puis vous réaliserez que la réponse plébiscitée conseille d’utiliser Task.WhenAll() alors que c’est inutile en réalité. Et vous remarquerez aussi que le commentaire plébiscité pour cette réponse est aussi complètement faux. Si, après tout ça, vous ne voulez pas me croire, faites confiance à Stephen Cleary 😉

Commentaire par Stephen Cleary sur Stack Overflow disant : "The key is that asynchronous methods always return hot (already started) tasks."

Toutefois, plus loin dans la discussion, Stephen soulève un point intéressant : un Task.WhenAll() explicite enlèvera toute confusion pour les développeurs moins expérimentés. D’où son utilité après tout. J’ai choisi de ne pas le faire dans ce code justement pour discuter de ce sujet dans l’article. J’espère que ça a été utile !

Software developer since 2000, I try to make the right things right. I usually work with .Net (C#) and Azure, mostly because the ecosystem is really friendly and helps me focus on the right things.
I try to avoid unnecessary (a.k.a. accidental) complexity and in general everything that gets in the way of solving the essential complexity induced by the business needs.
This is why I favor a test-first approach to focus on the problem space and ask questions to business experts before even coding the feature.

Catégories : Dev

Guillaume Téchené

Software developer since 2000, I try to make the right things right. I usually work with .Net (C#) and Azure, mostly because the ecosystem is really friendly and helps me focus on the right things. I try to avoid unnecessary (a.k.a. accidental) complexity and in general everything that gets in the way of solving the essential complexity induced by the business needs. This is why I favor a test-first approach to focus on the problem space and ask questions to business experts before even coding the feature.