Async HttpClient de .Net 4.5 est-il un mauvais choix pour les applications de charge intensive?

130

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é.

Florin Dumitrescu
la source
4
HttpClientdevrait respecter ServicePointManager.DefaultConnectionLimit.
Stephen Cleary
2
Vos observations semblent mériter d'être étudiées. Cependant, une chose me dérange: je pense qu'il est hautement artificiel d'émettre des milliers d'E / S asynchrones à la fois. Je ne ferais jamais ça en production. Le fait que vous soyez asynchrone ne signifie pas que vous pouvez devenir fou en consommant diverses ressources. (Les échantillons officiels de Microsofts sont également un peu trompeurs à cet égard.)
usr
1
Ne ralentissez pas avec les retards, cependant. Limitez-vous à un niveau de concurrence fixe que vous déterminez empiriquement. Une solution simple serait SemaphoreSlim.WaitAsync bien que cela ne convienne pas non plus pour des quantités arbitrairement grandes de tâches.
usr
1
@FlorinDumitrescu Pour la limitation, vous pouvez utiliser SemaphoreSlim, comme déjà mentionné, ou à ActionBlock<T>partir de TPL Dataflow.
svick
1
@svick, merci pour vos suggestions. Je ne suis pas intéressé par la mise en œuvre manuelle d'un mécanisme de limitation / limitation de la concurrence. Comme mentionné, la mise en œuvre incluse dans ma question était uniquement destinée à tester et à valider une théorie. Je n'essaie pas de l'améliorer, car il ne sera pas mis en production. Ce qui m'intéresse, c'est si le framework .Net offre un mécanisme intégré pour limiter la concurrence des opérations d'E / S asynchrones (HttpClient inclus).
Florin Dumitrescu

Réponses:

64

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 .

Florin Dumitrescu
la source
3
Quelle est la vitesse "très rapide"? 1ms? 100ms? 1 000 ms?
Tim P.
J'utilise quelque chose comme votre approche "asynchrone" pour rejouer une charge sur un serveur Web WebLogic déployé sur Windows, mais j'obtiens un problème d'épuisement de port éphémère, assez rapidement. Je n'ai pas touché ServicePointManager.DefaultConnectionLimit, et je supprime et recrée tout (HttpClient et réponse) à chaque demande. Avez-vous une idée de ce qui peut faire en sorte que les connexions restent ouvertes et épuisent les ports?
Iravanchi
@TimP. pour mes tests, comme mentionné ci-dessus, «très rapides» étaient les requêtes qui ne prenaient qu'une milliseconde à compléter. Dans le monde réel, ce sera toujours subjectif. De mon point de vue, quelque chose d'équivalent à une petite requête sur une base de données de réseau local peut être considéré comme rapide, tandis que quelque chose d'équivalent à un appel d'API sur Internet peut être considéré comme lent ou potentiellement lent.
Florin Dumitrescu
1
@Iravanchi, dans les approches "asynchrones", l'envoi des requêtes et le traitement des réponses sont effectués séparément. Si vous avez beaucoup d'appels, toutes les demandes seront envoyées très rapidement et les réponses seront traitées à leur arrivée. Étant donné que vous ne pouvez supprimer les connexions qu'après l'arrivée de leurs réponses, un grand nombre de connexions simultanées peuvent accumuler et épuiser vos ports éphémères. Vous devez limiter le nombre maximal de connexions simultanées à l'aide de ServicePointManager.DefaultConnectionLimit.
Florin Dumitrescu
1
@FlorinDumitrescu, j'ajouterais également que les appels réseau sont par nature imprévisibles. Les choses qui s'exécutent dans 10 ms 90% du temps peuvent provoquer des problèmes de blocage lorsque cette ressource réseau est encombrée ou indisponible les 10% restants du temps.
Tim P.
27

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

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 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.

Darrel Miller
la source
(vieille réponse, mais) HttpClientsemble 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 le HttpClientdevrait ê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?
mortb
@mortb, Flurl.Http flurl.io est un wrapper plus intuitif à utiliser de HttpClient
Michael Freidgeim
1
@MichaelFreidgeim: Merci, même si j'ai appris à vivre avec HttpClient maintenant ...
mortb
17

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'une using()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

Dave Black
la source
J'ai trouvé exactement le même problème en créant l'épuisement du port TCP sur le client. La solution était de louer l'instance HttpClient pendant de longues périodes pendant lesquelles des appels itératifs étaient effectués, et non de créer et de supprimer pour chaque appel. La conclusion à laquelle je suis parvenu était "Juste parce qu'il implémente Dispose, cela ne signifie pas qu'il est bon marché de le Dispose".
PhillipH
donc si le HttpClient est statique et que je dois changer un en-tête à la prochaine demande, qu'est-ce que cela fait à la première demande? Y a-t-il un mal à changer le HttpClient car il est statique - comme l'émission d'un HttpClient.DefaultRequestHeaders.Accept.Clear (); ? Par exemple, si j'ai des utilisateurs qui s'authentifient via des jetons, ces jetons doivent être ajoutés en tant qu'en-têtes sur la demande à l'API, dont différents jetons. Le fait d'avoir le HttpClient comme statique, puis de modifier cet en-tête sur HttpClient n'aurait-il pas des effets néfastes?
crizzwald
Si vous avez besoin d'utiliser des membres d'instance HttpClient tels que des en-têtes / cookies, etc., vous ne devez pas utiliser de HttpClient statique. Sinon, vos données d'instance (en-têtes, cookies) seraient les mêmes pour chaque requête - certainement PAS ce que vous voulez.
Dave Black
puisque c'est le cas ... comment éviteriez-vous ce que vous décrivez ci-dessus dans votre message - contre la charge? équilibreur de charge et lancez-vous plus de serveurs?
crizzwald
@crizzwald - Dans mon message, j'ai noté la solution utilisée. Utilisez une instance statique de HttpClient. Si vous avez besoin d'utiliser un en-tête / des cookies sur un HttpClient, je chercherais à utiliser une alternative.
Dave Black