Hoy vengo a hablar de un tema en el que muchos posiblemente no penseis que puede pasar nunca, y así pensaba yo que sería durante mi carrera profesional, pero ahora estoy en un cliente donde tenemos algunas aplicaciones que podríamos considerar legacy (en net framework) el cual nos genera muchos problemas, la gran mayoría debidos al thread pool starvation.
Índice
Aquí vamos a ver cómo solucionar o por lo menos mitigar dichos problemas, ya que no siempre es posible solucionarlos a corto plazo.
1 - Qué es el Thread Pool?
Lo primero que tenemos que entender es qué es el thread pool; Basicamente es una serie de hilos lógicos que tu aplicación tiene disponibles para realizar operaciones con ellos. Cuando tienes un programa que se ejecuta, sus acciones se ejecutan en el procesador, para ello necesitas un thread o hilo. Pues con el thread pool es un número fijo que tu applicación sabe que esta disponbile y los usa.
Por ejemplo, si tienes una app que calcula una multiplicación, cuando mandas los datos para que estos se calculen se hacen con un hilo, lo mismo aplica cuando tienes una aplicación web, cuando recibes una llamada a un endpoint esta se asigna a un hilo y cuando termina el hilo se devuelve al thread pool.
Lo bueno del thread pool es que te permite reutilizar threads en vez de tener que crearlos cada vez, lo cual es más eficiente. En el caso de C# esto nos viene dado por el framework de .NET.
2 - Qué es Thread Pool Starvation?
Cuando hablamos de thread pool starvation lo que estamos diciendo es que todos los hilos de los que disponemos están ocupados, lo que significa que cualquier acción que necesitemos hacer va a estar esperando.
Lo cual nos lleva a unos problemas de rendimiento tremendos y que la aplicación básicamente va a dar timeout.
Esto se ve muy fácil en servicios que reciben llamadas HTTP. para simplificar, si tenemos 20 hilos y cada uno tarda 1 minuto en terminar, si recibimos 50 llamadas de golpe va a haber 30 que van a estar esperando.
Obviamente en entornos profesionales esos números son más altos, normalmente debido a que tenemos más de una instancia, si por ejemplo tenemos 3 instancias con 20 hilos cada una, podemos soportar las 50 llamadas mencionadas anteriomente.
De primeras, este tipo de problemas es muy dificil verlo, las empresas se suelen curar en salud y tener máquinas mucho más grandes de lo necesario, pero claro, algunas veces, nuestro crecimiento es mucho mayor de lo que esperamos y aquí llegan las complicaciones.
3 - Cómo Detectar problemas por Thread Pool starvation
Para mi, uno de los problemas más grandes es detectar thread pool starvation. Este tipo de problemas se detectan a nivel de red, no en un log, ya puedes tener cualquier sistema de log, que no vas a ver un error como cuando intentas acceder a un null o dividir por cero;
Aquí lo que vas a ver son llamadas tardando muchísimo tiempo en terminar, sin saber por qué, algunas se completan, otras petan etc, pero lo que sí que ves es que cada vez este número de llamadas lentas va a más y más.
Además observamos que estos problemas ocurren cuando tienes picos de uso, o cuando la app en si recibe más llamadas.
Si tienes acceso a mirar el estado de la CPU y de la RAM y ves que es bajo, pero aún así ves que los tiempo de respuesta solo hacen que aumentar, es muy probable que tengas un problema de thread pool starvation.
No voy a dar números porque no hay número que valga, pero se puede ver si una llamada que se completa en 1 segundo y justo después la misma llamada tarda 5 minutos.
Como digo, si este es tu caso, lo más posible es que tengas un problema de thread pool starvation. La mayor dificultad radica en que no vas a tener ningún error, más allá de que algunas llamadas hacen timeout si lo tienes configurado de esa manera.
4 - Causas de thread pool starvation
A priori las causas pueden parecer obvias, la más clara es la que he dicho, soportamos 100 llamadas pero estamos recibiendo 500. Las mates no dan, así que hay que aumentar el servidor/contenedor o lo que utilicemos.
Por supuesto esto se multiplica si las tareas que estás realizando tardan varios minutos en vez de milisegundos
Hay otros temas como una configuración inadecuada o el no liberar los hilos adecuadamente que están ahí, pero en C# no nos pasa, ya que todo ese tema lo maneja el framework de .NET.
Pero otra causa, mucho más común de lo que parece es no saber programar. Y parece una tontería pero es así.
En primer lugar está el tema de realizar más llamadas de las necesarias.
Por ejemplo la típica aplicación donde al principio del caso de uso estamos llamando al microservicio de usuarios para coger los datos de usuario y por ejemplo comprobar que tiene permisos para modificar el elemento seleccionado.
Por ahora todo está bien, pero tu proceso sigue, y más adelante vuelves a hacer una llamada otra vez al microservicio de usuarios para recibir la misma información, por lo que en cada proceso estas haciendo dos llamadas en vez de una.
Si tienes una aplicación que recibe 10 llamadas por minuto, pues no pasa nada, si recibe 60 mil, tienes un problema.
Otro ejemplo es consultar usuarios uno por uno dentro de un bucle, en vez de eso es mejor hacer la consulta pasando todos los usuarios que estamos buscando de golpe, porque si tenemos 100 usuarios que buscar, son 100 conexiones al otro servicio, 100 conexiones a la bbdd mientras que si mandamos los cien en una sola llamada, pues es una llamada aunque sea algo mas lenta.
4.1 - El mal uso de Task.Run
Pero sin duda alguna, la mayor causa de problemas es el mal uso de Task.Run
(en C#); Porque a diferencia de cuando tenemos apps de escritorio, Task.Run no ayuda, sino que hace que todo colapse.
Task.Run se utiliza para realizar la tarea que vayas a realizar “en segundo plano” para no bloquear el hilo principal, esto tiene sentido en tareas de escritorio, porque si no haces esto, la interfaz se bloquea, por ejemplo.
Pero qué pasa en aplicaciones web? Sobre todo qué pasa cuando recibimos una llamada a nuestra API y tenemos task runs por ahí dando vueltas?
Lo primero que te puedes preguntar es ¿por qué alguien utilizaría Task.Run en una API?
En la gran mayoría de casos se hace Task.Run por un mal entendimiento del funcionamiento. Asumimos que el resultado final va a ser igual que el que tenemos con aplicaciones de escritorio es donde llegan los problemas.
Imaginate que tienes una API todo async/await todo bien configurado, pero tienes ciertos clientes que por lo que sea no tienen una implementación Async; Por ejemplo en el caso de antes, el microservicio de usuarios, tiene un cliente en C# que realiza las llamadas http, y tiene su “getUserInfo(userId)
” este método nos devuelve el objeto UserInfo
. Como tu tienes todo tu código async, haces un Task.Run alrededor de Userinfo y así puedes hacer tu await de la tarea.
UserInfo userInfo = await Task.Run(() => _userServiceClient.GetUserInfo(userId));
De primeras puede tener sentido, pero esto NO FUNCIONA IGUAL que async/await y aquí es donde viene el problema. Básicamente lo que la gran mayoría de la gente asume es que al poner el await
, estamos, mágicamente haciendo el código asíncrono, cuando no, porque lo que estamos haciendo es mover la llamada que está bloqueada a un hilo del thread pool.
Además como quieres el resultado y estas aplicando la lógica de async/await estas poniendo la keyword `await` lo cual va a causar otro problema, bloquear el hilo que ejecuta la tarea, y como estas haciendo Task.Run, estás bloqueando otro hilo del thread pool.
Si volvemos al caso de antes donde tenemos una “doble llamada” innecesaria junto a el uso de Task.Run, vamos a estar utilizando 4 hilos cuando en realidad solo necesitamos uno, si lo piensas es una locura.
Como digo, es posible que muchos tengáis este tipo de problemas y nunca os hayáis percatado porque en sistemas de bajo uso es muy difícil detectarlo.
Si el Cliente de la api que estás consumiendo es síncrona (no tiene async) utilizar Task.Run
no va a hacer magia y convertirla en asíncrona.
Antes de terminar este punto, si teneis que llamar a 4 o 5 servicios a la vez, ahí si podéis utilizar task run, porque vais a realizar las llamadas en paralelo y luego esperarlas todas, por lo que el tiempo de respuesta va a ser mucho más rápido y los beneficios son mayores a los costos, aún así hay mejores formas de arreglar esto.
// Ejecutar todas las llamadas utilizando Task.Run
var userInfoTask = Task.Run(() => _userServiceClient.GetUserInfo(userId));
var vehiclesTask = Task.Run(() => _vehicleServiceClient.GetVehicles(userId));
var storesTask = Task.Run(() => _storeServiceClient.GetStores(userId));
// Esperar todas las tareas
await Task.WhenAll(userInfoTask, vehiclesTask, storesTask);
// coger los resultados individualmente
var userInfo = await userInfoTask;
var vehicles = await vehiclesTask;
var stores = await storesTask;
Nota: es mejor usar await
que .Result
en las tareas, incluso aunque sepamos que el resultado ya está terminado.
Ahora vamos a ver como arreglar entre otras cosas este escenario.
5 - Mitigar problemas de thread pool starvation
Como todo en esta vida hay formas de evitar estos problemas, aunque la gran mayoría de las veces la solución va a ser mitigarlos que no arreglarlos, ya que arreglarlos suele costar mucho tiempo.
A - Tirar dinero al problema, básicamente te estás quedando sin hilos, pues pones un servidor mayor, CPUs más potentes, más servidores detrás de un Load Balancer, MAS MAS MAS; y el problema se irá, por un tiempo, pero volverá más fuerte, esto te lo aseguro.
B - Eliminar Task.Run, Si la aplicación que tiene el problema esta llena de Task.Run
simplemente quitalos, es mejor que se quede un hilo bloqueado que dos, en muchos casos, esto va a ser una solución semi-permanente (que no ideal);
C - Rate Limiting, vimos el rate limiting en otro post que es básicamente limitar el uso que se hace de la app, supongamos que tenemos una app que cuando pasa de 30 mil llamadas por minuto empezamos a ver los problemas de threadpool (recuerda, una vez pillas uno, se van acumulando “en la cola”). Pues ajustamos para que se limite a 27 mil llamadas, todas las llamadas que pasen de ese límite las tiramos, no es ideal pero mejor eso a que se caiga todo.
D - Migrar todo a async, esta es la solución ideal. Vimos en el post sobre async/await que todo “lo que sale del código” debe ser async, eso incluye tanto las llamadas a servicios externos, como al sistema de ficheros o a la base de datos. Pero claro, esto lleva tiempo, posiblemente tengas que migrar un buen puñado de clientes http, luego migrar su código, después migrar tu código, testear que todo vaya bien, etc. Este es el escenario ideal y a la larga el que mejor funciona. Este sería el código anterior con Async/Await implementado correctamente:
var userInfoTask = _userServiceClient.GetUserInfoAsync(userId);
var vehiclesTask = _vehicleServiceClient.GetVehiclesAsync(userId);
var storesTask = _storeServiceClient.GetStoresAsync(userId);
await Task.WhenAll(userInfoTask, vehiclesTask, storesTask);
var userInfo = await userInfoTask;
var vehicles = await vehiclesTask;
var stores = await storesTask;
Como vemos es muy similar con la diferencia en la que todas las llamadas se van a ejecutar de manera asíncrona y sin bloquear nada. Es la forma más eficiente y recomendada de implementar asincronía en C# ya que se utilizan los recursos de la forma más óptima.
Conclusión
Ahora ya lo sabes, si no quieres tener problemas de rendimiento en aplicationes que requieran de alta disponibilidad y de alto rendimiento implementa las cosas correctamente porque un mal bucle o una mala implementación puede colapsarlo todo en este tipo de sistemas.