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.
SemaphoreSlim
, como ya se mencionó, o ActionBlock<T>
desde TPL Dataflow.
HttpClient
Debería respetarServicePointManager.DefaultConnectionLimit
.