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:
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:
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 constructor
de 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, debounce
não a propriedade Debounce
readonly. 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 struct
e adiciona o readonly
ToString
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.
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 umstruct
. 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 astruct
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 oref
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 oref
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 parareadonly 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.