Async HttpClient à partir de .Net 4.5 est-il un mauvais choix pour les applications de chargement intensif?

J’ai récemment créé une application simple pour tester le débit d’appels HTTP qui peut être généré de manière asynchrone par rapport à une approche multithread classique.

L’application est capable d’effectuer un nombre prédéfini d’appels HTTP et à la fin, elle affiche le temps total nécessaire pour les exécuter. Lors de mes tests, tous les appels HTTP ont été effectués sur mon serveur IIS local et ils ont récupéré un petit fichier texte (de 12 octets).

La partie la plus importante du code pour l’implémentation asynchrone est la suivante:

public async void TestAsync() { this.TestInit(); HttpClient httpClient = new HttpClient(); for (int i = 0; i < NUMBER_OF_REQUESTS; i++) { ProcessUrlAsync(httpClient); } } private async void ProcessUrlAsync(HttpClient httpClient) { HttpResponseMessage httpResponse = null; try { Task getTask = httpClient.GetAsync(URL); httpResponse = await getTask; Interlocked.Increment(ref _successfulCalls); } catch (Exception ex) { Interlocked.Increment(ref _failedCalls); } finally { if(httpResponse != null) httpResponse.Dispose(); } lock (_syncLock) { _itemsLeft--; if (_itemsLeft == 0) { _utcEndTime = DateTime.UtcNow; this.DisplayTestResults(); } } } 

La partie la plus importante de l’implémentation multithreading est la suivante:

 public void TestParallel2() { this.TestInit(); ServicePointManager.DefaultConnectionLimit = 100; for (int i = 0; i  { try { this.PerformWebRequestGet(); Interlocked.Increment(ref _successfulCalls); } catch (Exception ex) { Interlocked.Increment(ref _failedCalls); } lock (_syncLock) { _itemsLeft--; if (_itemsLeft == 0) { _utcEndTime = DateTime.UtcNow; this.DisplayTestResults(); } } }); } } private void PerformWebRequestGet() { HttpWebRequest request = null; HttpWebResponse response = null; try { request = (HttpWebRequest)WebRequest.Create(URL); request.Method = "GET"; request.KeepAlive = true; response = (HttpWebResponse)request.GetResponse(); } finally { if (response != null) response.Close(); } } 

L’exécution des tests a révélé que la version multithread était plus rapide. Il a fallu environ 0,6 secondes pour compléter 10 000 requêtes, tandis que l’async a mis environ 2 secondes pour terminer la même quantité de charge. C’était un peu surprenant, car je m’attendais à ce que la version asynchrone soit plus rapide. Peut-être était-ce dû au fait que mes appels HTTP étaient très rapides. Dans un scénario réel, où le serveur doit effectuer une opération plus significative et où il doit également y avoir une certaine latence du réseau, les résultats peuvent être inversés.

Cependant, ce qui me préoccupe vraiment, c’est le comportement de HttpClient lorsque la charge est augmentée. Puisqu’il faut environ 2 secondes pour envoyer 10 000 messages, j’ai pensé qu’il faudrait environ 20 secondes pour délivrer 10 fois plus de messages, mais l’exécution du test a montré qu’il fallait environ 50 secondes pour délivrer les 100 000 messages. En outre, il faut généralement plus de 2 minutes pour envoyer 200 000 messages et, souvent, quelques milliers d’entre eux (3 à 4 Ko) échouent à l’exception suivante:

Une opération sur un socket n’a pas pu être effectuée car le système ne disposait pas de suffisamment d’espace tampon ou qu’une queue était pleine.

J’ai vérifié les journaux IIS et les opérations qui n’ont pas abouti sur le serveur. Ils ont échoué dans le client. J’ai effectué les tests sur une machine Windows 7 avec la plage par défaut de ports éphémères de 49152 à 65535. L’exécution de netstat a montré qu’environ 5 à 6 000 ports étaient utilisés pendant les tests. En théorie, il devrait y en avoir beaucoup plus. Si le manque de ports était en effet à l’origine des exceptions, cela signifie que netstat n’a pas correctement signalé la situation ou que HttClient n’utilise qu’un nombre maximal de ports, après quoi il commence à générer des exceptions.

En revanche, l’approche multithread consistant à générer des appels HTTP se comportait de manière très prévisible. J’ai pris environ 0,6 seconde pour 10 000 messages, environ 5,5 secondes pour 100 000 messages et, comme prévu, environ 55 secondes pour 1 million de messages. Aucun des messages n’a échoué. De plus, pendant qu’il fonctionnait, il n’utilisait jamais plus de 55 Mo de RAM (selon le Gestionnaire des tâches Windows). La mémoire utilisée lors de l’envoi de messages de manière asynchrone a augmenté proportionnellement à la charge. Il a utilisé environ 500 Mo de RAM lors des tests de 200 000 messages.

Je pense qu’il y a deux raisons principales aux résultats ci-dessus. Le premier est que HttpClient semble être très avide dans la création de nouvelles connexions avec le serveur. Le nombre élevé de ports utilisés signalés par netstat signifie qu’il n’est probablement pas très utile pour le maintien du protocole HTTP.

La seconde est que HttpClient ne semble pas avoir de mécanisme de limitation. En fait, cela semble être un problème général lié aux opérations asynchrones. Si vous devez effectuer un très grand nombre d’opérations, elles seront toutes lancées en même temps et leurs suites seront exécutées dès qu’elles seront disponibles. En théorie, cela devrait être correct, car dans les opérations asynchrones, la charge est sur des systèmes externes, mais comme cela a été prouvé ci-dessus, ce n’est pas tout à fait le cas. Avoir un grand nombre de requêtes démarrées en même temps augmentera l’utilisation de la mémoire et ralentira toute l’exécution.

J’ai réussi à obtenir de meilleurs résultats, en termes de mémoire et de temps d’exécution, en limitant le nombre maximal de requêtes asynchrones avec un mécanisme de délai simple mais primitif:

 public async void TestAsyncWithDelay() { this.TestInit(); HttpClient httpClient = new HttpClient(); for (int i = 0; i = MAX_CONCURENT_REQUESTS) await Task.Delay(DELAY_TIME); ProcessUrlAsyncWithReqCount(httpClient); } } 

Ce serait très utile si HttpClient incluait un mécanisme pour limiter le nombre de requêtes simultanées. Lorsque vous utilisez la classe de tâches (basée sur le pool de threads .Net), la limitation est automatiquement réalisée en limitant le nombre de threads simultanés.

Pour un aperçu complet, j’ai également créé une version du test async basé sur HttpWebRequest plutôt que HttpClient et géré pour obtenir de bien meilleurs résultats. Pour commencer, il permet de limiter le nombre de connexions simultanées (avec ServicePointManager.DefaultConnectionLimit ou via config), ce qui signifie qu’il n’a jamais manqué de ports et n’a jamais échoué sur une requête (HttpClient, par défaut, est basé sur HttpWebRequest). , mais il semble ignorer le paramètre de limite de connexion).

L’approche asynchrone HttpWebRequest était encore inférieure d’environ 50 à 60% à celle du multithreading, mais elle était prévisible et fiable. Le seul inconvénient était qu’il utilisait une énorme quantité de mémoire sous une charge importante. Par exemple, il fallait environ 1,6 Go pour envoyer 1 million de requêtes. En limitant le nombre de requêtes simultanées (comme je l’ai fait ci-dessus pour HttpClient), j’ai réussi à réduire la mémoire utilisée à seulement 20 Mo et à obtenir un temps d’exécution inférieur de 10% à celui du multithreading.

Après cette longue présentation, mes questions sont les suivantes: La classe HttpClient de .Net 4.5 est-elle un mauvais choix pour les applications de chargement intensif? Y a-t-il un moyen de l’étouffer, ce qui devrait régler les problèmes dont je parle? Que diriez-vous de la saveur asynchrone de HttpWebRequest?

Mise à jour (merci @Stephen Cleary)

Comme il se trouve, HttpClient, tout comme HttpWebRequest (sur lequel il est basé par défaut), peut avoir son nombre de connexions simultanées sur le même hôte limité avec ServicePointManager.DefaultConnectionLimit. La chose étrange est que selon MSDN , la valeur par défaut pour la limite de connexion est 2. J’ai également vérifié cela de mon côté en utilisant le débogueur qui indique que 2 est la valeur par défaut. Cependant, il semble que, à moins de définir explicitement une valeur sur ServicePointManager.DefaultConnectionLimit, la valeur par défaut sera ignorée. Comme je ne l’ai pas explicitement défini lors de mes tests HttpClient, je pensais que cela était ignoré.

Après avoir défini ServicePointManager.DefaultConnectionLimit sur 100, HttpClient est devenu fiable et prévisible (netstat confirme que seuls 100 ports sont utilisés). Il est toujours plus lent que async HttpWebRequest (d’environ 40%), mais étrangement, il utilise moins de mémoire. Pour le test qui implique 1 million de requêtes, il utilisait un maximum de 550 Mo, contre 1,6 Go dans le HttpWebRequest asynchrone.

Ainsi, alors que HttpClient en combinaison ServicePointManager.DefaultConnectionLimit semble garantir la fiabilité (du moins pour le scénario où tous les appels sont effectués vers le même hôte), il semblerait que l’absence de mécanisme de limitation approprié ait un impact négatif sur ses performances. Quelque chose qui limiterait le nombre de requêtes simultanées à une valeur configurable et placerait le rest dans une queue le rendrait beaucoup plus adapté aux scénarios à forte évolutivité.

Outre les tests mentionnés dans la question, j’ai récemment créé de nouveaux appels impliquant beaucoup moins d’appels HTTP (5000 contre 1 million auparavant) mais sur des requêtes beaucoup plus longues à exécuter (500 millisecondes contre environ 1 milliseconde auparavant). Les deux applications de test, la multithread synchrone (basée sur HttpWebRequest) et la première E / S asynchrone (basée sur le client HTTP) ont produit des résultats similaires: environ 10 secondes pour exécuter environ 3% du processeur et 30 Mo de mémoire. La seule différence entre les deux testeurs était que le multithread utilisait 310 threads pour s’exécuter, alors que le seul asynchrone juste 22 threads. Ainsi, dans une application qui aurait combiné à la fois des opérations liées aux E / S car il y aurait eu plus de temps processeur disponible pour les threads effectuant des opérations sur le processeur, celles qui en ont réellement besoin (les threads en attente d’opérations d’E / S ne font que perdre).

En conclusion de mes tests, les appels HTTP asynchrones ne sont pas la meilleure option pour traiter des requêtes très rapides. La raison en est que lors de l’exécution d’une tâche contenant un appel d’E / S asynchrone, le thread sur lequel la tâche est démarrée est quitté dès que l’appel asynchrone est effectué et que le rest de la tâche est enregistré en tant que rappel. Ensuite, lorsque l’opération d’E / S est terminée, le rappel est mis en queue pour être exécuté sur le premier thread disponible. Tout cela crée une surcharge qui rend les opérations d’E / S rapides plus efficaces lorsqu’elles sont exécutées sur le thread qui les a démarrées.

Les appels HTTP asynchrones sont une bonne option pour gérer des opérations d’E / S longues ou potentiellement longues, car ils n’activent aucune tâche sur les opérations d’E / S. Cela réduit le nombre total de threads utilisés par une application, ce qui permet de consacrer plus de temps processeur aux opérations liées au processeur. De plus, sur les applications qui n’allouent qu’un nombre limité de threads (comme c’est le cas avec les applications Web), les E / S asynchrones empêchent l’épuisement du thread du pool de threads, ce qui peut se produire de manière synchrone.

Ainsi, async HttpClient n’est pas un goulot d’étranglement pour les applications de chargement intensif. Il est juste que par sa nature, il n’est pas très bien adapté aux requêtes HTTP très rapides, mais il est idéal pour les requêtes longues ou potentiellement longues, en particulier dans les applications qui ne disposent que d’un nombre limité de threads. En outre, il est conseillé de limiter la simultanéité via ServicePointManager.DefaultConnectionLimit avec une valeur suffisamment élevée pour garantir un bon niveau de parallélisme, mais suffisamment faible pour empêcher toute perte de port éphémère. Vous trouverez plus de détails sur les tests et les conclusions présentés pour cette question ici .

Une chose à considérer qui pourrait affecter vos résultats est que, avec HttpWebRequest, vous n’obtenez pas le ResponseStream et ne consumz pas ce stream. Avec HttpClient, il copie par défaut le stream réseau dans un stream de mémoire. Pour utiliser HttpClient de la même manière que vous utilisez actuellement HttpWebRquest, vous devez le faire

 var requestMessage = new HttpRequestMessage() {RequestUri = URL}; Task getTask = httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead); 

L’autre chose est que je ne suis pas vraiment sûr de la différence réelle, du sharepoint vue du threading, que vous testez actuellement. Si vous creusez dans les profondeurs de HttpClientHandler, il suffit simplement d’effectuer Task.Factory.StartNew pour effectuer une requête asynchrone. Le comportement des threads est délégué au contexte de synchronisation de la même manière que votre exemple avec l’exemple HttpWebRequest.

Sans aucun doute, HttpClient ajoute un peu de surcharge car il utilise par défaut HttpWebRequest comme bibliothèque de transport. Ainsi, vous pourrez toujours obtenir de meilleures performances directement avec HttpWebRequest lorsque vous utilisez HttpClientHandler. Les avantages de HttpClient sont les classes standard telles que HttpResponseMessage, HttpRequestMessage, HttpContent et tous les en-têtes fortement typés. En soi, ce n’est pas une optimisation de perf.

Bien que cela ne réponde pas directement à la partie «asynchrone» de la question du PO, cela corrige une erreur dans l’implémentation qu’il utilise.

Si vous souhaitez que votre application évolue, évitez d’utiliser des clients HTTP basés sur des instances. La différence est énorme! En fonction de la charge, vous verrez des performances très différentes. HttpClient a été conçu pour être réutilisé dans les requêtes. Cela a été confirmé par les gars de l’équipe BCL qui l’ont écrit.

Un de mes projets récents consistait à aider un grand détaillant en ligne bien connu à faire évoluer le trafic Black Friday / vacances pour certains nouveaux systèmes. Nous avons rencontré des problèmes de performances liés à l’utilisation de HttpClient. Comme il implémente IDisposable , les développeurs ont fait ce que vous feriez normalement en créant une instance et en la plaçant dans une instruction using() . Une fois que nous avons commencé à tester le chargement, l’application a mis le serveur à genoux – oui, le serveur n’est pas seulement l’application. La raison en est que chaque instance de HttpClient ouvre un port d’achèvement d’E / S sur le serveur. En raison de la finalisation non déterministe du GC et du fait que vous travaillez avec des ressources informatiques couvrant plusieurs couches OSI , la fermeture des ports réseau peut prendre un certain temps. En fait, Windows OS lui-même peut prendre jusqu’à 20 secondes pour fermer un port (par Microsoft). Nous ouvrions les ports plus rapidement qu’ils pourraient être fermés – épuisement des ports du serveur, ce qui a pesé sur le processeur à 100%. Mon correctif était de changer le HttpClient en une instance statique qui résolvait le problème. Oui, il s’agit d’une ressource jetable, mais les différences de performances compensent largement les coûts indirects. Je vous encourage à faire des tests de charge pour voir comment se comporte votre application.

A également répondu au lien ci-dessous:

Quelle est la charge de la création d’un nouveau HttpClient par appel dans un client WebAPI?

https://www.asp.net/web-api/overview/advanced/calling-a-web-api-from-a-net-client