Поделиться через


Руководство по сокращению выделения памяти с помощью ref защищённости

Часто настройка производительности для приложения .NET включает два метода. Во-первых, уменьшите количество и размер выделений памяти в куче. Во-вторых, уменьшите частоту копирования данных. Visual Studio предоставляет отличные средства , которые помогают анализировать использование памяти приложения. Когда вы определите, где приложение делает ненужные выделения, вы вносите изменения, чтобы свести к минимуму эти выделения. Вы преобразуете типы class в типы struct. Вы используете refфункции безопасности для сохранения семантики и минимизации дополнительных копий.

Используйте Visual Studio 17.5 для получения наилучшего опыта с этим учебником. Средство выделения объектов .NET, используемое для анализа использования памяти, является частью Visual Studio. Вы можете использовать Visual Studio Code и командную строку для запуска приложения и внесения всех изменений. Однако вы не сможете просмотреть результаты анализа изменений.

Приложение, которое вы будете использовать, — это симуляция IoT-приложения, которое отслеживает несколько датчиков, чтобы определить, вошел ли злоумышленник в секретную галерею с ценными экспонатами. Датчики Интернета вещей постоянно отправляют данные, которые измеряют сочетание кислорода (O2) и углекислого газа (CO2) в воздухе. Они также сообщают о температуре и относительной влажности. Каждое из этих значений изменяется немного все время. Однако, когда человек входит в комнату, изменение немного больше, и всегда в том же направлении: кислород уменьшается, углекислый газ увеличивается, температура увеличивается, как и относительная влажность. Когда показания датчиков увеличиваются, срабатывает сигнализация о вторжении.

В этом руководстве вы запустите приложение, выполните измерения по выделению памяти, а затем улучшите производительность, уменьшая количество выделений. Исходный код доступен в браузере примеров.

Изучение начального приложения

Скачайте приложение и запустите начальный пример. Начальное приложение работает правильно, но так как оно выделяет множество небольших объектов с каждым циклом измерения, его производительность медленно снижается по мере выполнения с течением времени.

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

Многие строки удалены.

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

Вы можете изучить код, чтобы узнать, как работает приложение. Основная программа выполняет имитацию. После нажатия <Enter>клавиши он создает комнату и собирает некоторые начальные базовые данные:

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

После установки базовых данных он запускает имитацию в комнате, где генератор случайных чисел определяет, вошел ли злоумышленник в комнату:

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

Другие типы содержат измерения, устраненное дребезжание, среднее значение последних 50 измерений, а также среднее значение всех произведенных измерений.

Затем запустите приложение с помощью средства выделения объектов .NET. Убедитесь, что вы используете сборку Release , а не сборку Debug . В меню отладки откройте профилировщик производительности. Проверьте параметр отслеживания выделения объектов .NET , но ничего другого. Запустите приложение до завершения. Профилировщик измеряет выделение объектов и сообщает о выделениях и цикле сборки мусора. Вы увидите граф, аналогичный следующему изображению:

Граф выделения для выполнения приложения оповещения о вторжении перед проведением какой-либо оптимизации.

На предыдущем графике показано, что работа с сокращением распределения обеспечит преимущества производительности. В графике активных объектов отображается шаблон пилы. Это говорит о том, что создаются многочисленные объекты, которые быстро становятся мусором. Позже их собирают, как показано на графике дельта объекта. Вниз красные полосы указывают цикл сборки мусора.

Затем перейдите на вкладку "Выделения" под графами. В этой таблице показано, какие типы выделяются чаще всего:

Диаграмма, показывая, какие типы выделяются чаще всего.

Тип System.String учитывает большинство выделений. Наиболее важной задачей является минимизация частоты выделения строк. Это приложение постоянно печатает многочисленные отформатированные выходные данные в консоли. Для этой симуляции важно сохранить сообщения, поэтому мы сосредоточим внимание на следующих двух строках: тип SensorMeasurement и тип IntruderRisk.

Дважды щелкните SensorMeasurement строку. Вы можете увидеть, что все выделения осуществляются в методе staticSensorMeasurement.TakeMeasurement. Этот метод можно увидеть в следующем фрагменте кода:

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

Каждое измерение выделяет новый SensorMeasurement объект, который является типом class . Каждое SensorMeasurement созданное вызывает выделение кучи.

Замена классов на структуры

В следующем коде показано начальное объявление 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}
            """;
}

Тип изначально был создан как объект class , так как он содержит многочисленные double измерения. Это больше, чем вы хотите скопировать в горячие пути. Однако это решение означает большое количество выделений. Измените тип с class на struct.

Переход с class на struct приводит к появлению нескольких ошибок компилятора, так как в оригинальном коде в нескольких местах использовались проверки ссылок null. Первый — в DebounceMeasurement классе, в методе 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);
}

Тип DebounceMeasurement содержит массив из 50 измерений. Значения для датчика отображаются в среднем за последние 50 измерений. Это снижает шум в чтениях. Прежде чем были произведены полные 50 измерений, эти значения null. Код проверяет наличие null ссылки для вывода правильное среднее значение при запуске системы. После изменения SensorMeasurement типа в структуру необходимо использовать другой тест. Тип SensorMeasurement содержит string, который служит идентификатором комнаты, поэтому вы можете использовать этот тест вместо:

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

Остальные три ошибки компилятора находятся в методе, который неоднократно принимает измерения в комнате:

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

В начальном методе локальная переменная для этой SensorMeasurement переменной является ссылкой, допускающей значение NULL:

SensorMeasurement? measure = default;

Теперь, когда SensorMeasurement является struct вместо class, nullable является типом значений, допускающим null. Вы можете изменить объявление на тип значения, чтобы исправить оставшиеся ошибки компилятора:

SensorMeasurement measure = default;

Теперь, когда были устранены ошибки компилятора, необходимо проверить код, чтобы убедиться, что семантика не изменилась. Так как struct типы передаются по значению, изменения, внесенные в параметры метода, не отображаются после возврата метода.

Это важно

Изменение типа с class на struct может изменить семантику вашей программы. class При передаче типа в метод все изменения, внесенные в метод, вносятся в аргумент. Когда struct тип передается в метод, изменения, сделанные в методе, применяются к копии аргумента. Это означает, что любой метод, который по задумке изменяет свои аргументы, следует обновить для использования модификатора ref для любого типа аргумента, который вы изменили с class на struct.

Тип SensorMeasurement не включает методы, которые изменяют состояние, поэтому это не проблема в этом примере. Вы можете доказать это, добавив модификатор readonly к структуре SensorMeasurement.

public readonly struct SensorMeasurement

Компилятор обеспечивает readonly свойства SensorMeasurement структуры. Если проверка кода пропустила какой-то метод, который изменил состояние, компилятор сообщит вам. Ваше приложение по-прежнему выполняет сборку без ошибок, поэтому этот тип является readonly. Добавление модификатора readonly при изменении типа с class на struct поможет вам найти элементы, которые изменяют состояние типа struct.

Избегайте копирования

Вы удалили большое количество ненужных выделений из приложения. Тип SensorMeasurement не отображается в таблице нигде.

Теперь это делает дополнительную работу, копируя SensorMeasurement структуру каждый раз, когда она используется в качестве параметра или возвращаемого значения. Структуру SensorMeasurement содержит четыре двойника, a DateTime и a string. Эта структура значительно больше, чем эталон. Добавим модификаторы ref или in в места, где используется тип SensorMeasurement.

Следующий шаг — найти методы, возвращающие измерение, или принять измерение в качестве аргумента, и использовать ссылки, где это возможно. Начните в структуре SensorMeasurement. Статический TakeMeasurement метод создает и возвращает новое 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
    };
}

Мы оставим это как есть и будем возвращать по значению. Если вы попытаетесь выполнить возвращение с помощью ref, вы получите ошибку компилятора. Невозможно вернуть ref в новую структуру, созданную локально в методе. Конструкция неизменяемой структуры означает, что можно задать только значения измерения при построении. Этот метод должен создать новую структуру измерения.

Давайте еще раз рассмотрим DebounceMeasurement.AddMeasurement. Необходимо добавить модификатор in к параметру 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);
}

Это сохраняет одну операцию копирования. Параметр in является ссылкой на копию, уже созданную вызывающим оператором. Вы также можете сохранить копию с помощью метода TakeMeasurement в типе Room. Этот метод иллюстрирует, как компилятор обеспечивает типовую безопасность при передаче аргументов с помощью ref. Начальный TakeMeasurement метод в типе Room принимает аргумент Func<SensorMeasurement, bool>. Если вы попытаетесь добавить модификатор in или ref к этому объявлению, компилятор сообщит об ошибке. Нельзя передавать аргумент типа ref в лямбда-выражение. Компилятор не может гарантировать, что вызываемое выражение не копирует ссылку. Если лямбда-выражение захватывает ссылку, ссылка может иметь более продолжительное время существования, чем значение, на которое она ссылается. Доступ к нему за пределами безопасного контекста ссылки приведет к повреждению памяти. Правила ref безопасности этого не разрешают. Дополнительные сведения см. в обзоре функций безопасности ссылок.

Сохранение семантики

Окончательные наборы изменений не влияют на производительность этого приложения, так как типы не создаются в горячих путях. Эти изменения иллюстрируют некоторые другие методы, которые вы использовали бы в настройке производительности. Рассмотрим начальный 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));
    }
}

Этот тип содержит несколько свойств. Некоторые из них являются class типами. Создание объекта Room включает несколько выделений. Один для самого Room, и один для каждого члена типа class, который он содержит. Вы можете преобразовать два из этих свойств: из типов class в типы struct, а именно типы DebounceMeasurement и AverageMeasurement. Давайте рассмотрим это преобразование с обоими типами.

Измените тип DebounceMeasurement с class на struct. Это представляет ошибку CS8983: A 'struct' with field initializers must include an explicitly declared constructorкомпилятора. Это можно исправить, добавив пустой конструктор без параметров:

public DebounceMeasurement() { }

Дополнительные сведения об этом требовании см. в справочной статье по языку о структурам.

Переопределение Object.ToString() не изменяет ни одно из значений структуры. Можно добавить модификатор readonly в данное объявление метода. Тип DebounceMeasurement является изменяемым, поэтому необходимо позаботиться о том, что изменения не влияют на копии, которые удаляются. Метод AddMeasurement изменяет состояние объекта. Он вызывается из Room класса в методе TakeMeasurements . Вы хотите, чтобы эти изменения сохранялись после вызова метода. Свойство Room.Debounce можно изменить, чтобы оно возвращало ссылку на один экземпляр типа DebounceMeasurement.

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

В предыдущем примере есть несколько изменений. Во-первых, свойство является свойством только для чтения и возвращает ссылку только для чтения на экземпляр, принадлежащий этой комнате. Теперь это поддерживается объявленным полем, которое инициализируется при создании экземпляра объекта Room. После внесения этих изменений вы обновите реализацию AddMeasurement метода. Он использует приватное резервное поле debounce, а не свойство Debounce только для чтения. Таким образом, изменения происходят на одном экземпляре, созданном во время инициализации.

Тот же метод работает со свойством Average . Во-первых, вы измените AverageMeasurement тип из class на struct и добавьте readonly модификатор к 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}
        """;
}

Затем вы измените Room класс, следуя тому же методу, который использовался для Debounce свойства. Свойство Average возвращает readonly ref частное поле для среднего измерения. Метод AddMeasurement изменяет внутренние поля.

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

Избегайте бокса

Существует одно окончательное изменение для повышения производительности. Основная программа выводит статистику по комнате, включая оценку рисков.

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

Вызов созданных ToString полей задает значение перечисления. Это можно избежать, написав переопределение в Room классе, который форматирует строку на основе значения предполагаемого риска:

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

Затем измените код в главной программе, чтобы вызвать этот новый ToString метод:

Console.WriteLine(room.ToString());

Запустите приложение с помощью профилировщика и просмотрите обновленную таблицу для выделений.

Граф распределения при запуске приложения для оповещения о нарушении безопасности после изменений.

Вы удалили многочисленные выделения и предоставили приложению повышение производительности.

Использование безопасного обращения с ссылками в приложении

Эти методы являются низкоуровневой настройкой производительности. Они могут повысить производительность вашего приложения, если применены к критическим участкам, и при измерении влияния этих изменений до и после их внедрения. В большинстве случаев цикл, которого вы будете придерживаться:

  • Распределение мер: определите, какие типы выделяются чаще всего, и когда можно уменьшить выделение кучи.
  • Преобразование класса в структуру: во многих случаях типы можно преобразовать из типа class в структуру struct. Ваше приложение использует пространство стека вместо выделения памяти в куче.
  • Сохранение семантики: преобразование class в struct может повлиять на семантику параметров и возвращаемых значений. Теперь любой метод, изменяющий его параметры, должен пометить эти параметры модификатором ref . Это гарантирует, что изменения вносятся в правильный объект. Аналогичным образом, если значение возвращаемого свойства или метода должно быть изменено вызывающей стороной, то возвращаемое значение должно быть отмечено модификатором ref .
  • Избегайте копирования. При передаче большой структуры в качестве параметра можно пометить параметр модификатором in . Можно передать ссылку в меньшем количестве байтов и убедиться, что метод не изменяет исходное значение. Можно также возвращать значения, readonly ref возвращая ссылку, которая не может быть изменена.

С помощью этих методов можно повысить производительность в горячих путях кода.