Dela via


Självstudie: Minska minnesallokering med ref säkerhet

Prestandajustering för ett .NET-program omfattar ofta två tekniker. Minska först antalet och storleken på heapallokeringar. För det andra kan du minska hur ofta data kopieras. Visual Studio innehåller bra verktyg som hjälper dig att analysera hur ditt program använder minne. När du har fastställt var din app gör onödiga allokeringar gör du ändringar för att minimera dessa allokeringar. Du konverterar class typer till struct typer. Du använder ref säkerhetsfunktioner för att bevara semantik och minimera extra kopiering.

Använd Visual Studio 17.5 för att få bästa möjliga upplevelse med den här självstudien. Det .NET-objektallokeringsverktyg som används för att analysera minnesanvändning är en del av Visual Studio. Du kan använda Visual Studio Code och kommandoraden för att köra programmet och göra alla ändringar. Du kommer dock inte att kunna se analysresultaten för dina ändringar.

Programmet du använder är en simulering av ett IoT-program som övervakar flera sensorer för att avgöra om en inkräktare har gått in i ett hemligt galleri med värdesaker. IoT-sensorerna skickar ständigt data som mäter blandningen av syre (O2) och koldioxid (CO2) i luften. De rapporterar också temperatur och relativ luftfuktighet. Vart och ett av dessa värden varierar något hela tiden. Men när en person kommer in i rummet, förändringen lite mer, och alltid i samma riktning: Syre minskar, Koldioxid ökar, temperatur ökar, liksom relativ luftfuktighet. När sensorerna kombineras för att visa ökningar utlöses inbrottslarmet.

I den här självstudien kör du programmet, mäter minnesallokeringar och förbättrar sedan prestandan genom att minska antalet allokeringar. Källkoden är tillgänglig i exempelwebbläsaren.

Utforska startprogrammet

Ladda ned programmet och kör startexemplet. Startprogrammet fungerar korrekt, men eftersom det allokerar många små objekt med varje mätcykel försämras dess prestanda långsamt när det körs över tid.

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

Många rader har tagits bort.

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

Du kan utforska koden för att lära dig hur programmet fungerar. Huvudprogrammet kör simuleringen. När du trycker <Enter>skapar det ett rum och samlar in några inledande baslinjedata:

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;
    });

När baslinjedata har upprättats kör den simuleringen i rummet, där en slumptalsgenerator avgör om en inkräktare har kommit in i rummet:

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;
    });

Andra typer innehåller mätningarna, en bounced mätning som är genomsnittet av de senaste 50 mätningarna och genomsnittet av alla mätningar som gjorts.

Kör sedan programmet med hjälp av .NET-objektallokeringsverktyget. Kontrollera att du använder bygget Release , inte bygget Debug . Öppna Prestandaprofileringmenyn Felsök. Kontrollera alternativet .NET-objektallokeringsspårning, men inget annat. Kör programmet till slutförande. Profileraren mäter objektallokeringar och rapporter om allokeringar och skräpinsamlingscykler. Du bör se ett diagram som liknar följande bild:

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

Föregående diagram visar att arbete för att minimera allokeringar ger prestandafördelar. Du ser ett sawtooth-mönster i diagrammet med levande objekt. Det talar om för dig att många objekt skapas som snabbt blir skräp. De samlas senare in, som visas i objektdeltagrafen. De nedåtgående röda staplarna anger en skräpinsamlingscykel.

Titta sedan på fliken Allokeringar under graferna. Den här tabellen visar vilka typer som är mest allokerade:

Chart that shows which types are allocated most frequently.

Typen System.String står för flest allokeringar. Den viktigaste uppgiften bör vara att minimera frekvensen för strängallokeringar. Det här programmet skriver ut många formaterade utdata till konsolen hela tiden. För den här simuleringen vill vi behålla meddelanden, så vi koncentrerar oss på nästa två rader: SensorMeasurement typen och IntruderRisk typen.

Dubbelklicka på raden SensorMeasurement . Du kan se att alla allokeringar sker i static metoden SensorMeasurement.TakeMeasurement. Du kan se metoden i följande kodfragment:

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
    };
}

Varje mått allokerar ett nytt SensorMeasurement objekt, vilket är en class typ. Varje SensorMeasurement skapad orsakar en heap-allokering.

Ändra klasser till structs

Följande kod visar den första deklarationen av 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}
            """;
}

Typen skapades ursprungligen som en class eftersom den innehåller flera double mått. Den är större än du vill kopiera i heta sökvägar. Detta beslut innebar dock ett stort antal allokeringar. Ändra typen från en class till en struct.

Om du ändrar från en class till struct introduceras några kompilatorfel eftersom den ursprungliga koden använde null referenskontroller på några få punkter. Den första är i DebounceMeasurement klassen i AddMeasurement -metoden:

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);
}

Typen DebounceMeasurement innehåller en matris med 50 mått. Avläsningarna för en sensor rapporteras som medelvärdet av de senaste 50 mätningarna. Det minskar bruset i avläsningarna. Innan hela 50 avläsningar har gjorts är nulldessa värden . Koden söker efter referens för null att rapportera rätt genomsnitt vid systemstart. När du SensorMeasurement har ändrat typen till en struct måste du använda ett annat test. Typen SensorMeasurement innehåller en string för rumsidentifieraren, så att du kan använda testet i stället:

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

De andra tre kompilatorfelen finns i metoden som upprepade gånger utför mätningar i ett rum:

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));
}

I startmetoden är den lokala variabeln för SensorMeasurement en nullbar referens:

SensorMeasurement? measure = default;

Nu när SensorMeasurement är en struct i stället för en class, är nullable en nullbar värdetyp. Du kan ändra deklarationen till en värdetyp för att åtgärda återstående kompilatorfel:

SensorMeasurement measure = default;

Nu när kompileringsfelen har åtgärdats bör du undersöka koden för att se till att semantiken inte har ändrats. Eftersom struct typer skickas efter värde visas inte ändringar som gjorts i metodparametrar när metoden har returnerats.

Viktigt!

Om du ändrar en typ från en class till en struct kan du ändra programmets semantik. När en class typ skickas till en metod görs alla mutationer som görs i metoden till argumentet. När en struct typ skickas till en metod, och mutationer som görs i metoden görs till en kopia av argumentet. Det innebär att alla metoder som ändrar argumenten avsiktligt bör uppdateras för att använda ref modifieraren för alla argumenttyper som du har ändrat från en class till en struct.

Typen SensorMeasurement innehåller inga metoder som ändrar tillstånd, så det är inte ett problem i det här exemplet. Du kan bevisa det genom att lägga till readonly modifieraren i struct:SensorMeasurement

public readonly struct SensorMeasurement

Kompilatorn framtvingar readonly structens SensorMeasurement natur. Om kontrollen av koden missade någon metod som ändrade tillstånd skulle kompilatorn berätta för dig. Appen byggs fortfarande utan fel, så den här typen är readonly. readonly Om du lägger till modifieraren när du ändrar en typ från en class till en struct kan du hitta medlemmar som ändrar tillståndet för struct.

Undvik att göra kopior

Du har tagit bort ett stort antal onödiga allokeringar från din app. Typen SensorMeasurement visas inte i tabellen någonstans.

Nu utför den extra arbete med att kopiera strukturen varje gång den SensorMeasurement används som en parameter eller ett returvärde. Structen SensorMeasurement innehåller fyra dubbelgångare, en DateTime och en string. Den strukturen är mätbart större än en referens. Nu ska vi lägga till ref modifierarna eller in till platser där SensorMeasurement typen används.

Nästa steg är att hitta metoder som returnerar en mätning eller ta en mätning som ett argument och använda referenser där det är möjligt. Starta i structen SensorMeasurement . Den statiska TakeMeasurement metoden skapar och returnerar en ny SensorMeasurement:

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
    };
}

Vi lämnar den här som den är och returnerar efter värde. Om du försökte returnera med reffår du ett kompilatorfel. Du kan inte returnera en ref till en ny struktur som skapats lokalt i metoden. Utformningen av den oföränderliga structen innebär att du bara kan ange måttvärdena vid konstruktion. Den här metoden måste skapa en ny mått struct.

Låt oss titta igen på DebounceMeasurement.AddMeasurement. Du bör lägga till in modifieraren i parametern 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);
}

Det sparar en kopieringsåtgärd. Parametern in är en referens till kopian som redan skapats av anroparen. Du kan också spara en kopia med TakeMeasurement metoden i Room typen . Den här metoden illustrerar hur kompilatorn ger säkerhet när du skickar argument av ref. Den första TakeMeasurement metoden i Room typen tar argumentet Func<SensorMeasurement, bool>. Om du försöker lägga in till eller ref modifieraren i den deklarationen rapporterar kompilatorn ett fel. Du kan inte skicka ett ref argument till ett lambda-uttryck. Kompilatorn kan inte garantera att det anropade uttrycket inte kopierar referensen. Om lambda-uttrycket avbildar referensen kan referensen ha en livslängd som är längre än det värde som den refererar till. Åtkomst till den utanför referensens säkra kontext skulle leda till minnesskada. Säkerhetsreglerna ref tillåter det inte. Du kan läsa mer i översikten över referenssäkerhetsfunktioner.

Bevara semantik

De sista uppsättningarna med ändringar har ingen större inverkan på programmets prestanda eftersom typerna inte skapas i heta sökvägar. Dessa ändringar illustrerar några av de andra tekniker som du använder i prestandajusteringen. Nu ska vi ta en titt på den inledande Room klassen:

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));
    }
}

Den här typen innehåller flera egenskaper. Vissa är class typer. Att skapa ett Room objekt omfattar flera allokeringar. En för Room sig själv och en för var och en av medlemmarna av en class typ som den innehåller. Du kan konvertera två av dessa egenskaper från class typer till struct typer: typerna DebounceMeasurement och AverageMeasurement . Nu ska vi gå igenom den omvandlingen med båda typerna.

DebounceMeasurement Ändra typen från en class till struct. Det introducerar ett kompilatorfel CS8983: A 'struct' with field initializers must include an explicitly declared constructor. Du kan åtgärda detta genom att lägga till en tom parameterlös konstruktor:

public DebounceMeasurement() { }

Du kan läsa mer om det här kravet i språkreferensartikeln om structs.

Åsidosättningen Object.ToString() ändrar inte något av värdena för structen. Du kan lägga till modifieraren i readonly metoddeklarationen. Typen DebounceMeasurement är föränderlig, så du måste vara noga med att ändringar inte påverkar kopior som ignoreras. Metoden AddMeasurement ändrar objektets tillstånd. Den anropas från Room klassen i TakeMeasurements -metoden. Du vill att ändringarna ska sparas efter att du har anropat metoden. Du kan ändra Room.Debounce egenskapen för att returnera en referens till en enda instans av DebounceMeasurement typen:

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

Det finns några ändringar i föregående exempel. För det första är egenskapen en skrivskyddad egenskap som returnerar en skrivskyddad referens till den instans som ägs av det här rummet. Det backas nu upp av ett deklarerat fält som initieras när Room objektet instansieras. När du har gjort de här ändringarna uppdaterar du implementeringen av AddMeasurement metoden. Det använder det privata bakgrundsfältet , debounceinte readonly-egenskapen Debounce. På så sätt sker ändringarna på den enskilda instans som skapades under initieringen.

Samma teknik fungerar med egenskapen Average . Först ändrar AverageMeasurement du typen från en class till en structoch lägger till readonly modifieraren på ToString metoden:

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}
        """;
}

Sedan ändrar Room du klassen efter samma teknik som du använde för egenskapen Debounce . Egenskapen Average returnerar ett readonly ref till det privata fältet för genomsnittlig mätning. Metoden AddMeasurement ändrar de interna fälten.

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

Undvik boxning

Det finns en sista ändring för att förbättra prestanda. Huvudprogrammet är utskriftsstatistik för rummet, inklusive riskbedömningen:

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

Anropet till de genererade ToString rutorna innehåller uppräkningsvärdet. Du kan undvika det genom att skriva en åsidosättning i Room klassen som formaterar strängen baserat på värdet för den uppskattade risken:

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()}";

Ändra sedan koden i huvudprogrammet för att anropa den här nya ToString metoden:

Console.WriteLine(room.ToString());

Kör appen med profileraren och titta på den uppdaterade tabellen för allokeringar.

Allocation graph for running the intruder alert app after modifications.

Du har tagit bort många allokeringar och gett din app en prestandaökning.

Använda referenssäkerhet i ditt program

Dessa tekniker är prestandajustering på låg nivå. De kan öka prestandan i ditt program när de tillämpas på frekventa sökvägar och när du har mätt effekten före och efter ändringarna. I de flesta fall är cykeln du följer:

  • Mät allokeringar: Bestäm vilka typer som tilldelas mest och när du kan minska heap-allokeringarna.
  • Konvertera klassen till struct: Många gånger kan typer konverteras från en class till en struct. Din app använder stackutrymme i stället för att göra heap-allokeringar.
  • Bevara semantik: Om du konverterar en class till en struct kan semantiken påverkas för parametrar och returvärden. Alla metoder som ändrar dess parametrar bör nu markera dessa parametrar med ref modifieraren. Det säkerställer att ändringarna görs i rätt objekt. På samma sätt, om en egenskap eller ett metodreturvärde ska ändras av anroparen, bör den returen ref markeras med modifieraren.
  • Undvik kopior: När du skickar en stor struct som en parameter kan du markera parametern in med modifieraren. Du kan skicka en referens i färre byte och se till att metoden inte ändrar det ursprungliga värdet. Du kan också returnera värden genom readonly ref att returnera en referens som inte kan ändras.

Med hjälp av dessa tekniker kan du förbättra prestanda i heta sökvägar i koden.