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

130

Recientemente creé una aplicación simple para probar el rendimiento de llamadas HTTP que se puede generar de forma asincrónica frente a un enfoque clásico de subprocesos múltiples.

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 hicieron a 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<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 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 < 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();
    }
}

La ejecución de las pruebas reveló que la versión multiproceso era más rápida. Le tomó alrededor de 0.6 segundos completar las solicitudes de 10k, mientras que el asíncrono tardó alrededor de 2 segundos en completar la misma cantidad de carga. Esto fue un poco sorprendente, porque esperaba que el asíncrono 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 algo de latencia de red, los resultados podrían ser revertidos.

Sin embargo, lo que realmente me preocupa es la forma en que HttpClient se comporta cuando aumenta la carga. Como demora alrededor de 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 necesita alrededor de 50 segundos para entregar los 100k mensajes. Además, generalmente demora más de 2 minutos en entregar 200k mensajes y, a menudo, algunos miles de ellos (3-4k) fallan con la siguiente excepción:

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

Verifiqué 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 se estaban utilizando alrededor de 5-6k puertos 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ó adecuadamente la situación o que HttClient solo usa un número máximo de puertos después de lo cual comienza a lanzar excepciones.

Por el contrario, el enfoque multiproceso de generar llamadas HTTP se comportó de manera muy predecible. Lo tomé alrededor de 0.6 segundos para mensajes de 10k, alrededor de 5.5 segundos para mensajes de 100k 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 asincrónicamente 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. La primera es que HttpClient parece ser muy codicioso al crear nuevas conexiones con el servidor. El alto número de puertos usados ​​reportados por netstat significa que probablemente no se beneficie mucho de 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 asíncronas. Si necesita realizar una gran cantidad de operaciones, todas se iniciarán de una vez y luego se continuarán sus ejecuciones a medida que estén disponibles. En teoría, esto debería estar bien, porque en las operaciones asíncronas la carga está en sistemas externos, pero como se demostró anteriormente, este no es el caso. Tener una gran cantidad de solicitudes iniciadas a la vez aumentará el uso de memoria y ralentizará toda la ejecución.

Logré obtener mejores resultados, memoria y tiempo de ejecución, limitando el número máximo de solicitudes asíncronas con un mecanismo de retraso simple pero primitivo:

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

Sería realmente útil si HttpClient incluyera un mecanismo para limitar el número de solicitudes concurrentes. Cuando se utiliza la clase Task (que se basa en el grupo de subprocesos .Net), la limitación se consigue automáticamente limitando 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 logré obtener resultados mucho mejores. Para empezar, permite establecer un límite en la cantidad de conexiones concurrentes (con ServicePointManager.DefaultConnectionLimit o mediante config), lo que significa que nunca se quedó sin puertos y nunca falló en ninguna solicitud (HttpClient, por defecto, se basa en HttpWebRequest , pero parece ignorar la configuración del límite de conexión).

El enfoque asíncrono HttpWebRequest todavía era aproximadamente un 50 - 60% más lento que el de subprocesamiento múltiple, pero era predecible y confiable. El único inconveniente fue que usó 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 simultáneas (como hice anteriormente para HttpClient), logré reducir la memoria utilizada a solo 20 MB y obtener un tiempo de ejecución 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 elecció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 concurrentes en el mismo host limitado con ServicePointManager.DefaultConnectionLimit. Lo extraño es que, de acuerdo con MSDN , el valor predeterminado para el límite de conexión es 2. También lo comprobé de mi lado usando el depurador, que señaló que, de hecho, 2 es el valor predeterminado. Sin embargo, parece que a menos que se establezca explícitamente un valor en ServicePointManager.DefaultConnectionLimit, se ignorará el valor predeterminado. Como no lo configuré explícitamente durante mis pruebas de HttpClient, pensé que se ignoraba.

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 HttpWebRequest asíncrono (en aproximadamente un 40%), pero extrañamente, usa menos memoria. Para la prueba que involucra 1 millón de solicitudes, usó un máximo de 550 MB, en comparación con 1.6 GB en la HttpWebRequest asíncrona.

Entonces, si bien HttpClient en combinación ServicePointManager.DefaultConnectionLimit parece garantizar la confiabilidad (al menos para el escenario en el que todas las llamadas se realizan hacia el mismo host), todavía parece que su rendimiento se ve afectado negativamente 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 resto en una cola lo haría mucho más adecuado para escenarios de alta escalabilidad.

Florin Dumitrescu
fuente
44
HttpClientDebería respetar ServicePointManager.DefaultConnectionLimit.
Stephen Cleary
2
Sus observaciones parecen valer la pena ser investigadas. Sin embargo, una cosa me molesta: creo que es muy ingenioso emitir miles de E / S asíncronas a la vez. Nunca haría esto en producción. El hecho de que sea asíncrono no significa que pueda volverse loco consumiendo varios recursos. (Las muestras oficiales de Microsofts también son un poco engañosas en ese sentido.)
Usr
1
Sin embargo, no acelere con demoras. Acelere en un nivel de concurrencia fijo que determine empíricamente. Una solución simple sería SemaphoreSlim.WaitAsync aunque eso tampoco sería adecuado para cantidades arbitrariamente grandes de tareas.
usr
1
@FlorinDumitrescu Para la aceleración, puede usar SemaphoreSlim, como ya se mencionó, o ActionBlock<T>desde TPL Dataflow.
svick
1
@svick, gracias por tus sugerencias. No estoy interesado en implementar manualmente un mecanismo de limitación de limitación / concurrencia. Como se mencionó, la implementación incluida en mi pregunta fue solo para probar y validar una teoría. No estoy tratando de mejorarlo, ya que no llegará a producción. Lo que me interesa es si .Net Framework ofrece un mecanismo integrado para limitar la concurrencia de operaciones asíncronas de E / S (HttpClient incluido).
Florin Dumitrescu

Respuestas:

64

Además de las pruebas mencionadas en la pregunta, recientemente creé algunas nuevas que involucraban muchas menos llamadas HTTP (5000 en comparación con 1 millón anteriormente) pero en solicitudes que tardaron mucho más en ejecutarse (500 milisegundos en comparación con alrededor de 1 milisegundo anteriormente). Ambas aplicaciones de prueba, la sincrónica multiproceso (basada en HttpWebRequest) y la E / S asíncrona (basada en el cliente HTTP) produjeron resultados similares: aproximadamente 10 segundos para ejecutar usando alrededor del 3% de la CPU y 30 MB de memoria. La única diferencia entre los dos probadores fue que el multiproceso usó 310 hilos para ejecutar, mientras que el asíncrono solo 22.

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 eso es que cuando se ejecuta una tarea que contiene una llamada de E / S asíncrona, el subproceso en el que se inicia la tarea se cierra tan pronto como se realiza la llamada asincrónica y el resto de la tarea se registra como devolución de llamada. Luego, cuando se completa la operación de E / S, la devolución de llamada se pone en cola para su ejecución en el primer subproceso disponible. Todo esto crea una sobrecarga, que hace que las operaciones de E / S rápidas sean más eficientes cuando se ejecutan en el hilo que las inició.

Las llamadas asíncronas HTTP 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 el número total de subprocesos utilizados por una aplicación, lo que permite que las operaciones vinculadas a la CPU gasten más tiempo de CPU. Además, en las aplicaciones que solo asignan un número limitado de subprocesos (como es el caso de las aplicaciones web), la E / S asincrónica evita el agotamiento del subproceso del grupo de subprocesos, lo que puede suceder si se realizan llamadas de E / S sincrónicamente.

Por lo tanto, 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, sino que es ideal para solicitudes 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 efímero del puerto. Puede encontrar más detalles sobre las pruebas y conclusiones presentadas para esta pregunta aquí .

Florin Dumitrescu
fuente
3
¿Qué tan rápido es "muy rápido"? 1 ms? 100ms? 1,000ms?
Tim P.
Estoy usando algo como su enfoque "asíncrono" para reproducir una carga en un servidor web WebLogic implementado en Windows, pero estoy recibiendo un problema de agotamiento de puertos épico, bastante rápido. No he tocado ServicePointManager.DefaultConnectionLimit, y estoy desechando y volviendo a crear todo (HttpClient y respuesta) en cada solicitud. ¿Tiene alguna idea de lo que puede estar causando que las conexiones permanezcan abiertas y agoten los puertos?
Iravanchi
@TimP. para mis pruebas, como se mencionó anteriormente, "muy rápido" fueron las solicitudes que solo demoraban 1 milisegundo en completarse. En el mundo real, esto siempre será subjetivo. Desde mi punto de vista, algo equivalente a una pequeña consulta en una base de datos de la red local, puede considerarse rápido, mientras que algo equivalente a una llamada API a través de Internet, puede considerarse lento o potencialmente lento.
Florin Dumitrescu
1
@Iravanchi, en los enfoques "asíncronos", el envío de solicitudes y el manejo de respuestas se realizan por separado. Si tiene muchas llamadas, todas las solicitudes se enviarán muy rápido y las respuestas se manejarán cuando lleguen. Dado que solo puede deshacerse de las conexiones una vez que han llegado sus respuestas, una gran cantidad de conexiones simultáneas pueden acumular y agotar sus puertos efímeros. Debe limitar el número máximo de conexiones simultáneas utilizando ServicePointManager.DefaultConnectionLimit.
Florin Dumitrescu
1
@FlorinDumitrescu, también agregaría que las llamadas de red son por naturaleza impredecibles. Las cosas que se ejecutan en 10 ms el 90% del tiempo pueden causar problemas de bloqueo cuando ese recurso de red está congestionado o no está disponible el otro 10% del tiempo.
Tim P.
27

Una cosa a tener en cuenta que podría estar afectando sus resultados es que con HttpWebRequest no está obteniendo ResponseStream y consumiendo esa secuencia. Con HttpClient, por defecto copiará la transmisión de la red en una transmisión de memoria. Para utilizar HttpClient de la misma manera que actualmente está utilizando HttpWebRquest, debe hacerlo

var requestMessage = new HttpRequestMessage() {RequestUri = URL};
Task<HttpResponseMessage> 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 subprocesos, en realidad estás probando. Si profundiza en las profundidades de HttpClientHandler, simplemente realiza Task.Factory.StartNew para realizar una solicitud asincrónica. El comportamiento de subprocesos se delega al contexto de sincronización exactamente de la misma manera que se realiza su ejemplo con el ejemplo HttpWebRequest.

Sin lugar a dudas, HttpClient agrega algo de sobrecarga, ya que por defecto usa HttpWebRequest como su biblioteca de transporte. Por lo tanto, siempre podrá obtener un mejor rendimiento con HttpWebRequest directamente mientras usa HttpClientHandler. Los beneficios que trae 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 rendimiento.

Darrel Miller
fuente
(respuesta anterior, pero) HttpClientparece fácil de usar y pensé que asincrónico era el camino a seguir, pero parece que hay muchos "peros y peros" alrededor de esto. ¿Quizás HttpClientdebería reescribirse para que sea más intuitivo de usar? ¿O que la documentación realmente enfatizaba las cosas importantes sobre cómo usarla de manera más eficiente?
mortb
@mortb, Flurl.Http flurl.io es un envoltorio más intuitivo de usar de HttpClient
Michael Freidgeim
1
@MichaelFreidgeim: Gracias, aunque ya he aprendido a vivir con el HttpClient ...
mortb
17

Si bien esto no responde directamente a la parte 'asíncrona' de la pregunta del OP, sí soluciona un error en la implementación que está utilizando.

Si desea que su aplicación se escale, evite usar HttpClients basados ​​en instancias. ¡La diferencia es ENORME! 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 ayudar a un minorista informático 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 se implementa IDisposable, los desarrolladores hicieron lo que normalmente harían al crear una instancia y colocarla dentro de una using()declaración. Una vez que comenzamos a probar la carga, la aplicación puso de rodillas al servidor; sí, el servidor no solo la aplicación. La razón 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 OSI , el cierre de los puertos de red puede llevar un tiempo. De hecho, el propio sistema operativo WindowsPuede tomar hasta 20 segundos para cerrar un puerto (por Microsoft). Estábamos abriendo puertos más rápido de lo que podrían cerrarse: agotamiento del puerto del servidor que perjudicó 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 se ve 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-from-a-net-client

Dave Black
fuente
He encontrado exactamente el mismo problema al crear el agotamiento del puerto TCP en el cliente. La solución fue arrendar la instancia de HttpClient por largos períodos de tiempo donde se realizaban llamadas iterativas, no crear y eliminar cada llamada. La conclusión a la que llegué fue: "Solo porque implementa Dispose, eso no significa que sea barato desecharlo".
PhillipH
así que si el HttpClient es estático y necesito cambiar un encabezado en la próxima solicitud, ¿qué le hace eso a la primera solicitud? ¿Hay algún daño en cambiar el HttpClient ya que es estático, como emitir un HttpClient.DefaultRequestHeaders.Accept.Clear (); ? Por ejemplo, si tengo usuarios que se autentican a través de tokens, esos tokens deben agregarse como encabezados en la solicitud a la API, de los cuales son tokens diferentes. ¿No tendría el HttpClient como estático y luego cambiar este encabezado en HttpClient tendría efectos adversos?
crizzwald
Si necesita usar miembros de la instancia de HttpClient, como encabezados / cookies, etc., no debe usar un HttpClient estático. De lo contrario, los datos de su instancia (encabezados, cookies) serían los mismos para cada solicitud, ciertamente NO lo que desea.
Dave Black el
dado que este es el caso ... ¿cómo evitarías lo que estás describiendo anteriormente en tu publicación - contra carga? equilibrador de carga y arrojar más servidores?
crizzwald
@crizzwald: en mi publicación, noté la solución utilizada. Use una instancia estática de HttpClient. Si necesita usar encabezado / cookies en un HttpClient, buscaría usar una alternativa.
Dave Black el