Compartilhar via


Tutorial: como reduzir alocações de memória com segurança ref

Geralmente, 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. O Visual Studio fornece ótimas ferramentas que ajudam a analisar como seu aplicativo está usando memória. Depois de determinar onde seu aplicativo faz alocações desnecessárias, você faz alterações para minimizar essas alocações. Você converte os tipos class em tipos struct. Você usa ref 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 objeto .NET usada para analisar o uso da 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 de 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 torna-se um pouco mais acentuada, e sempre na mesma direção: Oxigênio diminui, Dióxido de Carbono aumenta, temperatura aumenta, tal como a umidade relativa. Quando os sensores se combinam para mostrar aumentos, o alarme de intruso é disparado.

Neste tutorial, você executará o aplicativo, fará medidas sobre 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.

Explorar o aplicativo inicial

Baixe o aplicativo e execute o exemplo inicial. O aplicativo inicial funciona corretamente, mas como aloca muitos objetos pequenos com cada ciclo de medição, seu desempenho diminui 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 coleta alguns dados de linha de base iniciais:

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

Depois que os dados de linha de base tiverem sido estabelecidos, ele executará a simulação na sala, em que um gerador de número aleatório 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 medidas, uma medida esporádica que é a média das últimas 50 medidas e a média de todas as medidas realizadas.

Em seguida, execute o aplicativo usando a ferramenta de alocação de objeto .NET. Verifique se você está usando o Release build, não o Debug build. No menu Depuração, abra o Analisador de Desempenho. Verifique a opção Acompanhamento de Alocação de Objetos do .NET, mas nada mais. Execute seu aplicativo até a conclusão. O criador de perfil mede as alocações de objetos e relata as alocações e os ciclos de coleta de lixo. Você deverá ver um grafo semelhante à seguinte imagem:

Grafo de alocação para executar o aplicativo de alerta de invasor antes de qualquer otimização.

O grafo anterior mostra que trabalhar para minimizar alocações fornecerá benefícios de desempenho. Você vê um padrão de serrilhado no grafo de objetos dinâmicos. Isso informa que vários objetos são criados que rapidamente se tornam lixo. Eles são coletados depois, como é mostrado no grafo delta de objeto. As barras vermelhas para baixo indicam um ciclo de coleta de lixo.

Em seguida, examine a guia Alocações abaixo dos grafos. Esta tabela mostra quais tipos são mais alocados:

Gráfico que mostra quais tipos são alocados com mais frequência.

O System.String tipo representa a maioria das alocações. A tarefa mais importante é minimizar a frequência das alocações de strings. Este aplicativo imprime várias saídas formatadas no console constantemente. Para esta simulação, queremos manter as mensagens; portanto, iremos nos concentrar nos dois próximos tipos de linha: o tipo SensorMeasurement e o tipo IntruderRisk.

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 snippet:

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 várias double medidas. Ele é maior do que o ideal para ser copiado em caminhos críticos. No entanto, essa decisão significou um grande número de alocações. Altere o tipo de um class para um struct.

A alteração de um class para struct introduz alguns erros de compilação porque o código original usava verificações de referência null em alguns pontos. O primeiro 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 medidas. As leituras de um sensor são relatadas como a média das últimas 50 medidas. Isso reduz o ruído nas leituras. Antes de 50 leituras completas serem realizadas, esses 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 tipo SensorMeasurement inclui um string para o identificador de sala, de forma que você possa usar esse teste no lugar desse.

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

Os outros três erros do compilador estão todos no método que faz medidas repetidamente 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 inicializador, a variável local para a SensorMeasurement é uma referência nula:

SensorMeasurement? measure = default;

Agora que o SensorMeasurement é um struct, em vez de um class, o nullable é 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 após o retorno do método.

Importante

Alterar um tipo de um class para um struct pode alterar a semântica do seu programa. Quando um class tipo é passado para um método, todas as mutações feitas no método são feitas no argumento. Quando um tipo struct é passado a um método, as mutações feitas no método são feitas em uma cópia do argumento. Isso significa que qualquer método que, por padrão, modifique seus argumentos deve ser atualizado para usar o modificador ref em qualquer tipo de argumento que você tenha alterado de class para 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 ao SensorMeasurement struct:

public readonly struct SensorMeasurement

O compilador impõe a natureza readonly do struct SensorMeasurement. Se a inspeção do código tiver perdido algum método que modificou o estado, o compilador informará. Seu aplicativo ainda é compilado sem erros, portanto, esse tipo é readonly. Adicionar o modificador readonly quando você altera um tipo de class para struct pode ajudá-lo a encontrar membros que modificam o estado de 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 trabalho extra copiando a SensorMeasurement estrutura sempre que ela é usada como um parâmetro ou um valor retornado. O "struct" SensorMeasurement contém quatro "doubles", um DateTime e um string. Essa estrutura é mensuravelmente maior que uma referência. Vamos adicionar os modificadores ref ou in aos locais onde o tipo SensorMeasurement é usado.

A próxima etapa é localizar métodos que retornam uma medida ou que façam uma medida como argumento e usem referências sempre que possível. Comece no struct SensorMeasurement. 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
    };
}

Deixaremos este como está, retornando por valor. Se você tentasse retornar por ref, obteria um erro de compilação. Não é possível retornar um ref a uma estrutura criada localmente no método. O design do struct imutável significa que você só pode definir os valores da medida na construção. Esse método deve criar um novo struct de medida.

Vamos analisar DebounceMeasurement.AddMeasurement novamente. 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 relatará um erro. Você não pode 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 maior do que o valor ao qual 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 permitem isso. Saiba mais na visão geral de recursos de segurança de referência.

Preservar semântica

Os conjuntos finais de alterações não terão um grande impacto no desempenho desse aplicativo porque os tipos não são criados em caminhos críticos. Essas alterações ilustram algumas das outras técnicas que você usaria no ajuste de desempenho. Vamos dar uma olhada na classe 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));
    }
}

Esse tipo contém várias propriedades. Alguns são tipos class. A criação de um Room objeto envolve várias alocações. Uma para o Room em si e outra para cada um dos membros de um tipo class que ele contém. Você pode converter duas dessas propriedades de tipos class em tipos struct: os tipos DebounceMeasurement e AverageMeasurement. Vamos trabalhar nessa transformação com ambos os tipos.

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

public DebounceMeasurement() { }

Você pode aprender mais sobre este requisito no artigo de referência linguística sobre structs.

A Object.ToString() substituição não modifica nenhum dos valores do 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 cópias descartadas. O AddMeasurement método modifica o estado do objeto. Ele é chamado da classe Room, no método TakeMeasurements. 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 à instância pertencente a essa sala. Agora ele é 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. Ela usa o campo de suporte privado, debounce, não a propriedade somente leitura Debounce. 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, modifique o AverageMeasurement tipo de um class para um structe adicione o readonly modificador ao ToString 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 propriedade Average retorna um readonly ref ao 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; } }

Evitar boxe

Há uma última mudança para melhorar o desempenho. O programa principal é imprimir 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 ao ToString gerado faz a conversão boxing para o valor de enumeração. Você pode evitar isso escrevendo uma substituição na Room classe que formata 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 este novo ToString método:

Console.WriteLine(room.ToString());

Execute o aplicativo usando a ferramenta de análise de desempenho e examine a tabela atualizada de alocações.

Grafo de alocação para executar o aplicativo de alerta de invasor após modificações.

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

Como usar a segurança de referência no aplicativo

Essas técnicas são um ajuste de desempenho de nível baixo. Elas podem aumentar o desempenho no aplicativo quando aplicadas a caminhos críticos e quando o impacto é medido antes e depois das alterações. Na maioria dos casos, o ciclo que você seguirá é:

  • Alocações de medidas: determine quais tipos estão sendo mais alocados e quando você pode reduzir as alocações de heap.
  • Converter classe em struct: muitas vezes, os tipos podem ser convertidos de um class para um struct. O aplicativo usa espaço de pilha em vez de fazer alocações de heap.
  • Preservar semântica: converter um class em um struct pode afetar a semântica para parâmetros e valores retornados. Qualquer método que modifique seus parâmetros agora deve 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 retornado de propriedade ou método deve ser modificado pelo chamador, esse retorno deve ser marcado com o ref modificador.
  • Evite cópias: Ao passar uma estrutura grande como parâmetro, você pode marcar o parâmetro com o modificador in. 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 readonly ref para retornar uma referência que não pode ser modificada.

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