¿Async HttpClient de .Net 4.5 es una mala elección para aplicaciones de carga intensiva?

Recientemente, creé una aplicación simple para probar el rendimiento de la llamada HTTP que se puede generar de manera asíncrona frente a un enfoque clásico de multiproceso.

La aplicación puede realizar un número predefinido de llamadas HTTP y al final muestra el tiempo total necesario para realizarlas. Durante mis pruebas, todas las llamadas HTTP se realizaron en mi servidor IIS local y recuperaron un pequeño archivo de texto (12 bytes de tamaño).

La parte más importante del código para la implementación asincrónica se enumera a continuación:

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 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 parte más importante de la implementación de subprocesos múltiples se enumera a continuación:

 public void TestParallel2() { this.TestInit(); ServicePointManager.DefaultConnectionLimit = 100; for (int i = 0; i  { 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(); } } 

La ejecución de las pruebas reveló que la versión multiproceso era más rápida. Se tardó alrededor de 0,6 segundos para completar las solicitudes de 10k, mientras que el asincrónico tardó alrededor de 2 segundos en completarse para la misma cantidad de carga. Esto fue un poco sorprendente, porque esperaba que el asincrónico fuera más rápido. Tal vez fue por el hecho de que mis llamadas HTTP fueron muy rápidas. En un escenario del mundo real, donde el servidor debería realizar una operación más significativa y donde también debería haber alguna latencia de red, los resultados podrían revertirse.

Sin embargo, lo que realmente me preocupa es la forma en que HttpClient se comporta cuando aumenta la carga. Dado que tarda unos 2 segundos en entregar 10k mensajes, pensé que tomaría alrededor de 20 segundos entregar 10 veces la cantidad de mensajes, pero ejecutar la prueba mostró que se necesitan alrededor de 50 segundos para entregar los 100k mensajes. Además, generalmente lleva más de 2 minutos entregar 200k mensajes y, a menudo, algunos miles (3-4k) fallan con la siguiente excepción:

No se pudo realizar una operación en un socket porque el sistema no tenía suficiente espacio en el búfer o porque una cola estaba llena.

Revisé los registros de IIS y las operaciones que fallaron nunca llegaron al servidor. Fracasaron dentro del cliente. Ejecuté las pruebas en una máquina con Windows 7 con el rango predeterminado de puertos efímeros de 49152 a 65535. La ejecución de netstat mostró que alrededor de 5-6k puertos se usaban durante las pruebas, por lo que en teoría debería haber muchos más disponibles. Si la falta de puertos fue realmente la causa de las excepciones, significa que netstat no informó correctamente la situación o HttClient solo usa un número máximo de puertos después de lo cual comienza a lanzar excepciones.

Por el contrario, el enfoque multithread de generar llamadas HTTP se comportó de manera muy predecible. Lo tomé alrededor de 0.6 segundos para 10k mensajes, alrededor de 5.5 segundos para 100k mensajes y como se esperaba alrededor de 55 segundos para 1 millón de mensajes. Ninguno de los mensajes falló. Además, mientras se ejecutaba, nunca usaba más de 55 MB de RAM (según el Administrador de tareas de Windows). La memoria utilizada al enviar mensajes de forma asincrónica creció proporcionalmente con la carga. Usó alrededor de 500 MB de RAM durante las pruebas de mensajes de 200k.

Creo que hay dos razones principales para los resultados anteriores. El primero es que HttpClient parece ser muy codicioso en la creación de nuevas conexiones con el servidor. La gran cantidad de puertos usados ​​informados por netstat significa que probablemente no se beneficie demasiado con HTTP keep-alive.

El segundo es que HttpClient no parece tener un mecanismo de aceleración. De hecho, esto parece ser un problema general relacionado con las operaciones de sincronización. Si necesita realizar una gran cantidad de operaciones, todas se iniciarán a la vez y luego sus ejecuciones se ejecutarán a medida que estén disponibles. En teoría, esto debería estar bien, porque en las operaciones de sincronización, la carga está en sistemas externos, pero como se demostró anteriormente, esto no es totalmente cierto. Tener una gran cantidad de solicitudes iniciadas a la vez boostá el uso de memoria y ralentizará toda la ejecución.

Logré obtener mejores resultados, memoria y tiempo de ejecución, al limitar el número máximo de solicitudes asincrónicas con un mecanismo de retardo simple pero primitivo:

 public async void TestAsyncWithDelay() { this.TestInit(); HttpClient httpClient = new HttpClient(); for (int i = 0; i = MAX_CONCURENT_REQUESTS) await Task.Delay(DELAY_TIME); ProcessUrlAsyncWithReqCount(httpClient); } } 

Sería realmente útil si HttpClient incluyera un mecanismo para limitar el número de solicitudes simultáneas. Al usar la clase Task (que se basa en el grupo de subprocesos de .NET), la limitación se logra automáticamente al limitar el número de subprocesos concurrentes.

Para obtener una descripción general completa, también he creado una versión de la prueba asíncrona basada en HttpWebRequest en lugar de HttpClient y obtuve resultados mucho mejores. Para empezar, permite establecer un límite en el número de conexiones simultáneas (con ServicePointManager.DefaultConnectionLimit o vía config), lo que significa que nunca se quedó sin puertos y nunca falló en ninguna solicitud (HttpClient, de manera predeterminada, está basado en HttpWebRequest , pero parece ignorar la configuración del límite de conexión).

El enfoque async HttpWebRequest todavía era aproximadamente 50 – 60% más lento que el de subprocesamiento múltiple, pero era predecible y confiable. El único inconveniente era que usaba una gran cantidad de memoria bajo una gran carga. Por ejemplo, necesitaba alrededor de 1,6 GB para enviar 1 millón de solicitudes. Al limitar el número de solicitudes concurrentes (como lo hice anteriormente para HttpClient) logré reducir la memoria utilizada a solo 20 MB y obtener un tiempo de ejecución solo un 10% más lento que el enfoque de subprocesamiento múltiple.

Después de esta larga presentación, mis preguntas son: ¿Es la clase HttpClient de .Net 4.5 una mala opción para aplicaciones de carga intensiva? ¿Hay alguna forma de estrangularlo, que debería solucionar los problemas que menciono? ¿Qué tal el sabor asíncrono de HttpWebRequest?

Actualización (gracias @Stephen Cleary)

Como resultado, HttpClient, al igual que HttpWebRequest (en el que se basa de manera predeterminada), puede tener su número de conexiones simultáneas en el mismo host limitado con ServicePointManager.DefaultConnectionLimit. Lo extraño es que, según MSDN , el valor predeterminado para el límite de conexión es 2. También comprobé eso por mi parte utilizando el depurador que señalaba que de hecho 2 es el valor predeterminado. Sin embargo, parece que, a menos que se establezca explícitamente un valor para ServicePointManager.DefaultConnectionLimit, se ignorará el valor predeterminado. Como no establecí explícitamente un valor para él durante mis pruebas de HttpClient, pensé que se había ignorado.

Después de establecer ServicePointManager.DefaultConnectionLimit en 100, HttpClient se volvió confiable y predecible (netstat confirma que solo se usan 100 puertos). Todavía es más lento que async HttpWebRequest (en aproximadamente 40%), pero extrañamente, usa menos memoria. Para la prueba que implica 1 millón de solicitudes, se utilizó un máximo de 550 MB, en comparación con 1,6 GB en la async HttpWebRequest.

Entonces, aunque HttpClient en combinación ServicePointManager.DefaultConnectionLimit parece garantizar la confiabilidad (al menos para el escenario donde todas las llamadas se realizan hacia el mismo host), aún parece que su rendimiento se ve negativamente afectado por la falta de un mecanismo de aceleración adecuado. Algo que limitaría el número concurrente de solicitudes a un valor configurable y pondría el rest en una cola lo haría mucho más adecuado para escenarios de alta escalabilidad.

    Además de las pruebas mencionadas en la pregunta, recientemente creé algunas nuevas que implican muchas menos llamadas HTTP (5000 en comparación con 1 millón anterior) pero en solicitudes que tardaron mucho más en ejecutarse (500 milisegundos en comparación con alrededor de 1 milisegundo anterior). Ambas aplicaciones de prueba, la multiplexada sincrónicamente (basada en HttpWebRequest) y la E / S asincrónica uno (basada en el cliente HTTP) produjeron resultados similares: alrededor de 10 segundos para ejecutar usando alrededor del 3% de la CPU y 30 MB de memoria. La única diferencia entre los dos probadores era que el multiproceso usaba 310 hilos para ejecutarse, mientras que el asíncrono solo 22. Por lo tanto, en una aplicación que combinaría operaciones de E / S vinculadas y CPU, la versión asíncrona hubiera producido mejores resultados porque habría habido más tiempo de CPU disponible para los subprocesos que realizan operaciones de CPU, que son los que realmente lo necesitan (los subprocesos que esperan que las operaciones de E / S se completen solo están desperdiciando).

    Como conclusión de mis pruebas, las llamadas HTTP asincrónicas no son la mejor opción cuando se trata de solicitudes muy rápidas. La razón detrás de esto es que cuando se ejecuta una tarea que contiene una llamada de E / S asincrónica, el hilo en el que se inicia la tarea se cierra tan pronto como se realiza la llamada asincrónica y el rest de la tarea se registra como una callback. Luego, cuando la operación de E / S se completa, la callback se pone en cola para su ejecución en la primera cadena disponible. Todo esto crea una sobrecarga, lo que hace que las operaciones de E / S rápidas sean más eficientes cuando se ejecutan en la secuencia que las inició.

    Las llamadas HTTP asincrónicas son una buena opción cuando se trata de operaciones de E / S largas o potencialmente largas porque no mantiene ningún subproceso ocupado esperando que se completen las operaciones de E / S. Esto disminuye la cantidad total de subprocesos utilizados por una aplicación, lo que permite que las operaciones enlazadas a la CPU gasten más tiempo de CPU. Además, en las aplicaciones que solo asignan un número limitado de subprocesos (como en el caso de las aplicaciones web), las E / S asíncronas evitan el agotamiento de subprocesos del grupo de subprocesos, lo que puede ocurrir si se realizan llamadas de E / S de forma síncrona.

    Entonces, async HttpClient no es un cuello de botella para aplicaciones de carga intensiva. Es solo que, por su naturaleza, no es muy adecuado para solicitudes HTTP muy rápidas, en cambio es ideal para aplicaciones largas o potencialmente largas, especialmente dentro de aplicaciones que solo tienen un número limitado de subprocesos disponibles. Además, es una buena práctica limitar la concurrencia a través de ServicePointManager.DefaultConnectionLimit con un valor lo suficientemente alto como para garantizar un buen nivel de paralelismo, pero lo suficientemente bajo como para evitar el agotamiento del puerto efímero. Puede encontrar más detalles sobre las pruebas y conclusiones presentadas para esta pregunta aquí .

    Una cosa a considerar que podría estar afectando sus resultados es que con HttpWebRequest no obtiene el ResponseStream y consume esa secuencia. Con HttpClient, de manera predeterminada copiará la secuencia de la red en una secuencia de memoria. Para usar HttpClient de la misma manera que está utilizando actualmente HttpWebRquest, tendría que hacer

     var requestMessage = new HttpRequestMessage() {RequestUri = URL}; Task getTask = httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead); 

    La otra cosa es que no estoy realmente seguro de cuál es la verdadera diferencia, desde una perspectiva de enhebrado, que en realidad estás probando. Si exploras las profundidades de HttpClientHandler, simplemente hace Task.Factory.StartNew para realizar una solicitud asíncrona. El comportamiento de subprocesamiento se delega en el contexto de sincronización exactamente de la misma forma que se realiza el ejemplo con el ejemplo HttpWebRequest.

    Sin lugar a dudas, HttpClient agrega algo de sobrecarga, ya que de manera predeterminada usa HttpWebRequest como su biblioteca de transporte. Por lo tanto, siempre podrá obtener mejores resultados con HttpWebRequest directamente mientras usa HttpClientHandler. Los beneficios que aporta HttpClient son las clases estándar como HttpResponseMessage, HttpRequestMessage, HttpContent y todos los encabezados fuertemente tipados. En sí mismo no es una optimización de perf.

    Si bien esto no responde directamente a la parte ‘asincrónica’ de la pregunta del OP, esto soluciona un error en la implementación que está utilizando.

    Si desea que su aplicación se escale, evite el uso de HttpClients basados ​​en instancias. ¡La diferencia es GRANDE! Dependiendo de la carga, verá números de rendimiento muy diferentes. El HttpClient fue diseñado para ser reutilizado en todas las solicitudes. Esto fue confirmado por los chicos del equipo de BCL que lo escribieron.

    Un proyecto reciente que tuve fue para ayudar a un minorista de computadoras en línea muy grande y conocido a escalar el tráfico del Viernes Negro / vacaciones para algunos sistemas nuevos. Nos encontramos con algunos problemas de rendimiento relacionados con el uso de HttpClient. Como implementa IDisposable , los desarrolladores hicieron lo que normalmente harían al crear una instancia y colocarla dentro de una sentencia using() . Una vez que comenzamos con las pruebas de carga, la aplicación puso al servidor de rodillas, sí, el servidor no solo la aplicación. El motivo es que cada instancia de HttpClient abre un puerto de finalización de E / S en el servidor. Debido a la finalización no determinista de GC y al hecho de que está trabajando con recursos informáticos que abarcan varias capas de OSI , el cierre de los puertos de red puede llevar un tiempo. De hecho, el sistema operativo Windows puede demorar hasta 20 segundos para cerrar un puerto (por Microsoft). Estábamos abriendo puertos más rápido de lo que podrían estar cerrados: agotamiento del puerto del servidor que golpeó la CPU al 100%. Mi solución fue cambiar el HttpClient a una instancia estática que resolvió el problema. Sí, es un recurso desechable, pero cualquier sobrecarga está ampliamente compensada por la diferencia en el rendimiento. Te animo a que hagas algunas pruebas de carga para ver cómo se comporta tu aplicación.

    También respondió en el siguiente enlace:

    ¿Cuál es la sobrecarga de crear un nuevo HttpClient por llamada en un cliente WebAPI?

    https://www.asp.net/web-api/overview/advanced/calling-a-web-api-f-a-net-client