J'ai récemment créé une application simple pour tester le débit des appels HTTP, qui peut être générée 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 affiche à la fin le temps total nécessaire pour les effectuer. Au cours de mes tests, tous les appels HTTP ont été passés vers mon serveur local IIS) et ils ont récupéré un petit fichier texte (taille de 12 octets).
La partie la plus importante du code pour l'implémentation asynchrone est répertoriée ci-dessous:
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<HttpResponseMessage> 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 la mise en œuvre multithreading est listée ci-dessous:
public void TestParallel2()
{
this.TestInit();
ServicePointManager.DefaultConnectionLimit = 100;
for (int i = 0; i < NUMBER_OF_REQUESTS; i++)
{
Task.Run(() =>
{
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 seconde à la requête pour 10 000 requêtes, contre 2 secondes pour la demande asynchrone pour la même charge. C'était un peu surprenant, car je m'attendais à une version asynchrone 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 devrait effectuer une opération plus significative et où il devrait également exister une certaine latence du réseau, les résultats pourraient être inversés.
Cependant, ce qui me préoccupe vraiment, c'est le comportement de HttpClient lorsque la charge augmente. Puisqu'il faut environ 2 secondes pour livrer 10 000 messages, j'ai pensé qu'il faudrait environ 20 secondes pour envoyer 10 fois plus de messages, mais l'exécution du test a montré qu'il fallait environ 50 secondes pour transmettre les 100 000 messages. De plus, la livraison de 200 000 messages prend généralement plus de 2 minutes et quelques milliers d'entre eux (3 à 4 000) échouent, à l'exception suivante:
Une opération sur un socket n'a pas pu être effectuée car le système manquait de suffisamment d'espace tampon ou parce qu'une file d'attente était pleine.
J'ai vérifié les IIS journaux et les opérations qui ont échoué ne sont jamais parvenus au serveur. Ils ont échoué dans le client. J'ai exécuté les tests sur un ordinateur 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 lors des tests, donc, en théorie, ils auraient dû être beaucoup plus disponibles. Si le manque de ports était effectivement la cause des exceptions, cela signifiait que ce n’était pas Netstat qui le signalait correctement. situation ou HttClient utilise uniquement un nombre maximal de ports après lequel il commence à générer des exceptions.
En revanche, l’approche multithread permettant de générer des appels HTTP se comportait de manière très prévisible. Je l'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 son exécution, il n’utilisait jamais plus de 55 Mo de RAM (selon le gestionnaire de tâches Windows). La mémoire utilisée pour l’envoi de messages de manière asynchrone augmentait proportionnellement à la charge. Elle utilisait environ 500 Mo de RAM lors des tests de 200k messages.
Je pense que les résultats ci-dessus ont deux raisons principales. La première est que HttpClient semble être très gourmand en créant de nouvelles connexions avec le serveur. Le nombre élevé de ports utilisés signalés par netstat signifie qu'il ne bénéficie probablement pas beaucoup du maintien en vie de 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 démarrées en même temps, puis 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é démontré précédemment, 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 mémoire et en 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 < NUMBER_OF_REQUESTS; i++)
{
if (_activeRequestsCount >= MAX_CONCURENT_REQUESTS)
await Task.Delay(DELAY_TIME);
ProcessUrlAsyncWithReqCount(httpClient);
}
}
Il serait vraiment utile que HttpClient inclue un mécanisme pour limiter le nombre de demandes simultanées. Lorsque vous utilisez la classe Task (qui est basée sur le pool de threads .Net), la limitation est automatiquement obtenue en limitant le nombre de threads simultanés.
Pour un aperçu complet, j'ai également créé une version du test asynchrone basée sur HttpWebRequest plutôt que sur HttpClient et j'ai réussi à 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é pour aucune demande (HttpClient, par défaut, est basé sur HttpWebRequest , mais il semble ignorer le paramètre de limite de connexion).
L'approche asynchrone HttpWebRequest était toujours environ 50 à 60% plus lente que l'approche 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 demandes. En limitant le nombre de demandes simultanées (comme je l’ai fait plus haut pour HttpClient), j’ai réussi à réduire la mémoire utilisée à tout juste 20 Mo et à obtenir un temps d’exécution juste 10% plus lent que l’approche 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 à charge intensive? Y a-t-il un moyen de l'étouffer, ce qui devrait résoudre les problèmes que je mentionne? Que diriez-vous de la saveur asynchrone de HttpWebRequest?
Mise à jour (merci @Stephen Cleary)
Il s'avère que 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 de la limite de connexion est 2. J'ai également vérifié cela de mon côté en utilisant le débogueur qui indiquait que 2 est la valeur par défaut. Cependant, il semble que, sauf si vous définissez explicitement une valeur sur ServicePointManager.DefaultConnectionLimit, la valeur par défaut sera ignorée. Comme je n’ai pas explicitement défini de valeur lors de mes tests HttpClient, j’ai pensé que c’é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 HttpWebRequest asynchrone (environ 40%), mais étrangement, il utilise moins de mémoire. Pour le test qui implique 1 million de requêtes, il a utilisé au maximum 550 Mo, contre 1,6 Go en asynchrone HttpWebRequest.
Ainsi, bien que HttpClient en combinaison ServicePointManager.DefaultConnectionLimit semble assurer la fiabilité (du moins pour le scénario dans lequel tous les appels sont passés vers le même hôte), il semble toujours que ses performances sont négativement affectées par l’absence d’un mécanisme de limitation approprié. Quelque chose qui limiterait le nombre de demandes simultanées à une valeur configurable et mettrait le reste dans une file d'attente le rendrait beaucoup plus adapté aux scénarios à forte évolutivité.
Outre les tests mentionnés dans la question, j'en ai récemment créé de nouveaux comportant beaucoup moins d'appels HTTP (5 000 contre 1 million précédemment), mais sur des demandes beaucoup plus longues à exécuter (500 millisecondes contre environ 1 milliseconde auparavant). Les deux applications de test, l'une multithread synchrone (basée sur HttpWebRequest) et l'autre asynchrone (basée sur le client HTTP) ont produit des résultats similaires: environ 10 secondes d'exécution avec environ 3% de la CPU et 30 Mo de mémoire. La seule différence entre les deux testeurs était que le processus multithread utilisait 310 threads à exécuter, tandis que le processus asynchrone n'en comptait que 22. Ainsi, dans une application qui aurait combiné les opérations liées aux entrées/sorties et aux processeurs, la version asynchrone aurait produit de meilleurs résultats. car il y aurait eu plus de temps processeur disponible pour les threads effectuant des opérations de processeur, ceux qui en ont réellement besoin (les threads en attente de la fin des opérations d'E/S ne font que gaspiller).
En conclusion de mes tests, les appels HTTP asynchrones ne sont pas la meilleure option pour traiter des requêtes très rapides. Cela s'explique par le fait 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 le reste de la tâche est enregistré en tant que rappel. Ensuite, à la fin de l'opération d'E/S, le rappel est mis en file d'attente 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 traiter des opérations d'E/S longues ou potentiellement longues, car elles ne gardent pas les threads occupés pendant l'attente de la fin des opérations d'E/S. Cela réduit le nombre total de threads utilisés par une application, ce qui permet de consacrer davantage de temps CPU aux opérations liées à la CPU. En outre, sur les applications qui n'affectent qu'un nombre limité de threads (comme c'est le cas avec les applications Web), les E/S asynchrones empêchent l'épuisement des threads du pool de threads, ce qui peut se produire si les appels d'E/S sont effectués de manière synchrone.
Ainsi, async HttpClient n'est pas un goulot d'étranglement pour les applications à charge intensive. C'est simplement que, de par sa nature, il n'est pas très bien adapté aux requêtes HTTP très rapides, il est plutôt idéal pour les requêtes longues ou potentiellement longues, en particulier dans les applications ne disposant que d'un nombre limité de threads. De plus, il est recommandé de limiter les accès simultanés via ServicePointManager.DefaultConnectionLimit avec une valeur suffisamment élevée pour assurer un bon niveau de parallélisme, mais suffisamment faible pour éviter l'épuisement des ports éphémères. Vous pouvez trouver 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 consommez pas ce flux. Avec HttpClient, par défaut, il copie le flux de réseau dans un flux 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<HttpResponseMessage> getTask = httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead);
L'autre chose est que je ne suis pas vraiment sûr de ce que la vraie différence, en termes de threading, vous testez réellement. Si vous creusez dans les profondeurs de HttpClientHandler, il suffit de faire Task.Factory.StartNew pour exécuter une demande asynchrone. Le comportement de threading est délégué au contexte de synchronisation exactement de la même manière que votre exemple avec l'exemple HttpWebRequest.
Sans aucun doute, HttpClient ajoute une surcharge car il utilise par défaut HttpWebRequest comme bibliothèque de transport. Ainsi, vous pourrez toujours obtenir de meilleurs résultats avec un HttpWebRequest directement tout en utilisant HttpClientHandler. Les avantages apportés par 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 parfaite.
Bien que cela ne réponde pas directement à la partie "asynchrone" de la question du PO, cela corrige une erreur dans la mise en œuvre qu'il utilise.
Si vous souhaitez que votre application évolue, évitez d'utiliser HttpClients basé sur une instance. La différence est énorme! En fonction de la charge, vous verrez des chiffres de performance très différents. HttpClient a été conçu pour être réutilisé dans toutes les demandes. Cela a été confirmé par les membres de l'équipe de la BCL qui l'ont écrit.
Un projet récent que j’avais consistait à aider un très grand et bien connu détaillant en ligne d’ordinateur à s’élever pour le trafic du Black Friday/vacances pour certains nouveaux systèmes. Nous avons rencontré des problèmes de performances liés à l’utilisation de HttpClient. Puisqu'il implémente IDisposable
, les développeurs ont fait ce que vous feriez normalement en créant une instance et en la plaçant à l'intérieur d'une instruction using()
. Une fois que nous avons commencé les tests de charge, l'application a mis le serveur à genoux - oui, le serveur et pas seulement l'application. La raison en est que chaque instance de HttpClient ouvre un port d’achèvement des E/S sur le serveur. En raison de la finalisation non déterministe du CPG et du fait que vous utilisez des ressources informatiques couvrant plusieurs couches OSI , la fermeture des ports réseau peut prendre un certain temps. En fait, le système d'exploitation Windows lui-même peut prendre jusqu'à 20 secondes pour fermer un port (par Microsoft). Nous ouvrions des ports plus rapidement qu’ils ne pourraient être fermés - l’épuisement des ports de serveur, qui a mis le processeur à 100%. Mon correctif était de changer le HttpClient à une instance statique qui a résolu le problème. Oui, il s’agit d’une ressource jetable, mais la différence de performances l’emporte sur les frais généraux. Je vous encourage à faire des tests de charge pour voir comment votre application se comporte.
Également répondu au lien ci-dessous:
Quel est le surcoût 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