J'ai récemment créé une application simple pour tester le débit des 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. Au cours 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 (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 l'implémentation multithreading est répertorié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 pour exécuter 10 000 demandes, tandis que celle asynchrone a pris environ 2 secondes pour la même quantité de charge. C'était un peu une surprise, car je m'attendais à ce que l'asynchrone soit plus rapide. C'était peut-être à cause du fait que mes appels HTTP étaient très rapides. Dans un scénario du monde réel, où le serveur devrait effectuer une opération plus significative et où il devrait é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 la façon dont HttpClient se comporte lorsque la charge est augmentée. Puisqu'il faut environ 2 secondes pour livrer 10 000 messages, j'ai pensé qu'il faudrait environ 20 secondes pour livrer 10 fois le nombre de messages, mais l'exécution du test a montré qu'il fallait environ 50 secondes pour livrer les 100 000 messages. De plus, il faut généralement plus de 2 minutes pour livrer 200 000 messages et souvent, 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 ne disposait pas d'un espace tampon suffisant ou parce qu'une file d'attente était pleine.
J'ai vérifié les journaux IIS et les opérations qui ont échoué ne sont jamais arrivées au serveur. Ils ont échoué au sein du client. J'ai exécuté 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-6k ports étaient utilisés pendant les tests, donc en théorie, il aurait dû y en avoir beaucoup plus disponibles. Si le manque de ports était effectivement la cause des exceptions, cela signifie que soit netstat n'a pas correctement signalé la situation, soit HttClient n'utilise qu'un nombre maximum de ports après quoi il commence à lever des exceptions.
En revanche, l'approche multithread de génération d'appels HTTP s'est comportée 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'a jamais utilisé plus de 55 Mo de RAM (selon le Gestionnaire des tâches de 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 pour les résultats ci-dessus. Le premier 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 profite probablement pas beaucoup de HTTP keep-alive.
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, puis leurs suites seront exécutées au fur et à mesure 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 démontré ci-dessus, ce n'est pas tout à fait le cas. Le fait d'avoir un grand nombre de requêtes démarrées à la fois augmentera l'utilisation de la mémoire et ralentira toute l'exécution.
J'ai réussi à obtenir de meilleurs résultats, mémoire et temps d'exécution, en limitant le nombre maximum 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);
}
}
Ce serait vraiment utile si HttpClient incluait un mécanisme pour limiter le nombre de requêtes simultanées. Lors de l'utilisation de 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 async basée sur HttpWebRequest plutôt que HttpClient et j'ai réussi à obtenir de bien meilleurs résultats. Pour commencer, il permet de fixer une limite sur 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 aucune 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 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 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 seulement 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 de charge intensive? Y a-t-il un moyen de le ralentir, ce qui devrait résoudre les problèmes dont je parle? Que diriez-vous de la saveur asynchrone de HttpWebRequest?
Mise à jour (merci @Stephen Cleary)
En fait, 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 a indiqué qu'en effet 2 est la valeur par défaut. Cependant, il semble qu'à moins de définir explicitement une valeur sur ServicePointManager.DefaultConnectionLimit, la valeur par défaut sera ignorée. Comme je ne lui ai pas explicitement défini de valeur lors de mes tests HttpClient, j'ai pensé qu'il é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 a utilisé un maximum de 550 Mo, contre 1,6 Go dans l'async HttpWebRequest.
Ainsi, bien 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 semble toujours que ses performances soient négativement affectées par l'absence d'un mécanisme de limitation approprié. Quelque chose qui limiterait le nombre simultané de demandes à une valeur configurable et placerait le reste dans une file d'attente le rendrait beaucoup plus adapté aux scénarios à haute évolutivité.
la source
HttpClient
devrait respecterServicePointManager.DefaultConnectionLimit
.SemaphoreSlim
, comme déjà mentionné, ou àActionBlock<T>
partir de TPL Dataflow.Réponses:
Outre les tests mentionnés dans la question, j'ai récemment créé de nouveaux tests impliquant beaucoup moins d'appels HTTP (5000 contre 1 million auparavant) mais sur des requêtes qui prenaient beaucoup plus de temps à s'exécuter (500 millisecondes contre environ 1 milliseconde auparavant). Les deux applications de test, celle multithread synchrone (basée sur HttpWebRequest) et celle d'E / S asynchrone (basée sur le client HTTP) ont produit des résultats similaires: environ 10 secondes pour s'exécuter en utilisant 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, tandis que le asynchrone n'en avait que 22.
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 qui contient un appel d'E / S asynchrone, le thread sur lequel la tâche est démarrée est fermé dès que l'appel asynchrone est effectué et le reste de la tâche est enregistré en tant que rappel. Ensuite, lorsque l'opération d'E / S se termine, le rappel est mis en file d'attente pour exécution sur le premier thread disponible. Tout cela crée une surcharge, ce 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 lorsqu'il s'agit d'opérations d'E / S longues ou potentiellement longues, car elles n'occupent aucun thread en attendant 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 plus de temps processeur aux opérations liées au processeur. En outre, 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 des threads du pool de threads, ce qui peut se produire si des appels d'E / S sont exécutés de manière synchrone.
Ainsi, async HttpClient n'est pas un goulot d'étranglement pour les applications de charge intensive. C'est juste que de par sa nature, il n'est pas très bien adapté aux requêtes HTTP très rapides, au contraire, il est idéal pour les requêtes longues ou potentiellement longues, en particulier dans les applications qui n'ont qu'un nombre limité de threads disponibles. En outre, il est recommandé de limiter la concurrence via ServicePointManager.DefaultConnectionLimit avec une valeur suffisamment élevée pour assurer un bon niveau de parallélisme, mais suffisamment faible pour éviter l'épuisement éphémère des ports. Vous pouvez trouver plus de détails sur les tests et les conclusions présentés pour cette question ici .
la source
Une chose à considérer qui pourrait affecter vos résultats est qu'avec le HttpWebRequest, vous n'obtenez pas le ResponseStream et ne consommez pas ce flux. Avec HttpClient, par défaut, il copiera le flux réseau dans un flux mémoire. Pour utiliser HttpClient de la même manière que vous utilisez actuellement HttpWebRquest, vous devez faire
L'autre chose est que je ne suis pas vraiment sûr de la vraie différence, du point de vue du threading, que vous testez réellement. Si vous creusez dans les profondeurs de HttpClientHandler, il fait simplement Task.Factory.StartNew afin d'exécuter une requête 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 par défaut, il utilise HttpWebRequest comme bibliothèque de transport. Ainsi, vous pourrez toujours obtenir de meilleures performances avec un HttpWebRequest directement tout en utilisant HttpClientHandler. Les avantages qu'apporte HttpClient sont avec les classes standard comme HttpResponseMessage, HttpRequestMessage, HttpContent et tous les en-têtes fortement typés. En soi, ce n'est pas une optimisation des performances.
la source
HttpClient
semble facile à utiliser et je pensais que l'asynchrone était la voie à suivre, mais il semble y avoir beaucoup de «mais et si» autour de cela. Peut-être que leHttpClient
devrait être réécrit pour qu'il soit plus intuitif à utiliser? Ou que la documentation mettait vraiment l'accent sur les éléments importants sur la façon de l'utiliser le plus efficacement possible?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 HttpClients basés sur une instance. La différence est ÉNORME! En fonction de la charge, vous verrez des chiffres de performance très différents. Le HttpClient a été conçu pour être réutilisé entre les demandes. Cela a été confirmé par des membres de l'équipe de la BCL qui l'ont écrit.
Un projet récent que j'ai eu était d'aider un très grand détaillant informatique en ligne bien connu à évoluer 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. Depuis sa mise en œuvre
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'uneusing()
instruction. Une fois que nous avons commencé les tests de charge, l'application a mis le serveur à genoux - oui, le serveur, pas seulement l'application. La raison 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 de GC et du fait que vous travaillez avec des ressources informatiques qui s'étendent sur plusieurs couches OSI , la fermeture des ports réseau peut prendre un certain temps. En fait le système d'exploitation Windows lui-mêmepeut prendre jusqu'à 20 secondes pour fermer un port (selon Microsoft). Nous ouvrions les ports plus rapidement qu'ils ne pouvaient être fermés - l'épuisement des ports du serveur qui a martelé le processeur à 100%. Ma solution consistait à changer le HttpClient en une instance statique qui a résolu le problème. Oui, c'est une ressource jetable, mais les frais généraux sont largement compensés par la différence de performances. Je vous encourage à faire des tests de charge pour voir comment votre application se comporte.Également répondu au lien ci-dessous:
Quelle est la surcharge 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
la source