Le modèle asynchrone apporte-t-il vraiment des avantages en termes de débit par rapport à un modèle synchrone correctement configuré ?

Tout le monde sait que l’asynchronie offre un “meilleur débit”, une “scalabilité” et une consommation de ressources plus efficace. Je pensais aussi de cette manière (simpliste) avant de réaliser l’expérience ci-dessous. Elle montre essentiellement que si l’on prend en compte toute la surcharge du code asynchrone et qu’on le compare à du code synchrone correctement configuré, cela n’apporte que peu ou pas d’avantages en termes de performances/débit/consommation de ressources.

La question : le code asynchrone est-il vraiment tellement plus performant comparé au code synchrone avec un pool de threads correctement configuré ? Peut-être que mes tests de performance sont fondamentalement biaisés ?

Configuration du test : deux méthodes ASP.NET Web API avec JMeter essayant de les appeler avec un groupe de 200 threads (temps de montée en charge de 30 secondes).

[HttpGet]
[Route("async")]
public async Task<string> AsyncTest()
{
    await Task.Delay(_delayMs);

    return "ok";
}

[HttpGet]
[Route("sync")]
public string SyncTest()
{
    Thread.Sleep(_delayMs);

    return "ok";
}

Voici le temps de réponse (échelle logarithmique). Remarquez comment le code synchrone devient plus rapide lorsque le pool de threads a injecté suffisamment de threads. Si nous avions configuré le pool de threads au préalable (via SetMinThreads), il aurait surpassé l’async dès le départ.

Et la consommation de ressources, me demanderez-vous ? “Un thread a un coût important en termes de temps CPU pour l’ordonnancement, le changement de contexte et l’empreinte RAM”. Pas si vite. L’ordonnancement et le changement de contexte des threads sont efficaces. En ce qui concerne l’utilisation de la pile, le thread ne consomme pas instantanément la RAM mais réserve simplement l’espace d’adressage virtuel et n’alloue qu’une petite fraction de ce qui est réellement nécessaire.

Regardons ce que disent les données. Même avec un plus grand nombre de threads, la version synchrone a une empreinte mémoire plus faible (working set qui correspond à la mémoire physique).

MISE À JOUR. Je souhaite publier les résultats de l’expérience complémentaire qui devrait être plus représentative car elle évite certains biais de la première.

Tout d’abord, les résultats de la première expérience sont obtenus avec IIS Express, qui est essentiellement un serveur de développement, j’ai donc dû m’en éloigner. De plus, en tenant compte des retours, j’ai isolé la machine de génération de charge du serveur (deux VM Azure dans le même réseau). J’ai également découvert que certaines limites de threading IIS sont difficiles voire impossibles à contourner et j’ai fini par passer à l’auto-hébergement ASP.NET WebAPI pour éliminer IIS des variables. Notez que les empreintes mémoire/temps CPU sont radicalement différents avec ce test, veuillez ne pas comparer les chiffres entre les différentes exécutions de test car les configurations sont totalement différentes (hébergement, matériel, configuration des machines). De plus, quand je suis passé sur d’autres machines et une autre solution d’hébergement, la stratégie du pool de threads a changé (elle est dynamique) et le taux d’injection a augmenté.

Paramètres : délai 100 ms, 200 “utilisateurs” JMeter, temps de montée en charge de 30 secondes.

Je souhaite conclure ces expériences par ce qui suit : oui, dans certaines circonstances spécifiques (plutôt de type laboratoire), il est possible d’obtenir des résultats comparables entre synchrone et asynchrone, mais dans les cas réels où la charge de travail ne peut pas être prévisible à 100 % et où la charge est inégale, nous finirons inévitablement par atteindre une sorte de limite de threading : soit des limites côté serveur, soit des limites de croissance du pool de threads (et gardez à l’esprit que la gestion du pool de threads est un mécanisme automatique dont les propriétés ne sont pas toujours facilement prévisibles). De plus, la version synchrone a effectivement une empreinte mémoire plus importante (à la fois le working set et une taille de mémoire virtuelle bien plus grande). En ce qui concerne la consommation CPU, l’asynchrone gagne également (métrique du temps CPU par requête).

Sur IIS avec les paramètres par défaut, la situation est encore plus dramatique : la version synchrone est d’un ou plusieurs ordres de grandeur plus lente (et un débit moindre) en raison de la limite assez stricte du nombre de threads - 20 par CPU.

PS. Utilisez les pipelines asynchrones pour les E/S ! [… soupir de soulagement…]

Tout le monde sait que l’asynchronie offre un “meilleur débit”, une “scalabilité” et une consommation de ressources plus efficace.

La scalabilité, oui. Le débit : cela dépend. Chaque requête asynchrone est plus lente que la requête synchrone équivalente, vous ne verrez donc un avantage de débit que lorsque la scalabilité entre en jeu (c’est-à-dire qu’il y a plus de requêtes que de threads disponibles).

Le code asynchrone est-il vraiment tellement plus performant comparé au code synchrone avec un pool de threads correctement configuré ?

Eh bien, le piège réside dans “pool de threads correctement configuré”. Ce que vous supposez, c’est que vous pouvez 1) prédire votre charge, et 2) avoir un serveur suffisamment puissant pour la gérer en utilisant un thread par requête. Pour beaucoup (la plupart ?) de scénarios de production réels, l’une ou les deux conditions ne sont pas remplies.

D’après mon article sur ASP.NET asynchrone :

Pourquoi ne pas simplement augmenter la taille du pool de threads [au lieu d’utiliser l’asynchrone] ? La réponse est double : le code asynchrone se met à l’échelle à la fois davantage et plus rapidement que le blocage des threads du pool de threads.

Premièrement, le code asynchrone se met à l’échelle davantage que le code synchrone. Avec un code d’exemple plus réaliste, la scalabilité totale des serveurs ASP.NET (testés en charge) a montré une augmentation multiplicative. En d’autres termes, un serveur asynchrone pouvait gérer plusieurs fois le nombre de requêtes continues par rapport à un serveur synchrone (avec les deux pools de threads poussés au maximum pour ce matériel). Cependant, ces expériences (non réalisées par moi) ont été effectuées sur une “référence réaliste attendue” pour des applications ASP.NET moyennes. Je ne sais pas comment les mêmes résultats se transposeraient à un simple retour de chaîne vide.

Deuxièmement, le code asynchrone se met à l’échelle plus rapidement que le code synchrone. C’est assez évident ; le code synchrone se met à l’échelle correctement jusqu’au nombre de threads du pool de threads, mais ne peut ensuite pas se mettre à l’échelle plus vite que le taux d’injection de threads. Vous obtenez donc cette réponse vraiment lente face à une charge soudaine et importante, comme montré au début de votre graphique de temps de réponse.

Je pense que le travail que vous avez réalisé est intéressant ; je suis particulièrement surpris par les différences d’utilisation mémoire (ou plutôt, l’absence de différence). J’adorerais que vous transformiez cela en article de blog. Recommandations :

  • Utilisez ASP.NET Core pour vos tests. L’ancien ASP.NET n’avait qu’un pipeline partiellement asynchrone ; ASP.NET Core serait nécessaire pour une comparaison plus “pure” entre synchrone et asynchrone.

  • Ne testez pas en local ; il y a beaucoup de mises en garde en faisant cela. Je recommanderais de choisir une taille de VM (ou un conteneur Docker mono-instance ou autre) et de tester dans le cloud pour la reproductibilité.

  • Essayez également les tests de stress en plus des tests de charge. Augmentez continuellement la charge jusqu’à ce que le serveur soit totalement submergé, et observez comment les serveurs asynchrone et synchrone réagissent.

En rappel final (également tiré de mon article) :

Gardez à l’esprit que le code asynchrone ne remplace pas le pool de threads. Ce n’est pas pool de threads ou code asynchrone ; c’est pool de threads et code asynchrone. Le code asynchrone permet à votre application d’utiliser le pool de threads de manière optimale. Il prend le pool de threads existant et le pousse au maximum.