Contadores de rendimiento multiplataforma en .Net Core

Tiempo de lectura: 8 minutos
Imagen ornamental para la entrada Contadores de rendimiento multiplataforma en .Net Core

Pasan los días y ya estamos de vuelta con una nueva entrada. Si bien en las últimas dos entradas hablamos sobre Github Actions y MongoDB en alta disponibilidad, hoy toca volver al rendimiento de las aplicaciones. Durante este último mes he trabajado un proyecto en el que el rendimiento y el número de peticiones a una API de terceros era importante.

Esto ha obligado a hacer pruebas de rendimiento y de carga donde se comprobaban métricas del sistema. Para medir y solucionar el tema de la optimización, basto con escribir código en .Net Core optimizado para el alto rendimiento. Para poder medir el rendimiento sostenido durante una prueba de carga, la primera opción que se planteo fue utilizar los contadores de rendimiento del sistema operativo (PerfomanceCounters). El principal problema es que, aunque pueden funcionar con .Net Core, están muy ligados a Windows, por lo tanto la aplicación solo se puede medir si estamos en ese sistema operativo.

¿Cómo se obtienen contadores de rendimiento en .Net Core?

Con la llegada de .Net Core hubo muchos cambios en el funcionamiento interno de algunas cosas y una de ellas es en los contadores de rendimiento y su funcionamiento. Hay que conseguir que .Net Core sea multiplataforma y eso significa tener que romper con el funcionamiento de muchas cosas atadas al sistema operativo.

¿Significa esto que ya no se pueden obtener esas métricas? No, aun se pueden obtener, pero el modelo ha cambiado ligeramente. Esos contadores ahora ya no forman parte del sistema operativo y accedemos directamente a ellos, sino que ahora forman parte del propio runtime de .Net Core. Hasta la llegada de .Net Core 3, estos datos no eran directos de obtener pero, las cosas han cambiado.

Dentro del conjunto de herramientas disponibles dentro de .Net Core, existe una que precisamente nos permite ver esos contadores de rendimiento. Esta herramienta se llama dotnet-counters y se puede instalar desde la propia linea de comandos.

Utilizando dotnet-counters

Lo primero que vamos a necesitar para poder utilizar la herramienta, es simplemente instalarla como de manera global como podríamos hacer con cualquier otra. Para ello, basta con ejecutar el comando:

dotnet tool install --global dotnet-counters

A partir de este momento una vez instalada la herramienta, vamos a poder acceder a ella utilizando simplemente el comando dotnet-counters.

Esta herramienta ofrece 4 comandos para ejecutar:

  • monitor
  • collect
  • list
  • ps

El funcionamiento de las opciones es muy sencillo. El comando ps nos va a permitir listar los procesos .Net Core corriendo en la máquina. El comando list permite ver los contadores de rendimiento comunes de .Net Core. Por último, los dos comandos restantes son los que nos van a permitir visualizar y registrar los contadores de rendimiento del proceso.

El comando monitor va a mostrar en la propia consola donde se ejecuta los valores asociados al proceso que le indiquemos al ejecutarlo. La sintaxis de este comando es:

dotnet-counters monitor [-p|--process-id] [--refreshInterval] [counter_list]

El único parámetro obligatorio es el id del proceso que queremos monitorizar, el cual podemos obtener por ejemplo desde el comando ps. Adicionalmente podemos indicarle la frecuencia de refresco (por defecto 1 segundo) y el grupo de contadores concretos que queremos monitorizar. En caso de que no le indiquemos ningún contador, utilizará por defecto System.Runtime.

El resultado de salida de este comando con los parámetros por defecto podría ser algo como esto:

[System.Runtime]
# of Assemblies Loaded                             4
% Time in GC (since last GC)                       0
Allocation Rate (Bytes / sec)                      8.168
CPU Usage (%)                                      0
Exceptions / sec                                   0
GC Heap Size (MB)                                  0
Gen 0 GC / sec                                     0
Gen 0 Size (B)                                     0
Gen 1 GC / sec                                     0
Gen 1 Size (B)                                     0
Gen 2 GC / sec                                     0
Gen 2 Size (B)                                     0
LOH Size (B)                                       0
Monitor Lock Contention Count / sec                0
Number of Active Timers                            0
ThreadPool Completed Work Items / sec              1
ThreadPool Queue Length                            0
ThreadPool Threads Count                           1
Working Set (MB)                                  19

En el caso de que por ejemplo solo necesitemos algunos de ellos, es posible indicar dentro del cada grupo de contadores cuales queremos monitorizar. Si solo nos interesará la CPU y la RAM, el comando sería algo así:

dotnet-counters monitor -p 14444 System.Runtime[cpu-usage,working-set]

Los contadores específicos dentro de un grupo se indican dentro de los corchetes separados con comas. El nombre de los contadores se puede obtener directamente desde el comando list.

Por último, el comando collect tiene un funcionamiento muy similar al que tiene monitor.

dotnet-counters collect  [-p|--process-id] [--refreshInterval] [counter_list] [--format] [-o|--output]

Las principales diferencias son que collect no muestra nada por consola, sino que lo guarda en el fichero que le indiquemos en output con el formato que le indiquemos en format (csv o json). Por defecto estos valores serán counter.csv en la ruta actual como destino y formato csv.

Si bien es cierto que las principales métricas están cubiertas con los contadores que ofrece built-in, en nuestro caso particular necesitábamos monitorizar otros datos diferentes a mayores de los que ofrece .Net Core.

Creando nuestros propios contadores de rendimiento

Para poder solucionar esa necesidad, el sistema de contadores de rendimiento de .Net Core ofrece la posibilidad de que lo extendamos con nuestros propios contadores. Existen 2 tipos de contadores de rendimiento que podemos utilizar.

La principal diferencia entre ellos es que el primero permite que el valor del contador suba y baje y el segundo mide las subidas durante un periodo de tiempo determinado. Un caso para el primero de ellos es por ejemplo para medir el consumo de CPU del equipo, es un valor que puede ir subiendo y bajando. En cambio, para el segundo, un caso claro puede ser para medir el número de veces por segundo que se realiza una acción.

Cada uno de los contadores además tiene dos ‘sabores’ (por llamarlo de alguna manera). Con el primero de ellos, vamos a ser nosotros mismos lo que manualmente actualicemos el valor. El segundo sabor es que el contador obtenga su valor directamente de una función que le especifiquemos. En el caso de que queramos este segundo sabor, vamos a tener que utilizar PollingCounter en lugar de EventCounter e IncrementingPollingCounter en lugar de IncrementingEventCounter.

Para poder crear los contadores, vamos a necesitar crear nuestro propio EventSource que herede de este, y donde vamos a mantener los contadores:

public class ExampleEventSource : EventSource
{
    private readonly EventCounter _eventCounter;
    private readonly PollingCounter _poolingCounter;
    private readonly IncrementingEventCounter _incrementingCounter;
    private readonly IncrementingPollingCounter _incrementingPollingCounter;
    private ulong _executions;
    public ExampleEventSource() : base("Example.Fixedbuffer")
    {
        _eventCounter = new EventCounter("event-counter", this)
        {
            DisplayUnits = "elements",
            DisplayName = "EventCounter"
        };
        
        _poolingCounter = new PollingCounter("pooling-counter", this, () => _executions)
        {
            DisplayUnits = "elements",
            DisplayName = "PollingCounter"
        };

        _incrementingCounter = new IncrementingEventCounter("incrementing-counter", this)
        {
            DisplayUnits = "elements/s",
            DisplayName = "IncrementingEventCounter",
            DisplayRateTimeScale = TimeSpan.FromSeconds(1)
        };


        _incrementingPollingCounter = new IncrementingPollingCounter("incrementing-pooling-counter", this, () => _executions)
        {
            DisplayUnits = "elements/s",
            DisplayName = "IncrementingPollingCounter",
            DisplayRateTimeScale = TimeSpan.FromSeconds(1)
        };
    }

    public void Increment()
    {
        _executions++;
        _eventCounter.WriteMetric(_executions);
        _incrementingCounter.Increment();
    }
}

Vamos a analizarlos por partes. Lo primero que hacemos es llamar al constructor de la clase base indicándole el nombre del grupo de contadores. Este será el nombre que vamos a tener que indicar en dotnet-counters para poder ver los contadores.

public ExampleEventSource() : base("Example.Fixedbuffer")

Después vamos a crear los contadores que suben y bajan. En ellos vamos a indicarles en el constructor el nombre del contador, el EventSource al que están asociados y algunas opciones para hacer la visualización más clara. Además, en el caso del PollingCounter vamos a indicarle también la función con la que obtendrá el valor.

_eventCounter = new EventCounter("event-counter", this)
{
    DisplayUnits = "elements",
    DisplayName = "EventCounter"
};
        
_poolingCounter = new PollingCounter("pooling-counter", this, () => _executions)
{
    DisplayUnits = "elements",
    DisplayName = "PollingCounter"
};

A continuación, vamos a crear los contadores incrementales. Al igual que en el caso anterior, vamos a indicarle el nombre de contador, así como el EventSource y algunas propiedades de visualización (y en el caso del IncrementingPollingCounter la función para obtener el valor).

_incrementingCounter = new IncrementingEventCounter("incrementing-counter", this)
{
    DisplayUnits = "elements/s",
    DisplayName = "IncrementingEventCounter",
    DisplayRateTimeScale = TimeSpan.FromSeconds(1)
};

_incrementingPollingCounter = new IncrementingPollingCounter("incrementing-pooling-counter", this, () => _executions)
{
    DisplayUnits = "elements/s",
    DisplayName = "IncrementingPollingCounter",
    DisplayRateTimeScale = TimeSpan.FromSeconds(1)
};

Por último, vamos a añadir un método que nos permita incrementar incrementar los contadores desde fuera.

public void Increment()
{
    _executions++;
    _eventCounter.WriteMetric(_executions);
    _incrementingCounter.Increment();
}

Para este ejemplo sencillo, todos los contadores van a incrementar, pero podrían subir o bajar según lo planteado más arriba.

Con nuestro EventSource listo, ya simplemente tenemos que llamar a su método Increment() para incrementar los contadores:

internal class Program
{
    private static void Main(string[] args)
    {
        var exampleEventSource= new ExampleEventSource();

        for (var i = 0; i < 3000; i++)
        {
            exampleEventSource.Increment();
            Thread.Sleep(100);
        }
    }
}

Si ahora ejecutamos el programa y vemos los contadores con el comando

dotnet-counters monitor -p 2124 Example.Fixedbuffer

Podremos ver algo parecido a esto:

[Example.Fixedbuffer]
    EventCounter (elements)                                 148
    IncrementingEventCounter / 1 sec (elements/s)             9
    IncrementingPollingCounter / 1 sec (elements/s)           9
    PollingCounter (elements)                               147

Hay que tener en cuenta que que en este caso los contadores pueden diferir ligeramente cuando son directos y cuando son con una función, ya que en el momento en el que se consulta puede variar la lectura del mismo dato.

Conclusión

Aunque es posible seguir utilizando PerformanceCounter al igual que se ha hecho durante muchos años en la plataforma .Net, con .Net Core las cosas han cambiado para desligarse de trabajar solo sobre Windows. Lo correcto si estás trabajando con .Net Core 3 o superior (sí, incluso en .Net 5 se mantiene el cambio, al menos en la preview actual) lo correcto es utilizar este nuevo modelo de contadores.

No lo hemos tratado en esta entrada para que no sea muy densa y larga, pero al igual que los leemos desde dotnet-counters es perfectamente posible leerlos desde otro código que escribamos. De hecho, en la próxima entrada hablaremos sobre qué hacer para leer esos contadores de rendimiento desde una aplicación en C# que escribamos. Aun así, el código de dotnet-counters es público y está en Github si quieres echarle un ojo.

Si no acabes de ver la utilidad que pueden aportar estos contadores de rendimiento, no hay que quedarse en la simple aplicación. Todos estos pequeños elementos tienen repercusiones más grandes al poder utilizarlos en otros puntos del sistema. Échale un ojo a la charla del gran Eduard Tomas sobre NetCoreConf Track 1 A/B Testing con .NET y AKS donde da un ejemplo de uso muy interesante.

Por último, como es habitual, dejo el código de este ejemplo en Github para que puedas trastear y probar tú mismo a hacer cambios y ver el funcionamiento.

Deja un comentario