教程:通过 ref safety 减少内存分配

通常,.NET 应用程序的性能优化涉及两种方法。 首先,减少堆分配的数量和大小。 其次,减少复制数据的频率。 Visual Studio 提供了出色的工具,可帮助分析应用程序如何使用内存。 确定应用在何处进行了不必要的分配后,可以进行更改以最大程度地减少这些分配。 将 class 类型转换为 struct 类型。 使用 ref safety 功能来保留语义并最大程度地减少额外的复制。

使用 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 对象分配跟踪”选项。 运行应用程序以完成。 探查器度量对象分配,并报告分配和垃圾回收周期。 应看到类似于以下图像的图:

Allocation graph for running the intruder alert app before any optimizations.

上图显示,尽量减少分配将带来性能优势。 在实时对象图中可以看到锯齿图像。 这说明创建了大量对象,而这些对象很快就变为垃圾。 稍后会收集这些对象,如对象增量图中所示。 向下的红色条形表示垃圾回收周期。

接下来,请查看图表下方的“分配”选项卡。 下表显示分配最多的类型:

Chart that shows which types are allocated most frequently.

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 度量值。 它比要在热路径中复制的要大。 但是,该决定意味着大量的分配。 将类型从 class 更改为 struct

class 更改为 struct 会引起一些编译器错误,因为原始代码在几个位置使用了 null 引用检查。 第一个在 AddMeasurement 方法的 DebounceMeasurement 类中:

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;

现在,SensorMeasurementstruct,而不是 class,可为空是可以为 null 的值类型。 可以将声明更改为值类型,以修复剩余的编译器错误:

SensorMeasurement measure = default;

解决编译器错误后,应检查代码以确保语义未更改。 由于 struct 类型是通过值传递的,因此在方法返回后,对方法参数所做的修改将不可见。

重要

将类型从 class 更改为 struct 可能会更改程序的语义。 将 class 类型传递给方法时,方法中所做的任何更改都对参数进行更改。 将 struct 类型传递给方法时,方法中所做的更改将对参数的副本进行更改。 这意味着任何通过设计修改其参数的方法都应更新为在已从 class 更改为 struct 的任何参数类型上使用 ref 修饰符。

SensorMeasurement 类型不包含任何更改状态的方法,因此在本示例中不考虑此问题。 可以通过向 SensorMeasurement 结构添加 readonly 修饰符来证明这一点:

public readonly struct SensorMeasurement

编译器强制实施 SensorMeasurement 结构的 readonly 性质。 如果检查代码时错过了某个修改状态的方法,编译器会告诉你。 应用仍会生成,且不会出错,因此此类型为 readonly。 在将类型从 class 更改为 struct 时添加 readonly 修饰符有助于查找修改 struct 状态的成员。

避免创建副本

你已从应用中删除了大量不必要的分配。 SensorMeasurement 类型不会显示在表中的任何位置。

现在,每次将 SensorMeasurement 结构用作参数或返回值时,它都会执行额外的工作复制该结构。 SensorMeasurement 结构包含四个双精度值、一个 DateTime 和一个 string。 该结构明显大于引用。 让我们将 refin 修饰符添加到使用 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 参数是对调用方已创建的副本的引用。 还可以使用 Room 类型中的 TakeMeasurement 方法保存副本。 此方法演示了在通过 ref 传递参数时编译器如何提供安全性。 Room 类型中的初始 TakeMeasurement 方法采用 Func<SensorMeasurement, bool> 的参数。 如果尝试向该声明添加 inref 修饰符,编译器将报告错误。 不能将 ref 参数传递给 Lambda 表达式。 编译器无法保证调用的表达式不会复制引用。 如果 Lambda 表达式捕获引用,则引用的生存期可能比它所引用的值长。 在其引用安全上下文范围之外对它进行访问会导致内存损坏。 ref 安全规则不允许。 可以在 ref safety 功能概述中了解详细信息。

保留语义

最后一组更改不会对此应用程序的性能产生重大影响,因为这些类型不是在热路径中创建的。 这些更改演示了可在性能优化中使用的一些其他方法。 让我们看一下初始 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 类型:DebounceMeasurementAverageMeasurement 类型。 让我们使用这两种类型完成该转换。

DebounceMeasurement 类型从 class 更改为 struct。 这会导致编译器错误 CS8983: A 'struct' with field initializers must include an explicitly declared constructor。 可以通过添加空的无参数构造函数来解决此问题:

public DebounceMeasurement() { }

有关此要求的详细信息,请参阅有关的语言参考文章。

Object.ToString() 替代不会修改结构的任何值。 可向该方法声明添加 readonly 修饰符。 DebounceMeasurement 类型是可变的,因此需要注意修改不会影响已丢弃的副本。 AddMeasurement 方法会修改对象的状态。 它在 TakeMeasurements 方法中从 Room 类中调用。 你希望在调用该方法后保留这些更改。 可以更改 Room.Debounce 属性以返回对 DebounceMeasurement 类型的单个实例的引用:

private DebounceMeasurement debounce = new();
public ref readonly DebounceMeasurement Debounce { get { return ref debounce; } }

上一个示例中有一些更改。 首先,属性是只读属性,它返回对此房间拥有的实例的只读引用。 它现在由实例化 Room 对象时初始化的声明字段提供支持。 进行这些更改后,你将更新 AddMeasurement 方法的实现。 它使用专用支持字段 debounce,而不是只读属性 Debounce。 这样,这些更改将发生在初始化期间创建的单个实例上。

相同的方法适用于 Average 属性。 首先,将 AverageMeasurement 类型从 class 修改为 struct,并在 ToString 方法上添加 readonly 修饰符:

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

然后,按照对 Debounce 属性使用的相同方法修改 Room 类。 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());

使用探查器运行应用,并查看更新的表了解分配。

Allocation graph for running the intruder alert app after modifications.

你已删除大量分配,并为应用提供了性能提升。

在应用程序中使用 ref safety

这些方法是低级别性能优化。 当应用于热路径时,以及你度量了更改前后的影响时,它们可以提高应用程序的性能。 在大多数情况下,你将遵循的周期是:

  • 度量分配:确定分配最多的类型,以及何时可以减少堆分配。
  • 将类转换为结构:很多时候,类型可以从 class 转换为 struct。 你的应用使用堆栈空间,而不是进行堆分配。
  • 保留语义:将 class 转换为 struct 可能会影响参数和返回值的语义。 任何修改其参数的方法现在都应使用 ref 修饰符标记这些参数。 这可确保对正确的对象进行修改。 同样,如果属性或方法返回值应由调用方修改,则应使用 ref 修饰符标记该返回值。
  • 避免复制:将大型结构作为参数传递时,可以使用 in 修饰符标记参数。 可以传递字节较少的引用,并确保方法不会修改原始值。 还可以通过 readonly ref 返回值,以返回无法修改的引用。

使用这些方法可以提高代码热路径的性能。