Agotamiento del ThreadPool de .Net
Más de una vez en mi carrera me he encontrado con este escenario: una aplicación .Net se presenta a menudo con tiempos de respuesta altos. Esta alta latencia puede originarse a través de 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” hasta 100%, sobrecarga de acceso a disco, entre otras. Necesito añadir a la lista anterior otra posibilidad, a menudo poco considerada: el agotamiento del ThreadPool.
Se presentará muy rápidamente como el ThreadPool de .Net funciona, además de ejemplos de código donde se sucede eso. Por último, demostra cómo evitar este problema.
O ThreadPool de .Net
El modelo de programación asincrónica basado en Tasks (Programación asincrónica basada en tareas) de .Net es bien conocido por la comunidad de desarrollo, aún creo que sus detalles de implementación son poco comprendidos, y es en los detalles donde reside el peligro, como dice el dicho.
Detrás del motor de ejecución Tasks de .Net hay una Scheduler, responsable, como su nombre lo indica, por programar la ejecución de Tasks. Al menos que se cambie explícitamente lo contrario, el scheduler estándar .NET es ThreadPoolTaskScheduler, que también como su nombre indica, utiliza el ThreadPool .Net estándar para hacer su trabajo.
O ThreadPool se las arregla entonces, como era de esperar, un pool de threads, a lo que atribuye la Task que recibe utilizando una cola. Es en esta línea donde se encuentra la Task se almacenan hasta que haya un thread libre en el pooly luego comenzar a procesarlo. De forma predeterminada, el número mínimo de threads do pool es igual al número de procesadores lógicos del host.
Y aquí está el detalle de cómo funciona: cuando hay más Tasks para ejecución que el número de threads do pool,el ThreadPoolpuede esperar un threadlibérate o crea más threads. Si decide crear una nueva thready si el número actual de threads del pooles igual o mayor al número mínimo configurado, este crecimiento demora entre 1 y 2 segundos por cada nueva threadañadido em el pool.
Nota: desde el Se han introducido mejoras de .Net 6 en este proceso, lo que permite un aumento más rápido en el número de subprocesos en el ThreadPool, pero aún así se mantenga la idea inicial.
Veamos un ejemplo para que quede más claro: supongamos una computadora con 4 núcleos. El valor mínimo de ThreadPool serán 4. Si todos las Tasks que llegan rápidamente procesan su trabajo, el pool puede incluso tener menos del mínimo de 4 threads activo. Ahora, imagina que 4 Tasks de duración un poco más longa llegaron simultáneamente, aprovechando así todos threads do pool. Cuando la próxima Task llegar a la cola, deberá esperar entre 1 y 2 segundos, hasta que aparezca una nueva thread ser añadido a el pool, luego salga de la cola y comience el procesamiento. Si esta nueva Task también tienen una duración mayor, las siguientes Tasks pasajeros volverán a esperar en la fila y deberán “pagar la cuota” durante 1 o 2 segundos antes de poder comenzar a correr.
Si este comportamiento de nuevo Task se mantiene una larga duración durante algún tiempo, la sensación para los clientes de este proceso será de lentitud, por cualquier nueva tarea que llegue a la cola de ThreadPool. Este escenario se llama Agotamiento del ThreadPool (ThreadPool exhaustion ou ThreadPool starvation). Esto ocurrirá hasta que Tasks termina tu trabajo y empeza a regressalas threads ao pool, lo que permite reducir la cola de Tasks pendiente, o que el 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 síncrono vs. código asíncrono
Ahora es necesario hacer una distinción importante entre los tipos de trabajo a largo plazo. Generalmente se pueden clasificar en 2 tipos: CPU/GPU limitada (CPU-bound ou GPU-bound), como realizar cálculos complejos o estar limitado por operaciones de entrada/salida (I/O-bound), como acceso a bases de datos o activación de recursos de red.
En el caso de tareas CPU-bound, salvo optimizaciones de algoritmos, no se puede hacer mucho: debe haber suficientes procesadores para satisfacer la demanda.
Pero en el caso de las tareas I/O-boundes posible liberar el procesador para responder a otras solicitudes mientras se espera que se complete la operación de I/O. Y eso es exactamente lo que ThreadPool hace cuando las API asincrónicas de I / O se utilizan. En este caso, incluso si la tarea específica aún requiere mucho tiempo, thread será devuelto al pool y puede que conozca a otro Task de la cola. Cuando la operación de I / O terminar, la Task pondrá en cola nuevamente y luego continuará ejecutándose. Para conocer más detalles sobre cómo funciona el ThreadPool espera el fin de las operaciones I / O, haga clic aquí.
Sin embargo, es importante tener en cuenta que todavía existen API sincrónicas I / O, que provocan el bloqueo de la thread y evitar su liberación al pool. Estas API, y cualquier otro tipo de llamada que bloquee una thread antes de volver a la ejecución – comprometer el correcto funcionamiento del ThreadPool, que pueden provocar agotamiento al ser sometidos a cargas suficientemente grandes y/o prolongadas.
Podemos decir entonces que el ThreadPool – y por extensión ASP.NET Core/Kestrel, diseñado para funcionar de forma asincrónica – está optimizado para ejecutar tareas de baja complejidad computacional, con cargas I/O bound asincrónico. En este escenario, un pequeño número de threads es capaz de procesar un número muy elevado de tasks/solicitudes de manera eficiente.
Bloqueo de threads con ASP.NET Core
Vamos a mirar algunos ejemplos de código que provocan el bloqueo de threads do 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 declaración WAITFOR DELAY.
Para generar una carga de uso y demostrar los efectos prácticos de cada ejemplo, utilizaremos el 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 sobre 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 action DbCall es sincrónico y el método ExecuteNonQueryExecuteNonQuery de DbCommand/SqlCommand es sincrónico, por lo que bloqueará la threadhasta 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).
Vea que logramos una tasa de 27 solicitudes por segundo (Transaction rate), y un tiempo de respuesta promedio (Response time) de unos 4 segundos, siendo la solicitud más larga (Longest transaction) que dura más de 16 segundos, lo que supone un rendimiento muy pobre.
Versión asincrónica – Intento 1
Ahora vamos a utilizar un action asíncrono (devolviendo Task<string>), pero aún así 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 – para transformar una llamada de E/S sincrónica (en nuestro caso, la ExecuteNonQuery) en una “API asincrónica”, utilizando 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 bien, la variación conocida como “sync over async”, en donde utilizamos métodos asincrónicos, como ExecuteNonQueryAsync de este ejemplo, pero el método se llama .Wait() da Task devuelto por el método, como se muestra a continuación. Tanto el .Wait() En cuanto a la propiedad .Result de una Task tienen el mismo comportamiento: provocan el bloqueo de la thread en ejecución!
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 el uso de .Wait() ou .Result en un Task No se recomienda su uso en código asincrónico.
Problema Solución
Por último, 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.
Entonces tenemos el action de forma 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 generalmente 3 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.
Versión del código | Tasa de solicitud ( /s) | Tiempo promedio (s) | Tiempo máximo (s) |
Sincrónico | 27,38 | 4,14 | 16,93 |
Asíncrono 1 | 14,33 | 7,94 | 14,03 |
Asíncrono 2 | 24,90 | 4,57 | 14,80 |
Asíncrono 3 | 32,43 | 3,52 | 25,03 |
Solución | 88,91 | 1,23 | 3,18 |
Solución paliativa
Cabe mencionar que podemos configurar el ThreadPool tener un número mínimo de threads mayor que el valor predeterminado (la cantidad de procesadores lógicos). Con esto podrá aumentar rápidamente el número de threads sin pagar “la cota” de 1 o 2 segundos.
Hay al menos menos 3 maneras para hacer esto: mediante configuración dinámica, utilizando el archivo runtimeconfig.json, mediante la configuración del proyecto, ajustando la propiedad ThreadPoolMinThreads, o por código, llamando al método ThreadPool.SetMinThreads.
Esto debe verse como una medida temporal, hasta que se realicen los ajustes adecuados al código como se muestra arriba, o después de las debidas pruebas previas para confirmar que trae beneficios sin efectos secundarios en el rendimiento, como se muestra arriba. recomendación de Microsoft.
Conclusión
El agotamiento del 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 representar la diferencia a la hora de evitar problemas de escala o diagnosticarlos más rápidamente.
Esté atento a las próximas publicaciones de Proud. Tech Writers. En un próximo artículo, vamos a explorar cómo diagnosticar el agotamiento del ThreadPool e identificar la fuente del problema en el código de un proceso en ejecución.
¡Buena publicación!