Pièges de HttpClient dans .NET

Poursuivant une série d'articles sur les «pièges», je ne peux pas ignorer System.Net.HttpClient, qui est très souvent utilisé dans la pratique, mais il présente plusieurs problèmes graves qui peuvent ne pas être immédiatement visibles.

Un problème assez courant en programmation est que les développeurs se concentrent uniquement sur la fonctionnalité d'un composant particulier, tout en ignorant complètement un composant non fonctionnel très important, ce qui peut affecter les performances, l'évolutivité, la facilité de récupération en cas de panne, la sécurité, etc. Par exemple, le même HttpClient est apparemment un composant élémentaire, mais il y a plusieurs questions: combien il crée des connexions parallèles au serveur, combien de temps ils vivent, comment il se comportera si le nom DNS qui a été consulté plus tôt est changé pour une adresse IP différente ? Essayons de répondre à ces questions dans l'article.

  1. Fuite de connexion
  2. Limiter les connexions de serveur simultanées
  3. Connexions durables et mise en cache DNS

Le premier problème avec HttpClient est la fuite de connexion non évidente. Très souvent, j'ai dû rencontrer du code où il est créé pour exécuter chaque demande:

public async Task<string> GetSomeText(Guid textId) { using (var client = new HttpClient()) { return await client.GetStringAsync($"http://someservice.com/api/v1/some-text/{textId}"); } } 

Malheureusement, cette approche entraîne un grand gaspillage de ressources et une forte probabilité d'obtenir un débordement de la liste des connexions ouvertes. Afin de montrer clairement le problème, il suffit d'exécuter le code suivant:

 static void Main(string[] args) { for(int i = 0; i < 10; i++) { using (var client = new HttpClient()) { client.GetStringAsync("https://habr.com").Wait(); } } } 

Et à la fin, regardez la liste des connexions ouvertes via netstat:

 PS C: \ Développement \ Exercices> netstat -n |  select-string -pattern "178.248.237.68"

   TCP 192.168.1.13:43684 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13:43685 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13:43686 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13:43687 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13:43689 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13-00-003690 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13-00-003691 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13-00-003692 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13-00-003693 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13-00-003695 178.248.237.68-00-0043 TIME_WAIT

Ici, le commutateur -n est utilisé pour accélérer la sortie, sinon Netstat pour chaque IP recherchera le nom de domaine et 178.248.237.68 recherchera l'adresse IP habr.com au moment d'écrire ces lignes.

Au total, nous voyons que malgré la construction utilisant, et même si le programme était complètement terminé, les connexions au serveur sont restées "suspendues". Et ils se bloqueront aussi longtemps qu'indiqué dans la clé de registre HKEY_LOCAL_MACHINE \ SYSTEM \ CurrentControlSet \ Services \ Tcpip \ Parameters \ TcpTimedWaitDelay.

Une question peut se poser immédiatement - comment se comporte .NET Core dans de tels cas? Ce qui est sous Windows, ce qui est sous Linux - exactement la même chose, car une telle rétention de connexion se produit au niveau du système, et non au niveau de l'application. Le statut TIME_WAIT est un état spécial du socket après sa fermeture par l'application, et cela est nécessaire pour traiter les paquets qui peuvent encore transiter par le réseau. Pour Linux, la durée de cet état est spécifiée en secondes dans / proc / sys / net / ipv4 / tcp_fin_timeout, et bien sûr, elle peut être modifiée si nécessaire.

Le deuxième problème de HttpClient est la limite non évidente des connexions simultanées au serveur . Supposons que vous utilisez le .NET Framework 4.7 familier, à l'aide duquel vous développez un service très chargé, où il y a des appels à d'autres services via HTTP. Le problème potentiel de fuite de connexion a été résolu, la même instance HttpClient est donc utilisée pour toutes les demandes. Qu'est-ce qui ne va pas?

Le problème peut être facilement vu en exécutant le code suivant:

 static void Main(string[] args) { var client = new HttpClient(); var tasks = new List<Task>(); for (var i = 0; i < 10; i++) { tasks.Add(SendRequest(client, "http://slowwly.robertomurray.co.uk/delay/5000/url/https://habr.com")); } Task.WaitAll(tasks.ToArray()); } private static async Task SendRequest(HttpClient client, string url) { var response = await client.GetAsync(url); Console.WriteLine($"Received response {response.StatusCode} from {url}"); } 

La ressource spécifiée dans le lien vous permet de retarder la réponse du serveur pendant la durée spécifiée, dans ce cas - 5 secondes.

Comme il est facile de le remarquer après avoir exécuté le code ci-dessus - toutes les 5 secondes seulement 2 réponses arrivent, bien que 10 demandes simultanées aient été créées. Cela est dû au fait que l'interaction avec HTTP dans un cadre .NET normal, entre autres, passe par une classe spéciale System.Net.ServicePointManager qui contrôle divers aspects des connexions HTTP. Cette classe possède une propriété DefaultConnectionLimit qui indique le nombre de connexions simultanées pouvant être créées pour chaque domaine. Et donc historiquement, la valeur par défaut d'une propriété est 2.

Si vous ajoutez l'exemple de code ci-dessus au tout début

 ServicePointManager.DefaultConnectionLimit = 5; 

l'exécution de l'exemple s'accélérera alors sensiblement, puisque les requêtes seront exécutées par lots de 5.

Et avant de passer à la façon dont cela fonctionne dans .NET Core, il convient d'en dire un peu plus sur ServicePointManager. La propriété décrite ci-dessus indique le nombre de connexions par défaut qui seront utilisées pour les connexions ultérieures à n'importe quel domaine. Mais en même temps, il est possible de contrôler les paramètres de chaque nom de domaine individuellement et cela se fait via la classe ServicePoint:

 var delayServicePoint = ServicePointManager.FindServicePoint(new Uri("http://slowwly.robertomurray.co.uk")); delayServicePoint.ConnectionLimit = 3; var habrServicePoint = ServicePointManager.FindServicePoint(new Uri("https://habr.com")); habrServicePoint.ConnectionLimit = 5; 

Après avoir exécuté ce code, toute interaction avec Habr via la même instance HttpClient utilisera 5 connexions simultanées et 3 connexions avec le site "slowwly".

Il y a une autre nuance intéressante ici - la limite sur le nombre de connexions pour les adresses locales (localhost) est int.MaxValue par défaut. Il suffit de regarder les résultats de l'exécution de ce code sans définir d'abord le DefaultConnectionLimit:

 var habrServicePoint = ServicePointManager.FindServicePoint(new Uri("https://habr.com")); Console.WriteLine(habrServicePoint.ConnectionLimit); var localServicePoint = ServicePointManager.FindServicePoint(new Uri("http://localhost")); Console.WriteLine(localServicePoint.ConnectionLimit); 

Passons maintenant à .NET Core. Bien que ServicePointManager existe toujours dans l'espace de noms System.Net, il n'affecte pas le comportement de HttpClient dans .NET Core. Au lieu de cela, les paramètres de connexion HTTP peuvent être contrôlés à l'aide de HttpClientHandler (ou SocketsHttpHandler, dont nous parlerons plus tard):

 static void Main(string[] args) { var handler = new HttpClientHandler(); handler.MaxConnectionsPerServer = 2; var client = new HttpClient(handler); var tasks = new List<Task>(); for (int i = 0; i < 10; i++) { tasks.Add(SendRequest(client, "http://slowwly.robertomurray.co.uk/delay/5000/url/https://habr.com")); } Task.WaitAll(tasks.ToArray()); Console.ReadLine(); } private static async Task SendRequest(HttpClient client, string url) { var response = await client.GetAsync(url); Console.WriteLine($"Received response {response.StatusCode} from {url}"); } 

L'exemple ci-dessus se comportera exactement de la même manière que l'exemple initial pour le .NET Framework standard - pour établir seulement 2 connexions à la fois. Mais si vous supprimez la ligne avec le jeu de propriétés MaxConnectionsPerServer, le nombre de connexions simultanées sera beaucoup plus élevé, car par défaut dans .NET Core, la valeur de cette propriété est int.MaxValue.

Et maintenant, regardons le troisième problème non évident avec les paramètres par défaut, qui peut être non moins critique que les deux précédents - les connexions à longue durée de vie et la mise en cache DNS . Lors de l'établissement d'une connexion avec un serveur distant, le nom de domaine est d'abord résolu à l'adresse IP correspondante, puis l'adresse reçue est placée dans le cache pendant un certain temps afin d'accélérer les connexions suivantes. De plus, pour économiser des ressources, le plus souvent la connexion n'est pas fermée après chaque requête, mais maintenue ouverte pendant longtemps.

Imaginez que le système que nous développons devrait fonctionner normalement sans forcer un redémarrage si le serveur avec lequel il interagit a changé pour une adresse IP différente. Par exemple, si vous basculez vers un autre centre de données en raison d'une défaillance du centre actuel. Même si une connexion permanente est rompue en raison d'une défaillance du premier centre de données (ce qui peut également se produire rapidement), le cache DNS ne permettra pas à notre système de répondre rapidement à un tel changement. Il en va de même pour les appels à l'adresse où l'équilibrage de charge est effectué via un round robin DNS.

Dans le cas d'un framework .NET «normal», ce comportement peut être contrôlé via ServicePointManager et ServicePoint (tous les paramètres répertoriés ci-dessous prennent des valeurs en millisecondes):

  • ServicePointManager.DnsRefreshTimeout - indique pendant combien de temps l'adresse IP reçue pour chaque nom de domaine sera mise en cache, la valeur par défaut est 2 minutes (120000).
  • ServicePoint.ConnectionLeaseTimeout - Indique pendant combien de temps la connexion peut être maintenue ouverte. Par défaut, il n'y a pas de limite de temps pour les connexions; toute connexion peut être maintenue pendant une durée arbitraire, car ce paramètre est -1. Si vous le définissez sur 0, chaque connexion se fermera immédiatement après la fin de la demande.
  • ServicePoint.MaxIdleTime - Indique après quelle heure d'inactivité la connexion sera fermée. L'inaction signifie aucun transfert de données via la connexion. Par défaut, la valeur de ce paramètre est de 100 secondes (100000).

Maintenant, pour améliorer la compréhension de ces paramètres, nous les combinerons tous dans un scénario. Supposons que personne n'ait modifié DnsRefreshTimeout et MaxIdleTime et qu'ils durent respectivement 120 et 100 secondes. Avec cela, ConnectionLeaseTimeout a été défini sur 60 secondes. L'application établit une seule connexion, via laquelle elle envoie des demandes toutes les 10 secondes.

Avec ces paramètres, la connexion sera fermée toutes les 60 secondes (ConnectionLeaseTimeout), même si elle transfère périodiquement des données. La fermeture et la recréation auront lieu de manière à ne pas interférer avec la bonne exécution des demandes - si le temps est écoulé et au moment où la demande est toujours en cours d'exécution, la connexion sera fermée une fois la demande terminée. Chaque fois qu'une connexion est recréée, l'adresse IP correspondante sera extraite du cache en premier, et seulement si sa résolution a expiré (120 secondes), le système enverra une demande au serveur DNS.

Le paramètre MaxIdleTime ne jouera aucun rôle dans ce scénario, car la connexion n'a pas été inactive pendant plus de 10 secondes.

Le rapport optimal de ces paramètres dépend fortement de la situation spécifique et des exigences non fonctionnelles:

  • Si vous n'avez pas l'intention de changer de manière transparente les adresses IP derrière le nom de domaine auquel votre application accède et que vous devez en même temps réduire le coût des connexions réseau, les paramètres par défaut semblent être une bonne option.
  • S'il est nécessaire de basculer entre les adresses IP en cas d'échec, vous pouvez définir DnsRefreshTimeout sur 0 et ConnectionLeaseTimeout sur la valeur non négative qui vous convient. Lequel dépend spécifiquement de la vitesse à laquelle vous devez passer à une autre IP. Évidemment, vous voulez avoir la réponse la plus rapide possible à l'échec, mais ici vous devez trouver la valeur optimale, qui, d'une part, fournit un temps de commutation acceptable, d'autre part, ne dégrade pas le débit du système et le temps de réponse par une reconnexion trop fréquente.
  • Si vous avez besoin de la réaction la plus rapide possible pour changer l'adresse IP, par exemple, comme dans le cas de l'équilibrage via un round-robin DNS, vous pouvez essayer de définir DnsRefreshTimeout et ConnectionLeaseTimeout sur 0, mais cela sera extrêmement inutile: pour chaque demande, le serveur DNS sera interrogé en premier, après quoi La connexion au nœud cible sera rétablie.
  • Il peut y avoir des situations où la définition de ConnectionLeaseTimeout à 0 avec un DnsRefreshTimeout différent de zéro peut être utile, mais je ne peux pas proposer un script approprié tout de suite. Logiquement, cela signifie que pour chaque demande, des connexions seront à nouveau créées, mais que les adresses IP seront extraites du cache chaque fois que possible.

Voici un exemple de code qui peut être utilisé pour observer le comportement des paramètres décrits ci-dessus:
 var client = new HttpClient(); ServicePointManager.DnsRefreshTimeout = 120000; var habrServicePoint = ServicePointManager.FindServicePoint(new Uri("https://habr.com")); habrServicePoint.MaxIdleTime = 100000; habrServicePoint.ConnectionLeaseTimeout = 60000; while (true) { client.GetAsync("https://habr.com").Wait(); Thread.Sleep(10000); } 

Pendant l'exécution du programme de test, vous pouvez exécuter netstat via PowerShell en boucle pour surveiller les connexions qu'il établit.

Il convient immédiatement de savoir comment gérer les paramètres décrits dans .NET Core. Les paramètres de ServicePointManager, comme dans le cas de ConnectionLimit, ne fonctionneront pas. Core possède un type spécial de gestionnaire HTTP qui implémente deux des trois paramètres décrits ci-dessus - SocketsHttpHandler:

 var handler = new SocketsHttpHandler(); handler.PooledConnectionLifetime = TimeSpan.FromSeconds(60); // ConnectionLeaseTimeout handler.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(100); // MaxIdleTime var client = new HttpClient(handler); 

Aucun paramètre ne contrôle la durée de mise en cache des enregistrements DNS dans .NET Core. Les cas de test montrent que la mise en cache ne fonctionne pas - lors de la création d'une nouvelle connexion DNS, la résolution est à nouveau effectuée, donc pour un fonctionnement normal dans des conditions où le nom de domaine demandé peut basculer entre différentes adresses IP, il suffit de définir PooledConnectionLifetime sur la valeur souhaitée.

En plus de tout, il faut dire que tous ces problèmes ne pouvaient pas être passés inaperçus par les développeurs de Microsoft, et donc, à partir de .NET Core 2.1, une fabrique de clients HTTP est apparue qui permet de résoudre certains d'entre eux - https://docs.microsoft.com/en- us / dotnet / standard / microservices-architecture / implement-resilient-applications / use-httpclientfactory-to-implement-resilient-http-requests . De plus, en plus de gérer la durée de vie des connexions, le nouveau composant offre des opportunités pour créer des clients typés, ainsi que d'autres choses utiles. Dans cet article et ses liens, il y a suffisamment d'informations et d'exemples sur l'utilisation de HttpClientFactory, par conséquent, je ne prendrai pas en compte les détails qui lui sont associés dans cet article.

Source: https://habr.com/ru/post/fr424873/


All Articles