教程:借助
通常,.NET 应用程序的性能优化涉及两种技术。 首先,减少堆分配的数量和大小。 其次,减少复制数据的频率。 Visual Studio 提供了出色的 工具 ,可帮助分析应用程序使用内存的方式。 确定应用在何处进行不必要的分配后,进行更改以最大程度地减少这些分配。 您将class
类型转换为struct
类型。 使用ref
安全功能来保留语义并最大程度地减少额外的复制。
使用 Visual Studio 17.5 获得本教程的最佳体验。 用于分析内存使用情况的 .NET 对象分配工具是 Visual Studio 的一部分。 可以使用 Visual Studio Code 和命令行运行应用程序并进行所有更改。 但是,无法查看更改的分析结果。
你将使用的应用程序是 IoT 应用程序的模拟,它监视多个传感器,以确定入侵者是否已进入具有贵重物品的机密库。 IoT 传感器不断发送测量空气中氧气(O2)和二氧化碳(CO2)混合的数据。 它们还会报告温度和相对湿度。 所有这些值在一直略有波动。 然而,当一个人进入房间时,变化多一点,总是在同一方向:氧气减少,二氧化碳增加,温度增加,相对湿度也是如此。 当传感器协同检测到增加时,入侵者警报将被触发。
在本教程中,你将运行应用程序,对内存分配进行度量,然后通过减少分配数来提高性能。 示例 浏览器中提供了源代码。
探索初学者应用程序
下载应用程序并运行初学者示例。 初学者应用程序正常工作,但由于它为每个度量周期分配了许多小型对象,因此其性能会随着时间推移而缓慢下降。
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
删除了许多行。
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
可以浏览代码以了解应用程序的工作原理。 主程序运行模拟。 按 <Enter>
完后,它会创建一个聊天室,并收集一些初始基线数据:
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;
});
建立基线数据后,它会在聊天室中运行模拟,随机数生成器确定入侵者是否已进入聊天室:
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;
});
其他类型包含度量值、防反跳度量值(即最近 50 个度量值的平均值)以及所有度量值的平均值。
接下来,使用 .NET 对象分配工具运行应用程序。 请确保使用的是Release
构建版本,而不是Debug
构建版本。 在 “调试” 菜单上,打开 性能探查器。 检查 .NET 对象分配跟踪 选项,但不检查其他任何内容。 运行你的应用程序直到完成。 探查器度量对象分配,并报告分配和垃圾回收周期。 应会看到类似于下图的图形:
上图显示,努力最大程度地减少分配将带来性能优势。 在实时对象图中看到锯齿图案。 这告诉你,会创建大量对象,这些对象会很快成为垃圾。 稍后会收集这些对象,如对象增量图中所示。 向下的红色条表示垃圾回收周期。
接下来,查看图形下方的 “分配 ”选项卡。 此表显示分配的类型最多:
类型 System.String 占大多数分配。 最重要的任务应该是尽量减少字符串分配的频率。 此应用程序不断将大量格式化输出打印到控制台。 对于此模拟,我们希望保留消息,因此我们将专注于接下来的两行: SensorMeasurement
类型和 IntruderRisk
类型。
双击 SensorMeasurement
行。 可以看到所有分配都发生在static
方法SensorMeasurement.TakeMeasurement
中。 可以在以下代码片段中看到该方法:
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
};
}
每个度量分配一个新 SensorMeasurement
对象,该对象是一种 class
类型。 创建的每个 SensorMeasurement
都会导致堆分配。
将类更改为结构
以下代码显示了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}
""";
}
该类型最初创建为一种 class
,因为它包含大量 double
度量值。 它比要在热路径中复制的要大。 但是,该决定意味着大量的分配。 将类型从 a class
更改为 a struct
。
从 a class
更改为 struct
引入了一些编译器错误,因为原始代码在几个位置使用了 null
引用检查。 第一个在 DebounceMeasurement
方法的 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);
}
该 DebounceMeasurement
类型包含 50 个度量值的数组。 传感器的读数报告为最近 50 个度量值的平均值。 这可以减少读数中的干扰。 在获取整整 50 个读数之前,这些值为 null
。 在系统启动时,代码会检查null
引用情况,以报告正确的平均值。 将 SensorMeasurement
类型更改为结构后,必须使用其他测试。
SensorMeasurement
类型包括房间标识符的 string
,因此可以改用该测试:
if (recentMeasurements[i].Room is not null)
其他三个编译器错误都位于在房间中重复执行度量的方法中:
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));
}
在入门方法中,SensorMeasurement
的局部变量是一个 可为 null 的引用:
SensorMeasurement? measure = default;
现在,SensorMeasurement
为 struct
,而不是 class
,可为空是可以为 null 的值类型。 可以将声明更改为值类型,以修复其余编译器错误:
SensorMeasurement measure = default;
现在已解决编译器错误,应检查代码以确保语义未更改。 由于 struct
类型按值传递,因此对方法参数所做的修改在方法返回后不可见。
重要
将类型从 class
更改为 struct
可能会改变程序的语义。 当class
类型传递给方法时,方法中所做的任何修改都会作用于参数。 将 struct
类型传递给方法时,方法中所做的更改将对参数的副本进行更改。 这意味着任何设计为修改其参数的方法都应更新,以便对已经从ref
更改为class
的任何参数类型使用struct
修饰符。
该 SensorMeasurement
类型不包含任何更改状态的方法,因此这不是此示例中的问题。 可以通过将 readonly
修饰符添加到 SensorMeasurement
结构来证明这一点。
public readonly struct SensorMeasurement
编译器强制实施 readonly
结构的 SensorMeasurement
性质。 如果在检查代码时错过了某些修改状态的方法,编译器会告诉你。 你的应用编译没有错误,因此此类型为 readonly
。 添加readonly
修饰符在将类型从class
更改为struct
时,可以帮助查找哪些成员修改了struct
的状态。
避免创建副本
你已从应用中删除了大量不必要的分配。 该 SensorMeasurement
类型不会在任何位置显示在表中。
现在,每次将 SensorMeasurement
结构用作参数或返回值时,它都会执行额外的工作复制该结构。
SensorMeasurement
结构包含四个双精度值、一个 DateTime 和一个 string
。 该结构可测量地比参考值大。 让我们将 ref
或 in
修饰符添加到使用 SensorMeasurement
类型的位置。
下一步是查找返回度量的方法,或将度量值作为参数,并尽可能使用引用。 从 SensorMeasurement
结构开始。 静态 TakeMeasurement
方法创建并返回一个新的 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
};
}
我们将其保留原样,通过值返回。 如果尝试通过 ref
返回时,则会收到编译错误。 无法将 ref
返回到方法中本地创建的新结构。 不可变结构的设计意味着只能在构造时设置度量值。 此方法必须创建新的度量结构。
让我们再看一下 DebounceMeasurement.AddMeasurement
。 应将 in
修饰符添加到 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);
}
这可以避免一次复制操作。 该 in
参数是对调用方已创建的副本的引用。 您还可以使用TakeMeasurement
方法在Room
类型中保存副本。 此方法说明了编译器在传递 ref
自变量时如何提供安全性。
TakeMeasurement
类型中的初始 Room
方法采用 Func<SensorMeasurement, bool>
的参数。 如果尝试将in
或ref
修饰符添加到该声明,编译器将报告错误。 不能将 ref
参数传递给 lambda 表达式。 编译器无法保证调用的表达式不会复制引用。 如果 lambda 表达式 捕获 引用,则引用的生存期可能比引用的值长。 在 引用安全上下文 之外访问它将导致内存损坏。
ref
安全规则不允许它。 可以在 ref 安全功能概述中了解详细信息。
保留语义
最终的更改集不会对应用程序的性能产生重大影响,因为类型不会在热路径中创建。 这些更改说明了在性能优化中使用的一些其他技术。 让我们看看初始 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));
}
}
此类型包含多个属性。 有些是 class
类型。 创建对象 Room
涉及多个分配。 一个用于 Room
自身,还有一个用于它所包含的 class
类型的每个成员。 可以将这两个属性从class
类型转换为struct
类型:DebounceMeasurement
类型和AverageMeasurement
类型。 让我们使用这两种类型完成该转换。
将 DebounceMeasurement
类型从 a class
更改为 struct
。 这引入了编译器错误 CS8983: A 'struct' with field initializers must include an explicitly declared constructor
。 可以通过添加空无参数构造函数来解决此问题:
public DebounceMeasurement() { }
可以在有关 结构的语言参考文章中了解有关此要求的详细信息。
重写 Object.ToString() 不会修改结构的任何值。 可以将 readonly
修饰符添加到该方法声明中。
DebounceMeasurement
类型是可变的,因此需要注意修改不会影响已丢弃的副本。 该方法 AddMeasurement
会修改对象的状态。 在Room
方法中从TakeMeasurements
类调用它。 你希望在调用该方法后保留这些更改。 可以更改 Room.Debounce
属性以返回对 类型的单个实例的引用DebounceMeasurement
:
private DebounceMeasurement debounce = new();
public ref readonly DebounceMeasurement Debounce { get { return ref debounce; } }
上一个示例中有一些更改。 首先,属性是只读属性,它返回对此房间拥有的实例的只读引用。 它现在由实例化 Room
对象时初始化的声明字段提供支持。 进行这些更改后,你将更新 AddMeasurement
方法的实现。 它使用专用后盾字段, debounce
而不是只读属性 Debounce
。 这样,更改将在初始化期间创建的单个实例上发生。
同一技术适用于属性 Average
。 首先,将AverageMeasurement
类型从 class
修改为 struct
,并在readonly
方法上添加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}
""";
}
然后,按照用于修改 Room
属性的相同技术修改 Debounce
类。
Average
属性将 readonly ref
返回给平均度量值的专用字段。 该 AddMeasurement
方法修改内部字段。
private AverageMeasurement average = new();
public ref readonly AverageMeasurement Average { get { return ref average; } }
避免拳击
还有最后一个更改可以提高性能。 主要计划是打印会议室的统计数据,包括风险评估:
Console.WriteLine($"Current intruders: {room.Intruders}");
Console.WriteLine($"Calculated intruder risk: {room.RiskStatus}");
对生成的 ToString
的调用将枚举值封装起来。 可以通过在Room
类中编写一个重写函数,该函数根据估计风险的值来格式化字符串,从而避免这种情况。
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()}";
然后,修改主程序中的代码以调用此新 ToString
方法:
Console.WriteLine(room.ToString());
使用性能分析器运行应用,并查看已更新的分配表。
你已删除大量分配,提高了应用程序的性能。
在应用程序中使用 ref safety
这些技术是低级别性能优化。 当应用于热路径时,以及你度量了更改前后的影响时,它们可以提高应用程序的性能。 在大多数情况下,你将遵循的周期是:
- 度量分配:确定分配最多的类型,以及何时可以减少堆分配。
-
将类转换为结构:很多时候,类型可以从 a
class
转换为结构struct
。 您的应用程序使用栈空间,而不是进行堆内存分配。 -
保留语义:将 a
class
转换为struct
可能会影响参数和返回值的语义。 修改其参数的任何方法现在都应使用ref
修饰符标记这些参数。 这可确保对正确的对象进行修改。 同样,如果调用方应修改属性或方法返回值,则返回值应使用ref
修饰符进行标记。 -
避免复制:将大型结构作为参数传递时,可以使用修饰符标记参数
in
。 可以以更少的字节传递引用,并确保该方法不会修改原始值。 还可以通过readonly ref
返回值,以返回无法修改的引用。
使用这些技术,可以在代码的热路径中提高性能。