Más de una vez en mi carrera me he encontrado con este escenario: una aplicación .Net muestra frecuentemente tiempos de respuesta altos. Esta alta latencia puede tener varias causas, como un acceso lento a un recurso externo (una base de datos o una API, por ejemplo), un uso de CPU que “llega” al 100%, sobrecarga de acceso a disco, entre otras. Quiero añadir a la lista anterior otra posibilidad, a menudo poco considerada: el agotamiento del ThreadPool. Se presentará muy rápidamente cómo funciona .Net ThreadPool y ejemplos de código donde esto puede suceder. Finalmente, se demostrará cómo evitar este problema. El modelo de programación asincrónica basado en tareas de .Net es bien conocido por la comunidad de desarrollo, pero creo que sus detalles de implementación son poco comprendidos, y es en los detalles donde reside el peligro, como dice el refrán. Detrás del mecanismo de ejecución de Tareas .Net hay un Scheduler, encargado, como su nombre indica, de programar la ejecución de Tareas. A menos que se cambie explícitamente, el programador .Net predeterminado es ThreadPoolTaskScheduler, que, como su nombre lo indica, utiliza el ThreadPool .Net predeterminado para realizar su trabajo. El ThreadPool luego administra, como se esperaba, una pool de hilos, a los cuales asigna las Tareas que recibe mediante una cola. Es en esta cola donde se almacenan las tareas hasta que haya un hilo libre en la cola. pooly luego comenzar a procesarlo. De forma predeterminada, el número mínimo de subprocesos pool es igual al número de procesadores lógicos en el host. Y aquí está el detalle de cómo funciona: cuando hay más tareas a ejecutar que el número de subprocesos en el poolEl ThreadPool puede esperar a que un hilo quede libre o crear más hilos. Si decide crear un nuevo hilo y si el número actual de hilos en el pool es igual o mayor que el número mínimo configurado, este crecimiento demora entre 1 y 2 segundos por cada nuevo hilo agregado al pool. Nota: A partir de .Net 6, se introdujeron mejoras en este proceso, lo que permitió un aumento más rápido en la cantidad de subprocesos en ThreadPool, pero la idea principal sigue siendo la misma. Veamos un ejemplo para que quede más claro: supongamos que una computadora tiene 4 colores. El valor mínimo del ThreadPool será 4. Si todas las tareas entrantes procesan rápidamente su trabajo, el pool Incluso puede tener menos del mínimo de 4 hilos activos. Ahora, imaginemos que 4 Tareas de duración ligeramente mayor llegan simultáneamente, utilizando así todos los hilos de la pool. Cuando la siguiente tarea llega a la cola, deberá esperar entre 1 y 2 segundos, hasta que se agregue un nuevo hilo a la cola. pool, luego salga de la cola y comience el procesamiento. Si esta nueva Tarea también tiene una duración mayor, las siguientes Tareas volverán a esperar en la cola y deberán “pagar el peaje” de 1 a 2 segundos antes de poder comenzar a ejecutarse. Si este comportamiento de las nuevas tareas de larga ejecución continúa durante algún tiempo, la sensación para los clientes de este proceso será de lentitud, para cualquier nueva tarea que llegue a la cola de ThreadPool. Este escenario se llama agotamiento de ThreadPool o inanición de ThreadPool. Esto sucederá hasta que las tareas terminen su trabajo y comiencen a devolver subprocesos a pool, permitiendo la reducción de la cola de Tareas pendientes, o que la pool Puede crecer lo suficiente para satisfacer la demanda actual. Esto puede tardar varios segundos, dependiendo de la carga, y solo entonces dejará de existir la desaceleración observada anteriormente. Código sincrónico vs. código asincrónico Ahora debemos hacer una distinción importante entre los tipos de trabajos de larga duración. Generalmente se pueden clasificar en dos tipos: limitados por CPU/GPU (CPU-bound o GPU-bound), como ejecutar cálculos complejos, o limitados por E/S (I/O-bound), como acceder a bases de datos o llamadas de red. En el caso de tareas limitadas por la CPU, a excepción de las optimizaciones de algoritmos, no hay mucho que se pueda hacer: es necesario tener suficientes procesadores para satisfacer la demanda. Pero, en el caso de tareas limitadas por E/S, es posible liberar el procesador para responder a otras solicitudes mientras se espera que se complete la operación de E/S. Y eso es exactamente lo que hace ThreadPool cuando se utilizan API de E/S asincrónicas. En este caso, incluso si la tarea específica aún consume mucho tiempo, el hilo volverá al pool y puede atender otra tarea en la cola. Cuando se complete la operación de E/S, la tarea se volverá a poner en cola y luego continuará ejecutándose. Para obtener más detalles sobre cómo ThreadPool espera a que finalicen las operaciones de E/S, haga clic aquí. Sin embargo, es importante tener en cuenta que todavía hay API de E/S sincrónicas que hacen que el hilo se bloquee y evitan que se libere al pool. Estas API -y cualquier otro tipo de llamada que bloquee un hilo antes de volver a la ejecución- comprometen el correcto funcionamiento del ThreadPool, y pueden provocar que se agote al someterse a cargas suficientemente grandes y/o prolongadas. Podemos decir entonces que ThreadPool –y por extensión ASP.NET Core/Kestrel, diseñado para operar de forma asincrónica– está optimizado para ejecutar tareas de baja complejidad computacional, con cargas de E/S limitadas de forma asincrónica. En este escenario, una pequeña cantidad de subprocesos es capaz de procesar una gran cantidad de tareas/solicitudes de manera eficiente. Bloqueo de subprocesos con ASP.NET Core Veamos algunos ejemplos de código que provocan el bloqueo de subprocesos en ASP.NET Core. pool, utilizando ASP.NET Core 8.
Nota: Estos códigos son ejemplos simples y no pretenden representar ninguna práctica, recomendación o estilo en particular, excepto los puntos relacionados específicamente con la demostración de ThreadPool.
Para mantener un comportamiento idéntico entre los ejemplos, se utilizará una solicitud a una base de datos de SQL Server que simulará una carga de trabajo que tarda 1 segundo en regresar, utilizando la instrucción WAITFOR DELAY.
Para generar una carga de uso y demostrar los efectos prácticos de cada ejemplo, utilizaremos siege, una utilidad de línea de comandos gratuita diseñada para este propósito.
En todos los ejemplos, se simulará una carga de 120 accesos simultáneos durante 1 minuto, con un retraso aleatorio de hasta 200 milisegundos entre solicitudes. Estos números son suficientes para demostrar los efectos en el ThreadPool sin generar tiempos de espera al acceder a la base de datos.
Versión sincrónica Comencemos con una implementación completamente sincrónica: la acción DbCall es sincrónica y el método ExecuteNonQuery de DbCommand/SqlCommand es sincrónico, por lo que bloqueará el hilo hasta que la base de datos regrese. A continuación se muestra el resultado de la simulación de carga (con el comando de asedio utilizado).
Puedes ver que logramos una tasa de 27 solicitudes por segundo (tasa de transacción) y un tiempo de respuesta promedio (tiempo de respuesta) de alrededor de 4 segundos, donde la solicitud más larga (transacción más larga) duró más de 16 segundos, un rendimiento muy pobre.
Versión asincrónica – Intento 1 Ahora usemos una acción asincrónica (devolviendo la tarea) ), pero aún utiliza el método sincrónico ExecuteNonQuery.
Ejecutando el mismo escenario de carga que antes, tenemos el siguiente resultado.
Cabe destacar que el resultado fue aún peor en este caso, con una tasa de solicitudes de 14 por segundo (en comparación con 27 para la versión completamente sincrónica) y un tiempo de respuesta promedio de más de 7 segundos (en comparación con 4 para la anterior).
Versión asincrónica – Intento 2 En esta próxima versión, tenemos una implementación que ejemplifica un intento común –y no recomendado– de transformar una llamada de E/S sincrónica (en nuestro caso, ExecuteNonQuery ) en una “API asincrónica”, usando Task.Run.
Después de la simulación, el resultado está cerca de la versión sincrónica: tasa de solicitudes de 24 por segundo, tiempo de respuesta promedio de más de 4 segundos y la solicitud más larga tardó más de 14 segundos en regresar.
Versión asincrónica – Intento 3 Ahora la variación conocida como “sync over async”, donde usamos métodos asincrónicos, como ExecuteNonQueryAsync en este ejemplo, pero se llama al método .Wait() de la Tarea devuelta por el método, como se muestra a continuación. Tanto la propiedad .Wait() como la .Result de una Tarea tienen el mismo comportamiento: ¡hacen que el hilo en ejecución se bloquee!
Ejecutando nuestra simulación, podemos ver a continuación como el resultado también es malo, con una tasa de 32 solicitudes por segundo, un tiempo promedio de más de 3 segundos, y solicitudes que tardan hasta 25 segundos en regresar. No es sorprendente que se desaconseje el uso de .Wait() o .Result en una tarea en código asincrónico.
Problema Solución Finalmente, veamos el código creado para trabajar de la manera más eficiente, a través de APIs asíncronas y aplicando async/await correctamente, siguiendo la recomendación de Microsoft.
Luego tenemos la acción asincrónica, con la llamada ExecuteNonQueryAsync con await.
El resultado de la simulación habla por sí solo: tasa de solicitudes de 88 por segundo, tiempo de respuesta promedio de 1,23 segundos y solicitud que tarda un máximo de 3 segundos en regresar: números en general tres veces mejores que cualquier opción anterior.
La tabla a continuación resume los resultados de las diferentes versiones, para una mejor comparación de los datos entre ellas.
Código VersiónTasa de Solicitud ( /s)Tiempo Promedio (s)Tiempo Máximo (s)Sincrónico27,384,1416,93Asincrónico114,337,9414,03Asincrónico224,904,5714,80Asincrónico332,433,5225,03Solución88,911,233,18 Solución Alternativa Cabe mencionar que podemos configurar el ThreadPool para tener un número mínimo de hilos mayor al predeterminado (el número de procesadores lógicos). Con esto podrá aumentar rápidamente el número de hilos sin pagar ese “peaje” de 1 o 2 segundos.
Hay al menos tres formas de hacer esto: mediante configuración dinámica, utilizando el archivo runtimeconfig.json, mediante configuración del proyecto, ajustando la propiedad ThreadPoolMinThreads, o mediante código, llamando al método ThreadPool.SetMinThreads.
Esto debe verse como una medida temporal, mientras no se realizan los ajustes adecuados al código como se muestra arriba, o después de pruebas previas apropiadas para confirmar que trae beneficios sin efectos secundarios en el rendimiento, como recomienda Microsoft.
Conclusión El agotamiento de ThreadPool es un detalle de implementación que puede tener consecuencias inesperadas. Y pueden ser difíciles de detectar si tenemos en cuenta que .Net tiene varias formas de obtener el mismo resultado, incluso en sus APIs más conocidas –creo que motivado por años de evolución del lenguaje y de ASP.NET, siempre apuntando a la compatibilidad hacia atrás.
Cuando hablamos de operar a ritmos o volúmenes crecientes, como pasar de decenas a cientos de solicitudes, es fundamental conocer las últimas prácticas y recomendaciones. Además, conocer uno u otro detalle de implementación puede marcar la diferencia a la hora de evitar problemas de escala o diagnosticarlos más rápidamente.
Tech Writers. En un artículo futuro, exploraremos cómo diagnosticar el agotamiento de ThreadPool e identificar la fuente del problema en el código de un proceso en ejecución.