Búsqueda y análisis de alto uso de CPU en aplicaciones .NET
Publicado: 2022-03-11El desarrollo de software puede ser un proceso muy complicado. Nosotros, como desarrolladores, debemos tener en cuenta muchas variables diferentes. Algunos no están bajo nuestro control, algunos son desconocidos para nosotros en el momento de la ejecución del código real y algunos están controlados directamente por nosotros. Y los desarrolladores de .NET no son una excepción a esto.
Ante esta realidad, las cosas suelen salir según lo previsto cuando trabajamos en entornos controlados. Un ejemplo es nuestra máquina de desarrollo, o un entorno de integración al que tenemos pleno acceso. En estas situaciones, tenemos a nuestra disposición herramientas para analizar distintas variables que están afectando a nuestro código y software. En estos casos, tampoco tenemos que lidiar con cargas pesadas del servidor o usuarios simultáneos que intentan hacer lo mismo al mismo tiempo.
En situaciones descritas y seguras, nuestro código funcionará bien, pero en producción bajo carga pesada u otros factores externos, podrían ocurrir problemas inesperados. El rendimiento del software en producción es difícil de analizar. La mayoría de las veces tenemos que lidiar con problemas potenciales en un escenario teórico: sabemos que un problema puede ocurrir, pero no podemos probarlo. Es por eso que necesitamos basar nuestro desarrollo en las mejores prácticas y documentación para el lenguaje que estamos usando y evitar errores comunes.
Como se mencionó, cuando el software se activa, las cosas podrían salir mal y el código podría comenzar a ejecutarse de una manera que no habíamos planeado. Podríamos terminar en una situación en la que tengamos que lidiar con problemas sin la capacidad de depurar o saber con seguridad qué está pasando. ¿Qué podemos hacer en este caso?
En este artículo vamos a analizar un escenario de caso real de alto uso de CPU de una aplicación web .NET en el servidor basado en Windows, los procesos involucrados para identificar el problema y, lo que es más importante, por qué ocurrió este problema en primer lugar y cómo lo solucionamos. resuélvelo.
El uso de la CPU y el consumo de memoria son temas ampliamente discutidos. Por lo general, es muy difícil saber con certeza cuál es la cantidad correcta de recursos (CPU, RAM, E/S) que debe usar un proceso específico y durante qué período de tiempo. Aunque una cosa es segura: si un proceso utiliza más del 90 % de la CPU durante un período de tiempo prolongado, estamos en problemas solo por el hecho de que el servidor no podrá procesar ninguna otra solicitud en esta circunstancia.
¿Significa esto que hay un problema con el proceso en sí? No necesariamente. Puede ser que el proceso necesite más potencia de procesamiento o que esté manejando una gran cantidad de datos. Para empezar, lo único que podemos hacer es tratar de identificar por qué sucede esto.
Todos los sistemas operativos tienen varias herramientas diferentes para monitorear lo que sucede en un servidor. Los servidores de Windows tienen específicamente el administrador de tareas, el Monitor de rendimiento o, en nuestro caso, usamos New Relic Servers, que es una gran herramienta para monitorear servidores.
Primeros Síntomas y Análisis del Problema
Después de implementar nuestra aplicación, durante un lapso de tiempo de las primeras dos semanas, comenzamos a ver que el servidor tiene picos de uso de la CPU, lo que hizo que el servidor no respondiera. Tuvimos que reiniciarlo para que estuviera disponible nuevamente, y este evento ocurrió tres veces durante ese período de tiempo. Como mencioné antes, usamos New Relic Servers como un monitor de servidor y mostró que el proceso w3wp.exe
estaba usando el 94% de la CPU en el momento en que el servidor colapsó.
Un proceso de trabajo de Internet Information Services (IIS) es un proceso de Windows ( w3wp.exe
) que ejecuta aplicaciones web y es responsable de manejar las solicitudes enviadas a un servidor web para un grupo de aplicaciones específico. El servidor IIS puede tener varios grupos de aplicaciones (y varios procesos w3wp.exe
diferentes) que podrían estar generando el problema. Según el usuario que tenía el proceso (esto se mostró en los informes de New Relic), identificamos que el problema era nuestra aplicación heredada de formulario web .NET C#.
.NET Framework está estrechamente integrado con las herramientas de depuración de Windows, por lo que lo primero que intentamos hacer fue mirar el visor de eventos y los archivos de registro de la aplicación para encontrar información útil sobre lo que estaba sucediendo. Si teníamos algunas excepciones registradas en el visor de eventos, no proporcionaron suficientes datos para analizar. Por eso decidimos dar un paso más y recopilar más datos, para que cuando volviera a surgir el evento estuviéramos preparados.
Recopilación de datos
La forma más sencilla de recopilar volcados de procesos en modo usuario es con Debug Diagnostic Tools v2.0 o simplemente DebugDiag. DebugDiag tiene un conjunto de herramientas para recopilar datos (DebugDiag Collection) y analizar datos (DebugDiag Analysis).
Entonces, comencemos a definir reglas para recopilar datos con las herramientas de diagnóstico de depuración:
Abra la colección DebugDiag y seleccione
Performance
.- Seleccione
Performance Counters
y haga clic enNext
. - Haga clic
Add Perf Triggers
. - Expanda el objeto
Processor
(no elProcess
) y seleccione% Processor Time
. Tenga en cuenta que si está en Windows Server 2008 R2 y tiene más de 64 procesadores, elija el objetoProcessor Information
en lugar del objetoProcessor
. - En la lista de instancias, seleccione
_Total
. - Haga clic en
Add
y luego enOK
. Seleccione el disparador recién agregado y haga clic en
Edit Thresholds
.- Seleccione
Above
en el menú desplegable. - Cambie el umbral a
80
. Introduzca
20
para el número de segundos. Puede ajustar este valor si es necesario, pero tenga cuidado de no especificar una pequeña cantidad de segundos para evitar activaciones falsas.- Haga clic en
OK
. - Haga clic
Next
. - Haga clic
Add Dump Target
. - Seleccione
Web Application Pool
en el menú desplegable. - Seleccione su grupo de aplicaciones de la lista de grupos de aplicaciones.
- Haga clic en
OK
. - Haga clic
Next
. - Haga clic
Next
nuevo. - Ingrese un nombre para su regla si lo desea y tome nota de la ubicación donde se guardarán los volcados. Puede cambiar esta ubicación si lo desea.
- Haga clic
Next
. - Seleccione
Activate the Rule Now
y haga clic enFinish
.
La regla descrita creará un conjunto de archivos de minivolcado que tendrán un tamaño bastante pequeño. El volcado final será un volcado con memoria llena, y esos volcados serán mucho más grandes. Ahora, solo tenemos que esperar a que vuelva a ocurrir el evento de CPU alta.
Una vez que tengamos los archivos de volcado en la carpeta seleccionada, utilizaremos la herramienta de análisis DebugDiag para analizar los datos recopilados:

Seleccione Analizadores de rendimiento.
Agregue los archivos de volcado.
Iniciar análisis.
DebugDiag tardará unos minutos (o varios) en analizar los volcados y proporcionar un análisis. Cuando haya completado el análisis, verá una página web con un resumen y mucha información sobre hilos, similar a la siguiente:
Como puede ver en el resumen, hay una advertencia que dice "Se detectó un alto uso de CPU entre archivos de volcado en uno o más subprocesos". Si hacemos clic en la recomendación, comenzaremos a entender dónde está el problema con nuestra aplicación. Nuestro informe de ejemplo se ve así:
Como podemos ver en el informe, existe un patrón con respecto al uso de la CPU. Todos los subprocesos que tienen un alto uso de la CPU están relacionados con la misma clase. Antes de saltar al código, echemos un vistazo al primero.
Este es el detalle del primer hilo con nuestro problema. La parte que nos interesa es la siguiente:
Aquí tenemos una llamada a nuestro código GameHub.OnDisconnected()
que desencadenó la operación problemática, pero antes de esa llamada tenemos dos llamadas de diccionario, que pueden dar una idea de lo que está pasando. Echemos un vistazo al código .NET para ver qué está haciendo ese método:
public override Task OnDisconnected() { try { var userId = GetUserId(); string connId; if (onlineSessions.TryGetValue(userId, out connId)) onlineSessions.Remove(userId); } catch (Exception) { // ignored } return base.OnDisconnected(); }
Obviamente tenemos un problema aquí. La pila de llamadas de los informes decía que el problema era con un Diccionario, y en este código estamos accediendo a un diccionario, y específicamente la línea que está causando el problema es esta:
if (onlineSessions.TryGetValue(userId, out connId))
Esta es la declaración del diccionario:
static Dictionary<int, string> onlineSessions = new Dictionary<int, string>();
¿Cuál es el problema con este código .NET?
Cualquiera que tenga experiencia en programación orientada a objetos sabe que todas las instancias de esta clase compartirán variables estáticas. Echemos un vistazo más profundo a lo que significa estático en el mundo .NET.
Según la especificación .NET C#:
Utilice el modificador estático para declarar un miembro estático, que pertenece al tipo en sí y no a un objeto específico.
Esto es lo que dicen las especificaciones del lenguaje .NET C# con respecto a las clases y miembros estáticos:
Como es el caso con todos los tipos de clase, la información de tipo para una clase estática la carga Common Language Runtime (CLR) de .NET Framework cuando se carga el programa que hace referencia a la clase. El programa no puede especificar exactamente cuándo se carga la clase. Sin embargo, se garantiza que se cargará y que se inicializarán sus campos y se llamará a su constructor estático antes de que se haga referencia a la clase por primera vez en su programa. Un constructor estático solo se llama una vez, y una clase estática permanece en la memoria durante la vida útil del dominio de la aplicación en el que reside su programa.
Una clase no estática puede contener métodos, campos, propiedades o eventos estáticos. Se puede llamar al miembro estático en una clase incluso cuando no se ha creado ninguna instancia de la clase. Siempre se accede al miembro estático por el nombre de la clase, no por el nombre de la instancia. Solo existe una copia de un miembro estático, independientemente de cuántas instancias de la clase se creen. Los métodos y propiedades estáticos no pueden acceder a campos y eventos no estáticos en su tipo contenedor, y no pueden acceder a una variable de instancia de ningún objeto a menos que se pase explícitamente en un parámetro de método.
Esto significa que los miembros estáticos pertenecen al tipo en sí, no al objeto. El CLR también los carga en el dominio de la aplicación, por lo tanto, los miembros estáticos pertenecen al proceso que aloja la aplicación y no a subprocesos específicos.
Dado el hecho de que un entorno web es un entorno de subprocesos múltiples, porque cada solicitud es un nuevo subproceso generado por el proceso w3wp.exe
; y dado que los miembros estáticos son parte del proceso, podemos tener un escenario en el que varios subprocesos diferentes intenten acceder a los datos de las variables estáticas (compartidas por varios subprocesos), lo que eventualmente puede conducir a problemas de subprocesos múltiples.
La documentación del Diccionario en seguridad de subprocesos establece lo siguiente:
Un
Dictionary<TKey, TValue>
puede admitir varios lectores al mismo tiempo, siempre que no se modifique la colección. Aun así, la enumeración a través de una colección no es intrínsecamente un procedimiento seguro para subprocesos. En el raro caso de que una enumeración compita con accesos de escritura, la colección debe estar bloqueada durante toda la enumeración. Para permitir que varios subprocesos accedan a la colección para leer y escribir, debe implementar su propia sincronización.
Esta declaración explica por qué podemos tener este problema. Según la información de los volcados, el problema estaba en el método FindEntry del diccionario:
Si observamos la implementación de FindEntry del diccionario, podemos ver que el método itera a través de la estructura interna (depósitos) para encontrar el valor.
Entonces, el siguiente código .NET enumera la colección, que no es una operación segura para subprocesos.
public override Task OnDisconnected() { try { var userId = GetUserId(); string connId; if (onlineSessions.TryGetValue(userId, out connId)) onlineSessions.Remove(userId); } catch (Exception) { // ignored } return base.OnDisconnected(); }
Conclusión
Como vimos en los volcados, hay varios subprocesos que intentan iterar y modificar un recurso compartido (diccionario estático) al mismo tiempo, lo que finalmente provocó que la iteración entrara en un bucle infinito, lo que provocó que el subproceso consumiera más del 90 % de la CPU. .
Hay varias soluciones posibles para este problema. El primero que implementamos fue bloquear y sincronizar el acceso al diccionario a costa de perder rendimiento. El servidor fallaba todos los días a esa hora, por lo que necesitábamos solucionarlo lo antes posible. Incluso si esta no era la solución óptima, resolvió el problema.
El siguiente paso para resolver este problema sería analizar el código y encontrar la solución óptima para esto. Refactorizar el código es una opción: la nueva clase ConcurrentDictionary podría resolver este problema porque solo se bloquea a nivel de depósito, lo que mejorará el rendimiento general. Aunque, este es un gran paso, y se requeriría un análisis más profundo.