Errores de HttpClient en .NET

Continuando con una serie de artículos sobre "trampas", no puedo ignorar System.Net.HttpClient, que se usa muy a menudo en la práctica, pero tiene varios problemas graves que pueden no ser visibles de inmediato.

Un problema bastante común en la programación es que los desarrolladores se centran solo en la funcionalidad de un componente en particular, mientras ignoran por completo un componente no funcional muy importante, que puede afectar el rendimiento, la escalabilidad, la facilidad de recuperación en caso de fallas, seguridad, etc. Por ejemplo, el mismo HttpClient es aparentemente un componente elemental, pero hay varias preguntas: cuánto crea conexiones paralelas al servidor, cuánto tiempo viven, cómo se comportará si el nombre DNS al que se accedió anteriormente se cambia a una dirección IP diferente ? Intentemos responder estas preguntas en el artículo.

  1. Fuga de conexión
  2. Limite las conexiones concurrentes del servidor
  3. Conexiones duraderas y almacenamiento en caché de DNS

El primer problema con HttpClient es la fuga de conexión no obvia. Muy a menudo, tenía que encontrar el código donde se crea para ejecutar cada solicitud:

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

Desafortunadamente, este enfoque conduce a un gran desperdicio de recursos y una alta probabilidad de obtener un desbordamiento de la lista de conexiones abiertas. Para mostrar claramente el problema, es suficiente ejecutar el siguiente código:

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

Y al final, mira la lista de conexiones abiertas a través de netstat:

 PS C: \ Desarrollo \ Ejercicios> 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

Aquí, el modificador -n se usa para acelerar la salida, ya que de lo contrario netstat para cada IP buscará el nombre de dominio, y 178.248.237.68 buscará la dirección IP de habr.com al momento de escribir esto.

En total, vemos que a pesar del uso de la construcción, y aunque el programa se completó por completo, las conexiones al servidor permanecieron "colgadas". Y se colgarán durante el tiempo indicado en la clave de registro HKEY_LOCAL_MACHINE \ SYSTEM \ CurrentControlSet \ Services \ Tcpip \ Parameters \ TcpTimedWaitDelay.

Puede surgir una pregunta de inmediato: ¿cómo se comporta .NET Core en tales casos? Lo que está en Windows, lo que está en Linux, exactamente lo mismo, porque dicha retención de conexión se produce a nivel del sistema y no a nivel de la aplicación. El estado TIME_WAIT es un estado especial del socket después de que la aplicación lo cierra, y esto es necesario para procesar paquetes que aún pueden pasar por la red. Para Linux, la duración de este estado se especifica en segundos en / proc / sys / net / ipv4 / tcp_fin_timeout y, por supuesto, se puede cambiar si es necesario.

El segundo problema de HttpClient es el límite no obvio de conexiones concurrentes al servidor . Suponga que usa el .NET Framework 4.7 familiar, con la ayuda del cual desarrolla un servicio altamente cargado, donde hay llamadas a otros servicios a través de HTTP. Se ha solucionado el problema potencial con la pérdida de conexión, por lo que se utiliza la misma instancia de HttpClient para todas las solicitudes. ¿Qué podría estar mal?

El problema se puede ver fácilmente ejecutando el siguiente código:

 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}"); } 

El recurso especificado en el enlace le permite retrasar la respuesta del servidor durante el tiempo especificado, en este caso, 5 segundos.

Como es fácil notar después de ejecutar el código anterior, cada 5 segundos solo llegan 2 respuestas, aunque se crearon 10 solicitudes simultáneas. Esto se debe al hecho de que la interacción con HTTP en un marco .NET regular, entre otras cosas, pasa por una clase especial System.Net.ServicePointManager que controla varios aspectos de las conexiones HTTP. Esta clase tiene una propiedad DefaultConnectionLimit que indica cuántas conexiones concurrentes se pueden crear para cada dominio. Y así, históricamente, el valor predeterminado de una propiedad es 2.

Si agrega el ejemplo de código anterior al principio

 ServicePointManager.DefaultConnectionLimit = 5; 

entonces la ejecución del ejemplo se acelerará notablemente, ya que las solicitudes se ejecutarán en lotes de 5.

Y antes de pasar a cómo funciona esto en .NET Core, se debe decir un poco más sobre ServicePointManager. La propiedad descrita anteriormente indica el número predeterminado de conexiones que se utilizarán para conexiones posteriores a cualquier dominio. Pero junto con esto, es posible controlar los parámetros para cada nombre de dominio individualmente y esto se hace a través de la clase 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; 

Después de ejecutar este código, cualquier interacción con Habr a través de la misma instancia de HttpClient utilizará 5 conexiones simultáneas y 3 conexiones con el sitio "lento".

Aquí hay otro matiz interesante: el límite en el número de conexiones para direcciones locales (localhost) es int.MaxValue de forma predeterminada. Solo mire los resultados de ejecutar este código sin configurar primero el 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); 

Ahora pasemos a .NET Core. Aunque ServicePointManager todavía existe en el espacio de nombres System.Net, no afecta el comportamiento de HttpClient en .NET Core. En cambio, los parámetros de conexión HTTP se pueden controlar utilizando HttpClientHandler (o SocketsHttpHandler, del que hablaremos más adelante):

 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}"); } 

El ejemplo anterior se comportará exactamente igual que el ejemplo inicial para .NET Framework normal: para establecer solo 2 conexiones a la vez. Pero si elimina la línea con el conjunto de propiedades MaxConnectionsPerServer, el número de conexiones simultáneas será mucho mayor, ya que de forma predeterminada en .NET Core el valor de esta propiedad es int.MaxValue.

Y ahora veamos el tercer problema no obvio con la configuración predeterminada, que puede ser no menos crítico que los dos anteriores: conexiones de larga duración y almacenamiento en caché de DNS . Cuando se establece una conexión con un servidor remoto, el nombre de dominio se resuelve primero en la dirección IP correspondiente, luego la dirección recibida se guarda en la memoria caché durante un tiempo para acelerar las conexiones posteriores. Además, para ahorrar recursos, a menudo la conexión no se cierra después de cada solicitud, sino que se mantiene abierta durante mucho tiempo.

Imagine que el sistema que estamos desarrollando debería funcionar normalmente sin forzar un reinicio si el servidor con el que interactúa ha cambiado a una dirección IP diferente. Por ejemplo, si cambia a otro centro de datos debido a una falla en el actual. Incluso si se interrumpe una conexión permanente debido a una falla en el primer centro de datos (que también puede suceder rápidamente), la caché DNS no permitirá que nuestro sistema responda rápidamente a dicho cambio. Lo mismo es cierto para las llamadas a la dirección donde se realiza el equilibrio de carga a través de DNS round-robin.

En el caso de un marco .NET "normal", este comportamiento se puede controlar a través de ServicePointManager y ServicePoint (todos los parámetros enumerados a continuación toman valores en milisegundos):

  • ServicePointManager.DnsRefreshTimeout: indica cuánto tiempo se almacenará en caché la dirección IP recibida para cada nombre de dominio; el valor predeterminado es 2 minutos (120000).
  • ServicePoint.ConnectionLeaseTimeout: indica durante cuánto tiempo se puede mantener abierta la conexión. Por defecto, no hay límite de tiempo para las conexiones; cualquier conexión puede mantenerse durante un tiempo arbitrariamente largo, ya que este parámetro es -1. Establecerlo en 0 hará que cada conexión se cierre inmediatamente después de que se complete la solicitud.
  • ServicePoint.MaxIdleTime: indica después de qué tiempo de inactividad se cerrará la conexión. Inacción significa que no hay transferencia de datos a través de la conexión. Por defecto, el valor de este parámetro es de 100 segundos (100000).

Ahora, para mejorar la comprensión de estos parámetros, los combinaremos todos en un solo escenario. Suponga que nadie cambió DnsRefreshTimeout y MaxIdleTime y son 120 y 100 segundos respectivamente. Con esto, ConnectionLeaseTimeout se estableció en 60 segundos. La aplicación establece solo una conexión, a través de la cual envía solicitudes cada 10 segundos.

Con esta configuración, la conexión se cerrará cada 60 segundos (ConnectionLeaseTimeout), aunque transfiera datos periódicamente. El cierre y la recreación se realizarán de tal manera que no interfieran con la ejecución correcta de las solicitudes; si se agota el tiempo y en el momento en que la solicitud aún se está ejecutando, la conexión se cerrará una vez que se complete la solicitud. Cada vez que se recrea una conexión, la dirección IP correspondiente se tomará primero del caché, y solo si su resolución ha expirado (120 segundos), el sistema enviará una solicitud al servidor DNS.

El parámetro MaxIdleTime no jugará un papel en este escenario, ya que la conexión no ha estado inactiva durante más de 10 segundos.

La relación óptima de estos parámetros depende en gran medida de la situación específica y los requisitos no funcionales:

  • Si no tiene la intención de cambiar de forma transparente las direcciones IP detrás del nombre de dominio al que accede su aplicación, y al mismo tiempo necesita minimizar el costo de las conexiones de red, entonces la configuración predeterminada parece una buena opción.
  • Si es necesario cambiar entre direcciones IP en caso de fallas, puede establecer DnsRefreshTimeout en 0 y ConnectionLeaseTimeout en el valor no negativo que más le convenga. Cuál depende específicamente de qué tan rápido necesita cambiar a otra IP. Obviamente, desea tener la respuesta más rápida posible a la falla, pero aquí necesita encontrar el valor óptimo, que, por un lado, proporciona un tiempo de conmutación aceptable, por otro lado, no degrada el rendimiento del sistema y el tiempo de respuesta por una reconexión demasiado frecuente.
  • Si necesita la reacción más rápida posible para cambiar la dirección IP, por ejemplo, como en el caso del equilibrio a través de DNS round-robin, puede intentar establecer DnsRefreshTimeout y ConnectionLeaseTimeout en 0, pero esto será extremadamente derrochador: para cada solicitud, el servidor DNS se sondeará primero, después de lo cual La conexión al nodo de destino se restablecerá.
  • Puede haber situaciones en las que establecer ConnectionLeaseTimeout en 0 con un DnsRefreshTimeout distinto de cero puede ser útil, pero no puedo encontrar una secuencia de comandos adecuada de inmediato. Lógicamente, esto significará que para cada solicitud, las conexiones se crearán de nuevo, pero las direcciones IP se tomarán de la memoria caché siempre que sea posible.

El siguiente es un ejemplo de código que puede usarse para observar el comportamiento de los parámetros descritos anteriormente:
 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); } 

Mientras se ejecuta el programa de prueba, puede ejecutar netstat a través de PowerShell en un bucle para monitorear las conexiones que establece.

Inmediatamente se debe decir cómo administrar los parámetros descritos en .NET Core. La configuración de ServicePointManager, como en el caso de ConnectionLimit, no funcionará. Core tiene un tipo especial de controlador HTTP que implementa dos de los tres parámetros descritos anteriormente: SocketsHttpHandler:

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

No hay ningún parámetro que controle el tiempo de almacenamiento en caché de los registros DNS en .NET Core. Los casos de prueba muestran que el almacenamiento en caché no funciona: cuando se crea una nueva conexión DNS, la resolución se realiza nuevamente, por lo que para el funcionamiento normal en condiciones en que el nombre de dominio solicitado puede cambiar entre diferentes direcciones IP, simplemente configure PooledConnectionLifetime en el valor deseado.

Además de todo, hay que decir que los desarrolladores de Microsoft no pudieron pasar desapercibidos todos estos problemas y, por lo tanto, a partir de .NET Core 2.1, apareció una fábrica de clientes HTTP que permite resolver algunos de ellos: https://docs.microsoft.com/en- us / dotnet / standard / microservices-architecture / implement-resilient-applications / use-httpclientfactory-to-implement-resilient-http-request . Además, además de administrar la vida útil de las conexiones, el nuevo componente brinda oportunidades para crear clientes mecanografiados, así como algunas otras cosas útiles. En este artículo y sus enlaces, hay suficiente información y ejemplos sobre el uso de HttpClientFactory, por lo tanto, no consideraré los detalles asociados con él en este artículo.

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


All Articles