Tutorial: Reduza as alocações de memória com ref segurança

Muitas vezes, o ajuste de desempenho para um aplicativo .NET envolve duas técnicas. Primeiro, reduza o número e o tamanho das alocações de heap. Em segundo lugar, reduza a frequência com que os dados são copiados. Visual Studio fornece ótimas ferramentas que ajudam a analisar como seu aplicativo está usando a memória. Depois de determinar onde seu aplicativo faz alocações desnecessárias, você faz alterações para minimizar essas alocações. Você converte class tipos em struct tipos. Você usa ref recursos de segurança para preservar a semântica e minimizar a cópia extra.

Use o Visual Studio 17.5 para obter a melhor experiência com este tutorial. A ferramenta de alocação de objetos .NET usada para analisar o uso de memória faz parte do Visual Studio. Você pode usar o Visual Studio Code e a linha de comando para executar o aplicativo e fazer todas as alterações. No entanto, você não poderá ver os resultados da análise de suas alterações.

O aplicativo que você usará é uma simulação de um aplicativo IoT que monitora vários sensores para determinar se um intruso entrou em uma galeria secreta com objetos de valor. Os sensores IoT estão constantemente enviando dados que medem a mistura de Oxigênio (O2) e Dióxido de Carbono (CO2) no ar. Eles também relatam a temperatura e a umidade relativa. Cada um desses valores está flutuando ligeiramente o tempo todo. No entanto, quando uma pessoa entra na sala, a mudança um pouco mais, e sempre na mesma direção: o oxigênio diminui, o dióxido de carbono aumenta, a temperatura aumenta, assim como a umidade relativa. Quando os sensores se combinam para mostrar aumentos, o alarme de intrusão é acionado.

Neste tutorial, você executará o aplicativo, fará medições nas alocações de memória e, em seguida, melhorará o desempenho reduzindo o número de alocações. O código-fonte está disponível no navegador de exemplos.

Explore a aplicação inicial

Baixe o aplicativo e execute o exemplo inicial. O aplicativo inicial funciona corretamente, mas como aloca muitos objetos pequenos a cada ciclo de medição, seu desempenho se degrada lentamente à medida que é executado ao longo do tempo.

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

Muitas linhas removidas.

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

Você pode explorar o código para saber como o aplicativo funciona. O programa principal executa a simulação. Depois de pressionar <Enter>, ele cria uma sala e reúne alguns dados iniciais da linha 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;
    });

Uma vez que os dados da linha de base tenham sido estabelecidos, ele executa a simulação na sala, onde um gerador de números aleatórios determina se um intruso entrou na 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;
    });

Outros tipos contêm as medições, uma medida debounced que é a média das últimas 50 medições, e a média de todas as medições tomadas.

Em seguida, execute o aplicativo usando a ferramenta de alocação de objetos .NET. Certifique-se de que você está usando a Release compilação, não a Debug compilação. No menu Depurar, abra o criador de perfil de desempenho. Verifique a opção .NET Object Allocation Tracking , mas nada mais. Execute seu aplicativo até a conclusão. O criador de perfil mede alocações de objetos e relatórios sobre alocações e ciclos de coleta de lixo. Você verá um gráfico semelhante à imagem a seguir:

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

O gráfico anterior mostra que trabalhar para minimizar as alocações proporcionará benefícios de desempenho. Você vê um padrão de dente de serra no gráfico de objetos dinâmicos. Isso diz que inúmeros objetos são criados que rapidamente se tornam lixo. Eles são coletados posteriormente, como mostrado no gráfico delta do objeto. As barras vermelhas para baixo indicam um ciclo de coleta de lixo.

Em seguida, observe a guia Alocações abaixo dos gráficos. Esta tabela mostra quais tipos são mais alocados:

Chart that shows which types are allocated most frequently.

O System.String tipo é responsável pela maioria das alocações. A tarefa mais importante deve ser minimizar a frequência das alocações de cadeias de caracteres. Esta aplicação imprime várias saídas formatadas para a consola constantemente. Para esta simulação, queremos manter as mensagens, por isso vamos concentrar-nos nas duas linhas seguintes: o SensorMeasurement tipo e o IntruderRisk tipo.

Clique duas vezes na SensorMeasurement linha. Você pode ver que todas as alocações ocorrem no static método SensorMeasurement.TakeMeasurement. Você pode ver o método no seguinte trecho:

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 aloca um novo SensorMeasurement objeto, que é um class tipo. Cada SensorMeasurement criado causa uma alocação de heap.

Alterar classes para structs

O código a seguir mostra a declaração 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}
            """;
}

O tipo foi originalmente criado como um class porque contém inúmeras double medições. É maior do que você gostaria de copiar em caminhos quentes. No entanto, essa decisão implicou um grande número de atribuições. Altere o tipo de a class para um struct.

Mudar de a class para struct introduz alguns erros do compilador porque o código original usava null verificações de referência em alguns pontos. A primeira está na DebounceMeasurement classe, no AddMeasurement método:

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

O DebounceMeasurement tipo contém uma matriz de 50 medições. As leituras para um sensor são relatadas como a média das últimas 50 medições. Isso reduz o ruído nas leituras. Antes de terem sido feitas 50 leituras completas, estes valores são null. O código verifica a null referência para relatar a média correta na inicialização do sistema. Depois de alterar o SensorMeasurement tipo para um struct, você deve usar um teste diferente. O SensorMeasurement tipo inclui um string para o identificador de sala, para que você possa usar esse teste em vez disso:

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

Os outros três erros do compilador estão todos no método que repetidamente faz medições em uma 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));
}

No método starter, a variável local para o SensorMeasurement é uma referência anulável:

SensorMeasurement? measure = default;

Agora que o SensorMeasurement é um struct em vez de um class, o anulável é um tipo de valor anulável. Você pode alterar a declaração para um tipo de valor para corrigir os erros restantes do compilador:

SensorMeasurement measure = default;

Agora que os erros do compilador foram resolvidos, você deve examinar o código para garantir que a semântica não tenha sido alterada. Como struct os tipos são passados por valor, as modificações feitas nos parâmetros do método não são visíveis depois que o método retorna.

Importante

Alterar um tipo de a class para a struct pode alterar a semântica do seu programa. Quando um class tipo é passado para um método, quaisquer mutações feitas no método são feitas para o argumento. Quando um struct tipo é passado para um método, e as mutações feitas no método são feitas para uma cópia do argumento. Isso significa que qualquer método que modifique seus argumentos por design deve ser atualizado para usar o ref modificador em qualquer tipo de argumento que você tenha alterado de a class para a struct.

O SensorMeasurement tipo não inclui nenhum método que altere o estado, portanto, isso não é uma preocupação neste exemplo. Você pode provar isso adicionando o readonly modificador à SensorMeasurement estrutura:

public readonly struct SensorMeasurement

O compilador impõe a readonly natureza da SensorMeasurement estrutura. Se sua inspeção do código perdeu algum método que modificou o estado, o compilador lhe dirá. Seu aplicativo ainda é compilado sem erros, portanto, esse tipo é readonly. Adicionar o readonly modificador quando você altera um tipo de a class para a struct pode ajudá-lo a encontrar membros que modificam o estado do struct.

Evite fazer cópias

Você removeu um grande número de alocações desnecessárias do seu aplicativo. O SensorMeasurement tipo não aparece na tabela em nenhum lugar.

Agora, ele está fazendo um trabalho extra copiando a SensorMeasurement estrutura toda vez que ela é usada como parâmetro ou valor de retorno. O SensorMeasurement struct contém quatro duplos, a DateTime e a string. Essa estrutura é mensuravelmente maior do que uma referência. Vamos adicionar os ref modificadores ou in aos locais onde o SensorMeasurement tipo é usado.

O próximo passo é encontrar métodos que retornem uma medição, ou tomar uma medida como um argumento, e usar referências sempre que possível. Comece na SensorMeasurement estrutura. O método estático TakeMeasurement cria e retorna um novo 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
    };
}

Vamos deixar este como está, retornando pelo valor. Se você tentasse retornar pelo ref, obteria um erro de compilador. Não é possível retornar um ref para uma nova estrutura criada localmente no método. O projeto da estrutura imutável significa que você só pode definir os valores da medição na construção. Este método deve criar uma nova estrutura de medição.

Vejamos novamente .DebounceMeasurement.AddMeasurement Você deve adicionar o in modificador ao measurement parâmetro:

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

Isso salva uma operação de cópia. O in parâmetro é uma referência à cópia já criada pelo chamador. Você também pode salvar uma cópia com o TakeMeasurement método no Room tipo. Este método ilustra como o compilador fornece segurança quando você passa argumentos por ref. O método inicial TakeMeasurement no Room tipo usa um argumento de Func<SensorMeasurement, bool>. Se você tentar adicionar o in modificador ou ref a essa declaração, o compilador relata um erro. Não é possível passar um ref argumento para uma expressão lambda. O compilador não pode garantir que a expressão chamada não copie a referência. Se a expressão lambda capturar a referência, a referência poderá ter um tempo de vida mais longo do que o valor a que se refere. Acessá-lo fora de seu contexto ref safe resultaria em corrupção de memória. As ref regras de segurança não o permitem. Você pode saber mais na visão geral dos recursos de segurança ref.

Preservar semântica

Os conjuntos finais de alterações não terão um grande impacto no desempenho deste aplicativo porque os tipos não são criados em caminhos quentes. Essas alterações ilustram algumas das outras técnicas que você usaria no ajuste de desempenho. Vamos dar uma olhada na aula 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 contém várias propriedades. Alguns são class tipos. A criação de um Room objeto envolve várias alocações. Um para si Room mesmo, e um para cada um dos membros de um class tipo que ele contém. Você pode converter duas dessas propriedades de class tipos para struct tipos: o DebounceMeasurement e AverageMeasurement tipos. Vamos trabalhar essa transformação com os dois tipos.

Altere o DebounceMeasurement tipo de a class para struct. Isso introduz um erro CS8983: A 'struct' with field initializers must include an explicitly declared constructorde compilador. Você pode corrigir isso adicionando um construtor vazio sem parâmetros:

public DebounceMeasurement() { }

Você pode saber mais sobre esse requisito no artigo de referência de idioma sobre estruturas.

A Object.ToString() substituição não modifica nenhum dos valores da struct. Você pode adicionar o readonly modificador a essa declaração de método. O DebounceMeasurement tipo é mutável, portanto, você precisará tomar cuidado para que as modificações não afetem as cópias que são descartadas. O AddMeasurement método modifica o estado do objeto. É chamado a Room partir da classe, no TakeMeasurements método. Você deseja que essas alterações persistam depois de chamar o método. Você pode alterar a Room.Debounce propriedade para retornar uma referência a uma única instância do DebounceMeasurement tipo:

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

Há algumas alterações no exemplo anterior. Primeiro, a propriedade é uma propriedade somente leitura que retorna uma referência somente leitura para a instância de propriedade desta sala. Agora é apoiado por um campo declarado que é inicializado quando o Room objeto é instanciado. Depois de fazer essas alterações, você atualizará a implementação do AddMeasurement método. Ele usa o campo de suporte privado, debouncenão a propriedade Debouncereadonly. Dessa forma, as alterações ocorrem na única instância criada durante a inicialização.

A mesma técnica funciona com a Average propriedade. Primeiro, você modifica o AverageMeasurement tipo de a class para um structe adiciona o readonlyToString modificador no método:

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

Em seguida, modifique a Room classe seguindo a mesma técnica usada para a Debounce propriedade. A Average propriedade retorna a readonly ref para o campo privado para a medição média. O AddMeasurement método modifica os campos internos.

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

Evite o boxe

Há uma última mudança para melhorar o desempenho. O programa principal é a impressão de estatísticas para a sala, incluindo a avaliação de risco:

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

A chamada para as caixas geradas ToString o valor de enum. Você pode evitar isso escrevendo uma substituição na classe que formata Room a cadeia de caracteres com base no valor do risco 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()}";

Em seguida, modifique o código no programa principal para chamar esse novo ToString método:

Console.WriteLine(room.ToString());

Execute o aplicativo usando o criador de perfil e examine a tabela atualizada para alocações.

Allocation graph for running the intruder alert app after modifications.

Você removeu várias alocações e forneceu ao seu aplicativo um aumento de desempenho.

Usando a segurança de ref em sua aplicação

Essas técnicas são ajustes de desempenho de baixo nível. Eles podem aumentar o desempenho em seu aplicativo quando aplicados a caminhos ativos e quando você mediu o impacto antes e depois das alterações. Na maioria dos casos, o ciclo que você seguirá é:

  • Medir alocações: determine quais tipos estão sendo alocados mais e quando você pode reduzir as alocações de heap.
  • Converter classe em struct: Muitas vezes, os tipos podem ser convertidos de a class para um struct. Seu aplicativo usa espaço de pilha em vez de fazer alocações de pilha.
  • Preservar semântica: a conversão de a class em a struct pode afetar a semântica de parâmetros e valores de retorno. Qualquer método que modifique seus parâmetros deve agora marcar esses parâmetros com o ref modificador. Isso garante que as modificações sejam feitas no objeto correto. Da mesma forma, se um valor de retorno de propriedade ou método deve ser modificado pelo chamador, esse retorno deve ser marcado com o ref modificador.
  • Evitar cópias: Quando você passa uma estrutura grande como parâmetro, você pode marcar o parâmetro com o in modificador. Você pode passar uma referência em menos bytes e garantir que o método não modifique o valor original. Você também pode retornar valores para readonly ref retornar uma referência que não pode ser modificada.

Usando essas técnicas, você pode melhorar o desempenho em caminhos quentes do seu código.