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é.
SemaphoreSlim
, comme déjà mentionné, ou à ActionBlock<T>
partir de TPL Dataflow.
HttpClient
devrait respecterServicePointManager.DefaultConnectionLimit
.