Tutoriel : Réduire les allocations de mémoire avec la sécurité ref
Souvent, le réglage des performances pour une application .NET implique deux techniques. Tout d’abord, réduisez le nombre et la taille des allocations de tas. Ensuite, réduisez la fréquence de copie des données. Visual Studio fournit d’excellents outils qui permettent d’analyser la façon dont votre application utilise la mémoire. Une fois que vous avez déterminé où votre application effectue des allocations inutiles, vous apportez des modifications pour réduire ces allocations. Vous convertissez des types class
en types struct
. Vous utilisez des fonctionnalités de sécurité ref
pour préserver la sémantique et réduire la copie supplémentaire.
Utilisez Visual Studio 17.5 pour une expérience optimale avec ce tutoriel. L’outil d’allocation d’objets .NET utilisé pour analyser l’utilisation de la mémoire fait partie de Visual Studio. Vous pouvez utiliser Visual Studio Code et la ligne de commande pour exécuter l’application et apporter toutes les modifications. Toutefois, vous ne pourrez pas voir les résultats d’analyse de vos modifications.
L’application que vous allez utiliser est une simulation d’une application IoT qui surveille plusieurs capteurs pour déterminer si un intrus est entré dans une galerie secrète avec des objets de valeur. Les capteurs IoT envoient constamment des données qui mesurent la combinaison d’oxygène (O2) et de dioxyde de carbone (CO2) dans l’air. Ils signalent également la température et l’humidité relative. Chacune de ces valeurs varie légèrement en permanence. Toutefois, lorsqu’une personne entre dans la pièce, le changement s’accentue un peu plus, et toujours dans la même direction : l’oxygène diminue, le dioxyde de carbone augmente, la température augmente, comme l’humidité relative. Lorsque les capteurs se combinent pour afficher des augmentations, l’alarme d’intrusion est déclenchée.
Dans ce tutoriel, vous allez exécuter l’application, prendre des mesures sur les allocations de mémoire, puis améliorer les performances en réduisant le nombre d’allocations. Le code source est disponible dans l’exemple de navigateur.
Découvrez l’application de démarrage
Téléchargez l’application et exécutez l’exemple de démarrage. L’application de démarrage fonctionne correctement, mais comme elle alloue de nombreux petits objets avec chaque cycle de mesure, ses performances se dégradent lentement au fil du temps.
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
De nombreuses lignes ont été supprimées.
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
Vous pouvez explorer le code pour découvrir le fonctionnement de l’application. Le programme principal exécute la simulation. Une fois que vous appuyez sur <Enter>
, cela crée une pièce et collecte des données de base initiales :
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;
});
Une fois que les données de base ont été établies, cela exécute la simulation sur la pièce, où un générateur de nombres aléatoires détermine si un intrus est entré :
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;
});
D’autres types contiennent les mesures, une mesure de réponse qui est la moyenne des 50 dernières mesures et la moyenne de toutes les mesures prises.
Ensuite, exécutez l’application à l’aide de l’outil d’allocation d’objets .NET. Vérifiez que vous utilisez le build Release
, et non le build Debug
. Dans le menu Débogage , ouvrez le Profileur de performances. Cochez l’option Suivi d’allocation d’objets .NET, mais rien d’autre. Exécutez votre application pour terminer. Le profileur mesure les allocations d’objets et signale les allocations et les cycles de nettoyage de la mémoire. Vous devez voir un graphique similaire à l’image suivante :
Le graphique précédent montre que travailler sur la réduction des allocations offre des avantages en matière de performances. Vous voyez un modèle en dents de scie dans le graphe d’objets en direct. Cela vous indique que de nombreux objets sont créés qui deviennent rapidement des données inutiles. Ils sont collectés ultérieurement, comme indiqué dans le graphique delta d’objet. Les barres rouges vers le bas indiquent un cycle de nettoyage de la mémoire.
Examinez ensuite l’onglet Allocations sous les graphiques. Ce tableau indique quels types sont le plus alloués :
Le type System.String tient compte de la plupart des allocations. La tâche la plus importante doit être de réduire la fréquence des allocations de chaînes. Cette application imprime constamment de nombreuses sorties mises en forme dans la console. Pour cette simulation, nous voulons conserver les messages. Nous allons donc nous concentrer sur les deux lignes suivantes : le type SensorMeasurement
et le type IntruderRisk
.
Double-cliquez sur la ligne SensorMeasurement
. Vous pouvez voir que toutes les allocations ont lieu dans la méthode static
SensorMeasurement.TakeMeasurement
. Vous pouvez voir la méthode dans l’extrait de code suivant :
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
};
}
Chaque mesure alloue un nouvel objet SensorMeasurement
, qui est un type class
. Chaque SensorMeasurement
créé provoque une allocation de tas.
Transformer les classes en structs
Le code suivant montre la déclaration initiale 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}
""";
}
Le type a été initialement créé en tant que class
, car il contient de nombreuses mesures double
. Il est plus grand que ce que vous souhaitez copier dans des chemins chauds. Toutefois, cette décision impliquait un grand nombre d’allocations. Remplacez le type d’un class
par un struct
.
La modification de class
en struct
introduit quelques erreurs de compilateur, car le code d’origine a utilisé des vérifications de référence null
à quelques endroits. Le premier est la classe DebounceMeasurement
, dans la méthode 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);
}
Le type DebounceMeasurement
contient un tableau de 50 mesures. Les lectures d’un capteur sont signalées comme la moyenne des 50 dernières mesures. Cela réduit le bruit dans les lectures. Avant que 50 lectures complètes aient été prises, ces valeurs sont null
. Le code vérifie la référence null
pour signaler la moyenne correcte au démarrage du système. Après avoir modifié le type SensorMeasurement
en struct, vous devez utiliser un autre test. Le type SensorMeasurement
inclut un identificateur de pièce string
. Vous pouvez donc utiliser ce test à la place :
if (recentMeasurements[i].Room is not null)
Les trois autres erreurs du compilateur résident toutes dans la méthode qui prend à plusieurs reprises des mesures dans une pièce :
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));
}
Dans la méthode de démarrage, la variable locale pour SensorMeasurement
est une référence nullable :
SensorMeasurement? measure = default;
Maintenant que SensorMeasurement
est struct
au lieu de class
, la valeur nullable est un type de valeur nullable. Vous pouvez modifier la déclaration en type de valeur pour corriger les erreurs restantes du compilateur :
SensorMeasurement measure = default;
Maintenant que les erreurs du compilateur ont été traitées, vous devez examiner le code pour vous assurer que la sémantique n’a pas changé. Étant donné que les types struct
sont transmis par valeur, les modifications apportées aux paramètres de méthode ne sont pas visibles une fois la méthode retournée.
Important
La modification d’un type de class
en struct
peut modifier la sémantique de votre programme. Lorsqu’un type class
est transmis à une méthode, toutes les mutations effectuées dans la méthode sont apportées à l’argument. Lorsqu’un type struct
est transmis à une méthode, toutes les mutations effectuées dans la méthode sont effectuées dans une copie de l’argument. Cela signifie que toute méthode qui modifie ses arguments par conception doit être mise à jour pour utiliser le modificateur ref
sur n’importe quel type d’argument que vous avez changé de class
en struct
.
Le type SensorMeasurement
n’inclut aucune méthode qui change d’état, donc ce n’est pas un problème dans cet exemple. Vous pouvez le prouver en ajoutant le modificateur readonly
au struct SensorMeasurement
:
public readonly struct SensorMeasurement
Le compilateur applique la nature readonly
du struct SensorMeasurement
. Si votre inspection du code a manqué une méthode qui a modifié l’état, le compilateur vous l’indique. Votre application continue d’être générée sans erreurs, donc ce type est readonly
. L’ajout du modificateur readonly
lorsque vous modifiez un type de class
en struct
peut vous aider à trouver des membres qui modifient l’état de struct
.
Éviter d’effectuer des copies
Vous avez supprimé un grand nombre d’allocations inutiles de votre application. Le type SensorMeasurement
n’apparaît nulle part dans le tableau.
À présent, il effectue un travail de copie supplémentaire de la structure SensorMeasurement
chaque fois qu’elle est utilisée comme paramètre ou valeur de retour. Le struct SensorMeasurement
contient quatre doubles, DateTime et string
. Cette structure est sensiblement plus grande qu’une référence. Ajoutons les modificateurs ref
ou in
aux emplacements où le type SensorMeasurement
est utilisé.
L’étape suivante consiste à rechercher des méthodes qui retournent une mesure ou à prendre une mesure en tant qu’argument et à utiliser des références dans la mesure du possible. Démarrez dans le struct SensorMeasurement
. La méthode statique TakeMeasurement
crée et retourne un nouveau 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
};
}
Laissons celui-ci en l’état, en retournant par valeur. Si vous avez essayé de retourner par ref
, vous obtenez une erreur du compilateur. Vous ne pouvez pas retourner ref
dans une nouvelle structure créée localement dans la méthode. La conception du struct immuable signifie que vous ne pouvez définir que les valeurs de la mesure au moment de la construction. Cette méthode doit créer un struct de mesure.
Examinons à nouveau DebounceMeasurement.AddMeasurement
. Vous devez ajouter le modificateur in
au paramètre 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);
}
Cela enregistre une opération de copie. Le paramètre in
est une référence à la copie déjà créée par l’appelant. Vous pouvez également enregistrer une copie avec la méthode TakeMeasurement
dans le type Room
. Cette méthode illustre la façon dont le compilateur assure la sécurité lorsque vous transmettez des arguments par ref
. La méthode initiale TakeMeasurement
dans le type Room
prend un argument de Func<SensorMeasurement, bool>
. Si vous essayez d’ajouter le modificateur in
ou ref
à cette déclaration, le compilateur signale une erreur. Vous ne pouvez pas transmettre d’argument ref
à une expression lambda. Le compilateur ne peut pas garantir que l’expression appelée ne copie pas la référence. Si l’expression lambda capture la référence, la référence peut avoir une durée de vie plus longue que la valeur à laquelle elle fait référence. Y accéder en dehors de son contexte de sécurité de référence entraînerait une corruption de la mémoire. Les règles de sécurité ref
ne l’autorisent pas. Vous pouvez en savoir plus dans la vue d’ensemble des fonctionnalités de sécurité de référence.
Conserver la sémantique
Les derniers ensembles de modifications n’ont pas d’impact majeur sur les performances de cette application, car les types ne sont pas créés dans les chemins chauds. Ces modifications illustrent certaines des autres techniques que vous utiliseriez dans votre réglage des performances. Examinons la classe initiale 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));
}
}
Ce type contient plusieurs propriétés. Certaines sont des types class
. La création d’un objet Room
implique plusieurs allocations. Un pour Room
proprement dit, et un pour chacun des membres d’un type class
qu’il contient. Vous pouvez convertir deux de ces propriétés de types class
en types struct
: les types DebounceMeasurement
et AverageMeasurement
. Examinons cette transformation avec les deux types.
Remplacez le type DebounceMeasurement
de class
par struct
. Cela introduit une erreur de compilateur CS8983: A 'struct' with field initializers must include an explicitly declared constructor
. Vous pouvez résoudre ce problème en ajoutant un constructeur sans paramètres vide :
public DebounceMeasurement() { }
Vous pouvez en savoir plus sur cette exigence dans l’article de référence du langage sur les structs.
Le remplacement de Object.ToString() ne modifie aucune des valeurs du struct. Vous pouvez ajouter le modificateur readonly
à cette déclaration de méthode. Le type DebounceMeasurement
est mutable. Vous devez donc vous assurer que les modifications n’affectent pas les copies qui sont ignorées. La méthode AddMeasurement
modifie l’état de l’objet. Elle est appelée à partir de la classe Room
, dans la méthode TakeMeasurements
. Vous souhaitez que ces modifications persistent après l’appel de la méthode. Vous pouvez modifier la propriété Room.Debounce
pour renvoyer une référence à une seule instance du type DebounceMeasurement
:
private DebounceMeasurement debounce = new();
public ref readonly DebounceMeasurement Debounce { get { return ref debounce; } }
Il existe quelques modifications dans l’exemple précédent. Tout d’abord, la propriété est une propriété en lecture seule qui retourne une référence en lecture seule à l’instance appartenant à cette pièce. Elle est maintenant soutenue par un champ déclaré qui est initialisé lorsque l’objet Room
est instancié. Après avoir apporté ces modifications, vous allez mettre à jour l’implémentation de la méthode AddMeasurement
. Elle utilise le champ de stockage privé debounce
, et non la propriété Debounce
en lecture seule . Ainsi, les modifications se produisent sur l’instance unique créée lors de l’initialisation.
La même technique fonctionne avec la propriété Average
. Tout d’abord, vous modifiez le type AverageMeasurement
de class
en struct
, puis vous ajoutez le modificateur readonly
à la méthode 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}
""";
}
Ensuite, vous modifiez la classe Room
en suivant la même technique que celle que vous avez utilisée pour la propriété Debounce
. La propriété Average
retourne un readonly ref
au champ privé pour la mesure moyenne. La méthode AddMeasurement
modifie les champs internes.
private AverageMeasurement average = new();
public ref readonly AverageMeasurement Average { get { return ref average; } }
Éviter le boxing
Il y a un changement final pour améliorer les performances. Le programme principal imprime des statistiques pour la pièce, y compris l’évaluation des risques :
Console.WriteLine($"Current intruders: {room.Intruders}");
Console.WriteLine($"Calculated intruder risk: {room.RiskStatus}");
L’appel au ToString
généré boxe la valeur d’énumération. Vous pouvez éviter cela en écrivant un remplacement dans la classe Room
qui met en forme la chaîne en fonction de la valeur du risque estimé :
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()}";
Ensuite, modifiez le code dans le programme principal pour appeler cette nouvelle méthode ToString
:
Console.WriteLine(room.ToString());
Exécutez l’application à l’aide du profileur et examinez la table mise à jour pour les allocations.
Vous avez supprimé de nombreuses allocations et fourni à votre application une optimisation des performances.
Utilisation de la sécurité de référence dans votre application
Ces techniques constituent un réglage des performances de bas niveau. Elle peuvent augmenter les performances de votre application lorsqu’elles sont appliquées à des chemins chauds et lorsque vous avez mesuré l’impact avant et après les modifications. Dans la plupart des cas, le cycle que vous allez suivre est le suivant :
- Mesurer les allocations : déterminez les types qui sont le plus alloués et quand vous pouvez réduire les allocations de tas.
- Convertir la classe en struct : plusieurs fois, les types peuvent être convertis de
class
enstruct
. Votre application utilise de l’espace de pile au lieu d’effectuer des allocations de tas. - Conserver la sémantique : la conversion de
class
enstruct
peut avoir un impact sur la sémantique des paramètres et des valeurs renvoyées. Toute méthode qui modifie ses paramètres doit maintenant marquer ces paramètres avec le modificateurref
. Cela garantit que les modifications sont apportées à l’objet correct. De même, si une valeur de retour de propriété ou de méthode doit être modifiée par l’appelant, ce retour doit être marqué avec le modificateurref
. - Éviter les copies : lorsque vous transmettez un struct volumineux en tant que paramètre, vous pouvez marquer le paramètre avec le modificateur
in
. Vous pouvez transmettre une référence en moins d’octets et vous assurer que la méthode ne modifie pas la valeur d’origine. Vous pouvez également retourner des valeurs parreadonly ref
pour retourner une référence qui ne peut pas être modifiée.
Ces techniques vous permettent d’améliorer les performances dans les chemins chauds de votre code.