Tutorial: como reduzir alocações de memória com segurança ref
Geralmente, o ajuste de desempenho de um aplicativo .NET envolve duas técnicas. Primeiro, reduza o número e o tamanho das alocações de heap. Segundo, reduza a frequência em que os dados são copiados. O Visual Studio oferece ótimas ferramentas que ajudam a analisar como o aplicativo está usando a memória. Depois de determinar onde o 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 recursos de segurança ref
para preservar a semântica e minimizar a cópia extra.
Use o Visual Studio 17.5 para obter a melhor experiência possível com este tutorial. A ferramenta de alocação de objeto .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 das alterações.
O aplicativo que você usará é uma simulação de um aplicativo de IoT que monitora vários sensores para determinar se um intruso entrou em uma galeria de segredos com itens de valor. Os sensores de IoT estão sempre enviando dados que medem a mistura de O2 (oxigênio) e CO2 (dióxido de carbono) no ar. Eles também relatam a temperatura e a umidade relativa. Cada um desses valores flutua um pouco o tempo todo. No entanto, quando uma pessoa entra na sala, a alteração aumenta um pouco e sempre na mesma direção: o oxigênio diminui, dióxido de carbono aumenta, a temperatura aumenta, assim como a umidade relativa. Quando os sensores se combinam para mostrar aumentos, o alarme de intruso é disparado.
Neste tutorial, você vai executar o aplicativo, obter medidas de alocações de memória e, depois, aprimorar 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 a cada ciclo de medição, o 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 forem estabelecidos, ele executará a simulação na sala, em que um gerador de número aleatório determinará 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.
Depois, execute o aplicativo usando a ferramenta de alocação de objeto .NET. Verifique se você está usando o build Release
, não o build Debug
. No menu Depurar, abra o Criador de perfil de desempenho. Marque somente a opção Acompanhamento de Alocação de Objeto .NET. Execute o 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ê verá um grafo semelhante à seguinte imagem:
O grafo anterior mostra que o trabalho para minimizar as alocações trará benefícios de desempenho. Você vê um padrão de serrilhado no grafo de objetos dinâmicos. Isso indica que são criados vários objetos 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.
Depois, examine a guia Alocações abaixo dos grafos. Esta tabela mostra quais tipos são mais alocados:
O tipo System.String conta para a maioria das alocações. A tarefa mais importante deve ser minimizar a frequência das alocações de cadeia de caracteres. Este aplicativo imprime várias saídas formatadas no console constantemente. Para essa simulação, queremos manter as mensagens, portanto, vamos olhar as duas próximas linhas: o tipo SensorMeasurement
e o tipo IntruderRisk
.
Clique duas vezes na linha SensorMeasurement
. Veja que todas as alocações ocorrem no método static
SensorMeasurement.TakeMeasurement
. Veja 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 objeto SensorMeasurement
, que é um tipo class
. Cada SensorMeasurement
criado causa uma alocação de heap.
Alterar classes para structs
O seguinte código 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 criado originalmente como um class
porque contém várias medidas double
. 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 class
para struct
.
A alteração de class
para struct
apresenta alguns erros do compilador porque o código original usou verificações de referência null
em alguns pontos. O primeiro está na classe DebounceMeasurement
, no método AddMeasurement
:
public void AddMeasurement(SensorMeasurement datum)
{
int index = totalMeasurements % debounceSize;
recentMeasurements[index] = datum;
totalMeasurements++;
double sumCO2 = 0;
double sumO2 = 0;
double sumTemp = 0;
double sumHumidity = 0;
for (int i = 0; i < debounceSize; i++)
{
if (recentMeasurements[i] is not null)
{
sumCO2 += recentMeasurements[i].CO2;
sumO2+= recentMeasurements[i].O2;
sumTemp+= recentMeasurements[i].Temperature;
sumHumidity += recentMeasurements[i].Humidity;
}
}
O2 = sumO2 / ((totalMeasurements > debounceSize) ? debounceSize : totalMeasurements);
CO2 = sumCO2 / ((totalMeasurements > debounceSize) ? debounceSize : totalMeasurements);
Temperature = sumTemp / ((totalMeasurements > debounceSize) ? debounceSize : totalMeasurements);
Humidity = sumHumidity / ((totalMeasurements > debounceSize) ? debounceSize : totalMeasurements);
}
O tipo DebounceMeasurement
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 serem realizadas 50 leituras completas, esses valores são null
. O código verifica a referência null
para relatar a média correta na inicialização do sistema. Depois de alterar o tipo SensorMeasurement
para um struct, você precisa usar um teste diferente. O tipo SensorMeasurement
inclui um string
para o identificador de sala, para que você possa usar esse teste nesse caso:
if (recentMeasurements[i].Room is not null)
Os outros três erros do compilador estão no método que obtém 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 inicial, a variável local de SensorMeasurement
é uma referência anulável:
SensorMeasurement? measure = default;
Agora que 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 a fim de 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 os tipos struct
são passados por valor, as modificações feitas nos parâmetros de método não são visíveis após o retorno do método.
Importante
A alteração de um tipo de class
para struct
pode alterar a semântica do programa. Quando um tipo class
é passado a um método, as mutações feitas no método são feitas ao 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 modifique argumentos por design deve ser atualizado para usar o modificador ref
nos tipos de argumento alterados de class
para struct
.
O tipo SensorMeasurement
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 modificador readonly
ao struct SensorMeasurement
:
public readonly struct SensorMeasurement
O compilador impõe a natureza readonly
do struct SensorMeasurement
. Se a inspeção do código deixar passar algum método que tenha modificado o estado, o compilador informará isso. O aplicativo ainda é criado sem erros, portanto, esse tipo é readonly
. A adição do modificador readonly
ao alterar um tipo de class
para struct
pode ajudar 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 aplicativo. O tipo SensorMeasurement
não aparece na tabela em nenhum lugar.
Agora, ele está fazendo um trabalho extra copiando a estrutura SensorMeasurement
sempre que ela é usada como um parâmetro ou um valor retornado. O struct SensorMeasurement
contém quatro duplas, um DateTime e um string
. Essa estrutura é mensuravelmente maior que uma referência. Vamos adicionar os modificadores ref
ou in
a locais em que o tipo SensorMeasurement
é usado.
A próxima etapa é localizar métodos que retornam uma medida ou que obtenham uma medida como argumento e usem referências sempre que possível. Comece no struct SensorMeasurement
. O método TakeMeasurement
estático 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 do compilador. 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 precisa criar um struct de medida.
Vamos analisar DebounceMeasurement.AddMeasurement
novamente. Você deve adicionar o modificador in
ao parâmetro measurement
:
public void AddMeasurement(in SensorMeasurement datum)
{
int index = totalMeasurements % debounceSize;
recentMeasurements[index] = datum;
totalMeasurements++;
double sumCO2 = 0;
double sumO2 = 0;
double sumTemp = 0;
double sumHumidity = 0;
for (int i = 0; i < debounceSize; i++)
{
if (recentMeasurements[i].Room is not null)
{
sumCO2 += recentMeasurements[i].CO2;
sumO2+= recentMeasurements[i].O2;
sumTemp+= recentMeasurements[i].Temperature;
sumHumidity += recentMeasurements[i].Humidity;
}
}
O2 = sumO2 / ((totalMeasurements > debounceSize) ? debounceSize : totalMeasurements);
CO2 = sumCO2 / ((totalMeasurements > debounceSize) ? debounceSize : totalMeasurements);
Temperature = sumTemp / ((totalMeasurements > debounceSize) ? debounceSize : totalMeasurements);
Humidity = sumHumidity / ((totalMeasurements > debounceSize) ? debounceSize : totalMeasurements);
}
Isso economiza uma operação de cópia. O parâmetro in
é uma referência à cópia já criada pelo chamador. Você também pode salvar uma cópia com o método TakeMeasurement
no tipo Room
. Este método ilustra como o compilador fornece segurança quando você passa argumentos por ref
. O método TakeMeasurement
inicial no tipo Room
usa o argumento Func<SensorMeasurement, bool>
. Se você tentar adicionar o modificador in
ou ref
a essa declaração, o compilador relatará um erro. Você não pode passar um argumento ref
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 de referência segura resultaria em corrupção de memória. As regras de segurança de ref
não permitem isso. Saiba mais na visão geral de recursos de segurança de referência.
Preservar a 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 Room
inicial:
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 objeto Room
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 os dois tipos.
Altere o tipo DebounceMeasurement
de class
para struct
. Isso apresenta um erro do compilador CS8983: A 'struct' with field initializers must include an explicitly declared constructor
. Você pode corrigir isso adicionando um construtor sem parâmetros vazio:
public DebounceMeasurement() { }
Saiba mais sobre esse requisito no artigo de referência de linguagem em structs.
A substituição de Object.ToString() não modifica nenhum dos valores do struct. Você pode adicionar o modificador readonly
a essa declaração de método. O tipo DebounceMeasurement
é mutável, portanto, você precisará tomar cuidado para que as modificações não afetem cópias descartadas. O método AddMeasurement
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 propriedade Room.Debounce
para retornar uma referência a uma só instância do tipo DebounceMeasurement
:
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 ela é apoiada por um campo declarado que é inicializado quando é criada uma instância do objeto Room
. Depois de fazer essas alterações, você atualizará a implementação do método AddMeasurement
. 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 propriedade Average
. Primeiro, modifique o tipo AverageMeasurement
de class
para struct
e adicione o modificador readonly
ao método ToString
:
namespace IntruderAlert;
public struct AverageMeasurement
{
private double sumCO2 = 0;
private double sumO2 = 0;
private double sumTemperature = 0;
private double sumHumidity = 0;
private int totalMeasurements = 0;
public AverageMeasurement() { }
public readonly double CO2 => sumCO2 / totalMeasurements;
public readonly double O2 => sumO2 / totalMeasurements;
public readonly double Temperature => sumTemperature / totalMeasurements;
public readonly double Humidity => sumHumidity / totalMeasurements;
public void AddMeasurement(in SensorMeasurement datum)
{
totalMeasurements++;
sumCO2 += datum.CO2;
sumO2 += datum.O2;
sumTemperature += datum.Temperature;
sumHumidity+= datum.Humidity;
}
public readonly override string ToString() => $"""
Average measurements:
Temp: {Temperature:F3}
Humidity: {Humidity:P3}
Oxygen: {O2:P3}
CO2 (ppm): {CO2:F3}
""";
}
Depois, modifique a classe Room
seguindo a mesma técnica usada para a propriedade Debounce
. A propriedade Average
retorna um readonly ref
ao campo privado para a medição média. O método AddMeasurement
modifica os campos internos.
private AverageMeasurement average = new();
public ref readonly AverageMeasurement Average { get { return ref average; } }
Evitar a conversão boxing
Há uma última alteração para aprimorar o desempenho. O programa principal está imprimindo 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 classe Room
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()}";
Depois, modifique o código no programa principal para chamar esse novo método ToString
:
Console.WriteLine(room.ToString());
Execute o aplicativo usando o criador de perfil e examine a tabela atualizada em busca de alocações.
Você removeu várias alocações e proporcionou 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 a ser seguido é:
- 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
class
emstruct
. O aplicativo usa espaço de pilha em vez de fazer alocações de heap. - Preservar a semântica: a conversão de
class
emstruct
pode afetar a semântica dos parâmetros e valores retornados. Agora, qualquer método que modificar os parâmetros deverá marcar esses parâmetros com o modificadorref
. Isso garante que as modificações sejam feitas no objeto correto. Da mesma forma, se um valor retornado de uma propriedade ou um método precisar ser modificado pelo chamador, esse retorno deverá ser marcado com o modificadorref
. - Evitar cópias: ao passar um struct grande como um 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 porreadonly ref
para retornar uma referência que não possa ser modificada.
Usando essas técnicas, você pode aprimorar o desempenho em caminhos críticos do código.