Tutorial: Reducción de asignaciones de memoria con seguridad ref

A menudo, el ajuste del rendimiento de una aplicación .NET implica dos técnicas. Primero, la reducción del número y el tamaño de las asignaciones del montón. Segundo, la reducción de la frecuencia con la que se copian los datos. Visual Studio proporciona excelentes herramientas que ayudan a analizar cómo usa la aplicación la memoria. Una vez que haya determinado dónde realiza la aplicación asignaciones innecesarias, realice cambios para minimizar esas asignaciones. Los tipos class se convierten en tipos struct. Use características de seguridad ref para conservar la semántica y minimizar la copia adicional.

Use Visual Studio 17.5 para obtener la mejor experiencia con este tutorial. La herramienta de asignación de objetos .NET que se usa para analizar el uso de memoria forma parte de Visual Studio. Puede usar Visual Studio Code y la línea de comandos para ejecutar la aplicación y realizar todos los cambios. Pero no podrá ver los resultados del análisis de los cambios.

La aplicación que usará es una simulación de una aplicación de IoT que supervisa varios sensores para determinar si un intruso ha introducido una galería secreta con objetos valiosos. Los sensores de IoT envían constantemente datos que miden la combinación de oxígeno (O2) y dióxido de carbono (CO2) en el aire. También informan de la temperatura y la humedad relativa. Cada uno de estos valores fluctúa ligeramente todo el tiempo. Pero cuando una persona entra en la habitación, cambia un poco más y siempre en la misma dirección: el oxígeno disminuye, el dióxido de carbono aumenta, y tanto la temperatura como la humedad relativa aumentan también. Cuando los sensores se combinan para mostrar aumentos, se desencadena la alarma de intruso.

En este tutorial, ejecutará la aplicación, tomará medidas de las asignaciones de memoria y, después, mejorará el rendimiento reduciendo el número de asignaciones. El código fuente está disponible en el explorador de ejemplos.

Exploración de la aplicación de inicio

Descargue la aplicación y ejecute el ejemplo de inicio. La aplicación de inicio funciona correctamente, pero como asigna muchos objetos pequeños con cada ciclo de medida, su rendimiento se degrada lentamente según se ejecuta con el tiempo.

Press <return> to start simulation

Debounced measurements:
    Temp:      67.332
    Humidity:  41.077%
    Oxygen:    21.097%
    CO2 (ppm): 404.906
Average measurements:
    Temp:      67.332
    Humidity:  41.077%
    Oxygen:    21.097%
    CO2 (ppm): 404.906

Debounced measurements:
    Temp:      67.349
    Humidity:  46.605%
    Oxygen:    20.998%
    CO2 (ppm): 408.707
Average measurements:
    Temp:      67.349
    Humidity:  46.605%
    Oxygen:    20.998%
    CO2 (ppm): 408.707

Se han quitado muchas filas.

Debounced measurements:
    Temp:      67.597
    Humidity:  46.543%
    Oxygen:    19.021%
    CO2 (ppm): 429.149
Average measurements:
    Temp:      67.568
    Humidity:  45.684%
    Oxygen:    19.631%
    CO2 (ppm): 423.498
Current intruders: 3
Calculated intruder risk: High

Debounced measurements:
    Temp:      67.602
    Humidity:  46.835%
    Oxygen:    19.003%
    CO2 (ppm): 429.393
Average measurements:
    Temp:      67.568
    Humidity:  45.684%
    Oxygen:    19.631%
    CO2 (ppm): 423.498
Current intruders: 3
Calculated intruder risk: High

Puede explorar el código para obtener información sobre cómo funciona la aplicación. El programa principal ejecuta la simulación. Después de presionar <Enter>, crea una sala y recopila algunos datos iniciales de línea de base:

Console.WriteLine("Press <return> to start simulation");
Console.ReadLine();
var room = new Room("gallery");
var r = new Random();

int counter = 0;

room.TakeMeasurements(
    m =>
    {
        Console.WriteLine(room.Debounce);
        Console.WriteLine(room.Average);
        Console.WriteLine();
        counter++;
        return counter < 20000;
    });

Una vez que se han establecido los datos de línea de base, ejecuta la simulación en la sala, donde un generador de números aleatorios determina si un intruso ha entrado en la sala:

counter = 0;
room.TakeMeasurements(
    m =>
    {
        Console.WriteLine(room.Debounce);
        Console.WriteLine(room.Average);
        room.Intruders += (room.Intruders, r.Next(5)) switch
        {
            ( > 0, 0) => -1,
            ( < 3, 1) => 1,
            _ => 0
        };

        Console.WriteLine($"Current intruders: {room.Intruders}");
        Console.WriteLine($"Calculated intruder risk: {room.RiskStatus}");
        Console.WriteLine();
        counter++;
        return counter < 200000;
    });

Otros tipos contienen las medidas, una medida sin rebotes, que es el promedio de las últimas 50 medidas, y el promedio de todas las medidas tomadas.

Ahora, ejecute la aplicación mediante la herramienta de asignación de objetos de .NET. Asegúrese de usar la compilación Release, no la Debug. En el menú Depurar, abra el Generador de perfiles de rendimiento. Active la opción Seguimiento de asignación de objetos de .NET, pero nada más. Ejecute la aplicación hasta su finalización. El generador de perfiles mide las asignaciones de objetos e informa sobre las asignaciones y los ciclos de recolección de elementos no utilizados. Debería ver un gráfico similar al de la imagen siguiente:

Allocation graph for running the intruder alert app before any optimizations.

En el gráfico anterior se muestra que trabajar para minimizar las asignaciones proporcionará ventajas de rendimiento. Verá un patrón de sierra en el gráfico de objetos activos. Esto le indica que se crean numerosos objetos que se convierten rápidamente en elementos no utilizados. Más adelante se recopilan, como se muestra en el gráfico delta del objeto. Las barras rojas descendentes indican un ciclo de recolección de elementos no utilizados.

Ahora, examine la pestaña Asignaciones debajo de los gráficos. En esta tabla se muestran los tipos que más se asignan:

Chart that shows which types are allocated most frequently.

El tipo System.String cuenta con la mayoría de las asignaciones. La tarea más importante debe ser minimizar la frecuencia de las asignaciones de cadenas. Esta aplicación imprime numerosas salidas con formato en la consola de forma constante. Para esta simulación, queremos mantener los mensajes, por lo que nos centraremos en las dos filas siguientes: los tipos SensorMeasurement y IntruderRisk.

Haga doble clic en la línea SensorMeasurement. Puede ver que todas las asignaciones tienen lugar en el método staticSensorMeasurement.TakeMeasurement. Puede ver el método en el fragmento de código siguiente:

public static SensorMeasurement TakeMeasurement(string room, int intruders)
{
    return new SensorMeasurement
    {
        CO2 = (CO2Concentration + intruders * 10) + (20 * generator.NextDouble() - 10.0),
        O2 = (O2Concentration - intruders * 0.01) + (0.005 * generator.NextDouble() - 0.0025),
        Temperature = (TemperatureSetting + intruders * 0.05) + (0.5 * generator.NextDouble() - 0.25),
        Humidity = (HumiditySetting + intruders * 0.005) + (0.20 * generator.NextDouble() - 0.10),
        Room = room,
        TimeRecorded = DateTime.Now
    };
}

Cada medida asigna un objeto SensorMeasurement nuevo, que es tipo class. Cada SensorMeasurement creado provoca una asignación de montón.

Cambio de clases a estructuras

El código siguiente muestra la declaración inicial de SensorMeasurement:

public class SensorMeasurement
{
    private static readonly Random generator = new Random();

    public static SensorMeasurement TakeMeasurement(string room, int intruders)
    {
        return new SensorMeasurement
        {
            CO2 = (CO2Concentration + intruders * 10) + (20 * generator.NextDouble() - 10.0),
            O2 = (O2Concentration - intruders * 0.01) + (0.005 * generator.NextDouble() - 0.0025),
            Temperature = (TemperatureSetting + intruders * 0.05) + (0.5 * generator.NextDouble() - 0.25),
            Humidity = (HumiditySetting + intruders * 0.005) + (0.20 * generator.NextDouble() - 0.10),
            Room = room,
            TimeRecorded = DateTime.Now
        };
    }

    private const double CO2Concentration = 409.8; // increases with people.
    private const double O2Concentration = 0.2100; // decreases
    private const double TemperatureSetting = 67.5; // increases
    private const double HumiditySetting = 0.4500; // increases

    public required double CO2 { get; init; }
    public required double O2 { get; init; }
    public required double Temperature { get; init; }
    public required double Humidity { get; init; }
    public required string Room { get; init; }
    public required DateTime TimeRecorded { get; init; }

    public override string ToString() => $"""
            Room: {Room} at {TimeRecorded}:
                Temp:      {Temperature:F3}
                Humidity:  {Humidity:P3}
                Oxygen:    {O2:P3}
                CO2 (ppm): {CO2:F3}
            """;
}

El tipo se creó originalmente como class porque contiene numerosas medidas double. Es mayor de lo que desea copiar en rutas de acceso activas. Pero esa decisión implicaba un gran número de asignaciones. Cambie el tipo de class a struct.

Al cambiar de class a struct, se presentan algunos errores del compilador porque el código original usó comprobaciones de referencia null en algunos puntos. La primera está en la clase DebounceMeasurement, en el método AddMeasurement:

public void AddMeasurement(SensorMeasurement datum)
{
    int index = totalMeasurements % debounceSize;
    recentMeasurements[index] = datum;
    totalMeasurements++;
    double sumCO2 = 0;
    double sumO2 = 0;
    double sumTemp = 0;
    double sumHumidity = 0;
    for (int i = 0; i < debounceSize; i++)
    {
        if (recentMeasurements[i] is not null)
        {
            sumCO2 += recentMeasurements[i].CO2;
            sumO2+= recentMeasurements[i].O2;
            sumTemp+= recentMeasurements[i].Temperature;
            sumHumidity += recentMeasurements[i].Humidity;
        }
    }
    O2 = sumO2 / ((totalMeasurements > debounceSize) ? debounceSize : totalMeasurements);
    CO2 = sumCO2 / ((totalMeasurements > debounceSize) ? debounceSize : totalMeasurements);
    Temperature = sumTemp / ((totalMeasurements > debounceSize) ? debounceSize : totalMeasurements);
    Humidity = sumHumidity / ((totalMeasurements > debounceSize) ? debounceSize : totalMeasurements);
}

El tipo DebounceMeasurement contiene una matriz de 50 medidas. Las lecturas para un sensor se informan como el promedio de las últimas 50 medidas. Esto reduce el ruido en las lecturas. Antes de tomar 50 lecturas completas, estos valores son null. El código comprueba si hay una referencia null para informar del promedio correcto en el startup. Después de cambiar el tipo SensorMeasurement a una estructura, debe usar una prueba diferente. El tipo SensorMeasurement incluye string para el identificador de sala, por lo que puede usar esa prueba en su lugar:

if (recentMeasurements[i].Room is not null)

Los otros tres errores del compilador están todos en el método que toma repetidamente medidas en una sala:

public void TakeMeasurements(Func<SensorMeasurement, bool> MeasurementHandler)
{
    SensorMeasurement? measure = default;
    do {
        measure = SensorMeasurement.TakeMeasurement(Name, Intruders);
        Average.AddMeasurement(measure);
        Debounce.AddMeasurement(measure);
    } while (MeasurementHandler(measure));
}

En el método inicial, la variable local de SensorMeasurement es una referencia que admite un valor NULL:

SensorMeasurement? measure = default;

Ahora que SensorMeasurement es struct en lugar de class, el que admite un valor NULL es un tipo de valor que admite un valor NULL. Puede cambiar la declaración a un tipo de valor para corregir los errores restantes del compilador:

SensorMeasurement measure = default;

Ahora que se han solucionado los errores del compilador, debe examinar el código para asegurarse de que la semántica no ha cambiado. Dado que los tipos struct se pasan por valor, las modificaciones realizadas en los parámetros del método no son visibles después de que el método devuelva.

Importante

Cambiar un tipo de class a struct puede cambiar la semántica del programa. Cuando un tipo class se pasa a un método, las mutaciones realizadas en el método se realizan en el argumento. Cuando un tipo struct se pasa a un método y las mutaciones realizadas en el método se realizan en una copia del argumento. Esto significa que cualquier método que modifique sus argumentos por diseño debe actualizarse para usar el modificador ref en cualquier tipo de argumento que haya cambiado de class a struct.

El tipo SensorMeasurement no incluye ningún método que cambie el estado, por lo que no es un problema en este ejemplo. Puede demostrarlo agregando el modificador readonly a la estructura SensorMeasurement:

public readonly struct SensorMeasurement

El compilador aplica la naturaleza readonly de struct SensorMeasurement. Si la inspección del código pasara por alto algún método que modifica el estado, el compilador le avisaría. La aplicación sigue compilando sin errores, por lo que este tipo es readonly. Agregar el modificador readonly al cambiar un tipo de class a struct puede ayudarle a encontrar miembros que modifiquen el estado de struct.

Evitación de la realización de copias

Ha quitado un gran número de asignaciones innecesarias de la aplicación. El tipo SensorMeasurement no aparece en ningún lugar de la tabla.

Ahora, está realizando un trabajo adicional al copiar la estructura SensorMeasurement cada vez que se usa como parámetro o valor devuelto. La estructura SensorMeasurement contiene cuatro dobles, una unidad de DateTime y otra de string. Esa estructura es considerablemente mayor que una referencia. Vamos a agregar los modificadores ref o in a lugares donde se usa el tipo SensorMeasurement.

El siguiente paso consiste en buscar métodos que devuelven una medida o toman una medida como argumento y usan referencias siempre que sea posible. Comience en la estructura SensorMeasurement. El método estático TakeMeasurement crea y devuelve una medida SensorMeasurement nueva:

public static SensorMeasurement TakeMeasurement(string room, int intruders)
{
    return new SensorMeasurement
    {
        CO2 = (CO2Concentration + intruders * 10) + (20 * generator.NextDouble() - 10.0),
        O2 = (O2Concentration - intruders * 0.01) + (0.005 * generator.NextDouble() - 0.0025),
        Temperature = (TemperatureSetting + intruders * 0.05) + (0.5 * generator.NextDouble() - 0.25),
        Humidity = (HumiditySetting + intruders * 0.005) + (0.20 * generator.NextDouble() - 0.10),
        Room = room,
        TimeRecorded = DateTime.Now
    };
}

Lo dejaremos tal como está, devolviendo por valor. Si intentó devolver por ref, obtendría un error del compilador. No se puede devolver un elemento ref a una estructura nueva creada localmente en el método. El diseño de la estructura inmutable implica que solo se pueden establecer los valores de la medida en la construcción. Este método debe crear una estructura de medida nueva.

Volvamos a echar un vistazo a DebounceMeasurement.AddMeasurement. Debe agregar el modificador in al parámetro measurement:

public void AddMeasurement(in SensorMeasurement datum)
{
    int index = totalMeasurements % debounceSize;
    recentMeasurements[index] = datum;
    totalMeasurements++;
    double sumCO2 = 0;
    double sumO2 = 0;
    double sumTemp = 0;
    double sumHumidity = 0;
    for (int i = 0; i < debounceSize; i++)
    {
        if (recentMeasurements[i].Room is not null)
        {
            sumCO2 += recentMeasurements[i].CO2;
            sumO2+= recentMeasurements[i].O2;
            sumTemp+= recentMeasurements[i].Temperature;
            sumHumidity += recentMeasurements[i].Humidity;
        }
    }
    O2 = sumO2 / ((totalMeasurements > debounceSize) ? debounceSize : totalMeasurements);
    CO2 = sumCO2 / ((totalMeasurements > debounceSize) ? debounceSize : totalMeasurements);
    Temperature = sumTemp / ((totalMeasurements > debounceSize) ? debounceSize : totalMeasurements);
    Humidity = sumHumidity / ((totalMeasurements > debounceSize) ? debounceSize : totalMeasurements);
}

Eso guarda una operación de copia. El parámetro in es una referencia a la copia ya creada por el autor de la llamada. También puede guardar una copia con el método TakeMeasurement en el tipo Room. Este método muestra cómo el compilador proporciona seguridad al pasar argumentos por ref. El método inicial TakeMeasurement del tipo Room toma un argumento de Func<SensorMeasurement, bool>. Si intenta agregar el modificador in o ref a esa declaración, el compilador notifica un error. No se puede pasar un argumento ref a una expresión lambda. El compilador no puede garantizar que la expresión llamada no copie la referencia. Si la expresión lambda captura la referencia, esta podría tener una duración mayor que el valor al que hace referencia. Acceder a él fuera de su contexto seguro ref provocaría daños en la memoria. Las reglas de seguridad ref no lo permiten. Puede obtener más información en la introducción a las características de seguridad ref.

Conservación de la semántica

Los conjuntos finales de cambios no tendrán un impacto importante en el rendimiento de esta aplicación porque los tipos no se crean en rutas de acceso activas. Estos cambios muestran algunas de las otras técnicas que usaría en el ajuste del rendimiento. Echemos un vistazo a la clase inicial Room:

public class Room
{
    public AverageMeasurement Average { get; } = new ();
    public DebounceMeasurement Debounce { get; } = new ();
    public string Name { get; }

    public IntruderRisk RiskStatus
    {
        get
        {
            var CO2Variance = (Debounce.CO2 - Average.CO2) > 10.0 / 4;
            var O2Variance = (Average.O2 - Debounce.O2) > 0.005 / 4.0;
            var TempVariance = (Debounce.Temperature - Average.Temperature) > 0.05 / 4.0;
            var HumidityVariance = (Debounce.Humidity - Average.Humidity) > 0.20 / 4;
            IntruderRisk risk = IntruderRisk.None;
            if (CO2Variance) { risk++; }
            if (O2Variance) { risk++; }
            if (TempVariance) { risk++; }
            if (HumidityVariance) { risk++; }
            return risk;
        }
    }

    public int Intruders { get; set; }

    
    public Room(string name)
    {
        Name = name;
    }

    public void TakeMeasurements(Func<SensorMeasurement, bool> MeasurementHandler)
    {
        SensorMeasurement? measure = default;
        do {
            measure = SensorMeasurement.TakeMeasurement(Name, Intruders);
            Average.AddMeasurement(measure);
            Debounce.AddMeasurement(measure);
        } while (MeasurementHandler(measure));
    }
}

Este tipo contiene varias propiedades. Algunos son tipos class. La creación de un objeto Room implica varias asignaciones. Uno para el propio objeto Room y otro para cada uno de los miembros de tipo class que contiene. Puede convertir dos de estas propiedades de tipos class a struct: los tipos DebounceMeasurement y AverageMeasurement. Vamos a trabajar con esa transformación con ambos tipos.

Cambie el tipo DebounceMeasurement de class a struct. Esto introduce un error del compilador CS8983: A 'struct' with field initializers must include an explicitly declared constructor. Para corregirlo, agregue un constructor sin parámetros vacío:

public DebounceMeasurement() { }

Puede obtener más información sobre este requisito en el artículo de referencia del lenguaje sobre estructuras.

La invalidación Object.ToString() no modifica ninguno de los valores de la estructura. Puede agregar el modificador readonly a esa declaración de método. El tipo DebounceMeasurement es mutable, por lo que deberá evitar que las modificaciones afecten a las copias que se descartan. El método AddMeasurement modifica el estado del objeto. Se llama desde la clase Room, en el método TakeMeasurements. Quiere que esos cambios se conserven después de llamar al método. Puede cambiar la propiedad Room.Debounce para devolver una referencia a una sola instancia del tipo DebounceMeasurement:

private DebounceMeasurement debounce = new();
public ref readonly DebounceMeasurement Debounce { get { return ref debounce; } }

Hay algunos cambios en el ejemplo anterior. En primer lugar, la propiedad es una propiedad readonly que devuelve una referencia de solo lectura a la instancia de propiedad de esta sala. Ahora está respaldado por un campo declarado que se inicializa cuando se crea una instancia del objeto Room. Después de realizar estos cambios, actualizará la implementación del método AddMeasurement. Usa el campo de respaldo privado, debounce, no la propiedad readonly Debounce. De este modo, los cambios tienen lugar en la única instancia creada durante la inicialización.

La misma técnica funciona con la propiedad Average. Primero, modifique el tipo AverageMeasurement de class a struct y agregue el modificador readonly en el método ToString:

namespace IntruderAlert;

public struct AverageMeasurement
{
    private double sumCO2 = 0;
    private double sumO2 = 0;
    private double sumTemperature = 0;
    private double sumHumidity = 0;
    private int totalMeasurements = 0;

    public AverageMeasurement() { }

    public readonly double CO2 => sumCO2 / totalMeasurements;
    public readonly double O2 => sumO2 / totalMeasurements;
    public readonly double Temperature => sumTemperature / totalMeasurements;
    public readonly double Humidity => sumHumidity / totalMeasurements;

    public void AddMeasurement(in SensorMeasurement datum)
    {
        totalMeasurements++;
        sumCO2 += datum.CO2;
        sumO2 += datum.O2;
        sumTemperature += datum.Temperature;
        sumHumidity+= datum.Humidity;
    }

    public readonly override string ToString() => $"""
        Average measurements:
            Temp:      {Temperature:F3}
            Humidity:  {Humidity:P3}
            Oxygen:    {O2:P3}
            CO2 (ppm): {CO2:F3}
        """;
}

Después, modifique la clase Room siguiendo la misma técnica que usó para la propiedad Debounce. La propiedad Average devuelve un objeto readonly ref al campo privado para la medición promedio. El método AddMeasurement modifica los campos internos.

private AverageMeasurement average = new();
public  ref readonly AverageMeasurement Average { get { return ref average; } }

Evasión de la conversión boxing

Hay un último cambio para mejorar el rendimiento. El programa principal está imprimiendo estadísticas para la sala, incluida la evaluación de riesgos:

Console.WriteLine($"Current intruders: {room.Intruders}");
Console.WriteLine($"Calculated intruder risk: {room.RiskStatus}");

La llamada a los cuadros ToString generados establece el valor de enumeración. Puede evitarlo escribiendo una invalidación en la clase Room que da formato a la cadena en función del valor de riesgo estimado:

public override string ToString() =>
    $"Calculated intruder risk: {RiskStatus switch
    {
        IntruderRisk.None => "None",
        IntruderRisk.Low => "Low",
        IntruderRisk.Medium => "Medium",
        IntruderRisk.High => "High",
        IntruderRisk.Extreme => "Extreme",
        _ => "Error!"
    }}, Current intruders: {Intruders.ToString()}";

Después, modifique el código del programa principal para llamar a este método ToString nuevo:

Console.WriteLine(room.ToString());

Ejecute la aplicación con el generador de perfiles y examine la tabla actualizada para las asignaciones.

Allocation graph for running the intruder alert app after modifications.

Ha quitado numerosas asignaciones y ha proporcionado a la aplicación un aumento del rendimiento.

Uso de la seguridad ref en la aplicación

Estas técnicas son un ajuste de rendimiento de bajo nivel. Pueden aumentar el rendimiento de la aplicación cuando se aplican a las rutas de acceso activas y cuando se ha medido el impacto antes y después de los cambios. En la mayoría de los casos, el ciclo que seguirá es el siguiente:

  • Asignaciones de medida: determine qué tipos se asignan más y cuándo puede reducir las asignaciones del montón.
  • Convertir la clase en estructura: muchas veces, los tipos se pueden convertir de class a struct. La aplicación usa espacio de pila en lugar de realizar asignaciones de montón.
  • Conservar la semántica: la conversión de class a struct puede afectar a la semántica de los parámetros y los valores devueltos. Cualquier método que modifique sus parámetros ahora debe marcarlos con el modificador ref. Esto garantiza que las modificaciones se realizan en el objeto correcto. Del mismo modo, si el autor de la llamada debe modificar una propiedad o un valor devuelto de método, dicho valor devuelto debe marcarse con el modificador ref.
  • Evitar copias: cuando se pasa una estructura grande como parámetro, puede marcarlo con el modificador in. Puede pasar una referencia en menos bytes y asegurarse de que el método no modifica el valor original. También puede devolver valores con readonly ref para devolver una referencia que no se pueda modificar.

Con estas técnicas, puede mejorar el rendimiento en las rutas de acceso activas del código.