共用方式為


教學課程:使用 ref 安全性減少記憶體配置

.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 物件配置追蹤] 選項,將其他選項保留空白。 執行您的應用程式以完成流程。 分析工具會測量物件配置,並報告配置和記憶體回收週期。 您應該會看到類似下圖的圖表:

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

上一個圖表顯示,如果將配置降到最低,將會提供效能優勢。 您會在即時物件圖中看到鋸齒圖樣。 這表示有大量建立的物件迅速成為無用記憶體, 隨後遭到回收,如物件差異圖表所示。 向下的紅色長條表示記憶體回收週期。

接下來,查看圖表下方的 [配置] 索引標籤。 下表會顯示配置最多的類型:

Chart that shows which types are allocated most frequently.

System.String 型別使用大部分的配置。 最重要的工作應該是將 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 都會產生堆積配置。

將 class 變更為 struct

下列程式碼顯示 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 參考檢查。 第一個位於 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 類型變更為 struct 之後,您必須使用不同的測試。 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 是「可為 Null 的實值型別」。 您可以將宣告變更為實值型別,藉此修正其餘編譯器錯誤:

SensorMeasurement measure = default;

現在已解決編譯器錯誤,您應該檢查程式碼以確保語意未變更。 由於 struct 類型會以值傳遞,所以在方法傳回之後,不會顯示對方法參數所做的修改。

重要

將類型從 class 變更為 struct 可變更程式的語意。 將 class 型別傳遞至方法時,在方法中所做的任何變動都會套用至引數。 將 struct 型別傳遞至方法時,在方法中所做的任何變動都會套用至引數的「複本」。 這表示任何依設計修改其引數的方法都應該更新,才能在您已從 class 變更為 struct 的任何引數類型上使用 ref 修飾元。

SensorMeasurement 型別不包含任何變更狀態的方法,因此在本範例中無須擔心此問題。 您可以藉由將 readonly 修飾元新增至 SensorMeasurement 結構來證明這點:

public readonly struct SensorMeasurement

編譯器會強制執行 SensorMeasurement 結構的 readonly 性質。 如果您在檢查程式碼時遺漏了一些已修改狀態的方法,編譯器會告訴您。 您的應用程式仍會建置而不會發生錯誤,所以此類型為 readonly。 當您將型別從 class 變更為 struct 時,請新增 readonly 修飾詞以協助您尋找修改 struct 狀態的成員。

避免建立複本

您已從應用程式中移除大量不必要的配置。 SensorMeasurement 型別不會出現在資料表中的任何位置。

現在,每當其做為參數或傳回值時,就會產生 SensorMeasurement 結構的額外工作複本。 SensorMeasurement 結構包含四個雙精度浮點數,DateTimestring。 該結構明顯大於參考。 讓我們將 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 安全規則不允許此行為。 您可以在 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 型別: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) 所擁有執行個體的唯讀參考。 該屬性現在是由具現化 Room 物件時初始化的宣告欄位所支援。 進行這些變更之後,您需要更新 AddMeasurement 方法的實作。 此方法會使用私用備份欄位 (debounce),而不是 readonly 屬性 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; } }

避免 Boxing

還有一個最終變更可改善效能。 主要程式負責列印房間的統計資料,包括風險評估:

Console.WriteLine($"Current intruders: {room.Intruders}");
Console.WriteLine($"Calculated intruder risk: {room.RiskStatus}");

呼叫產生的 ToString 會將列舉值裝箱 (Box)。 若要避免此情況,您可以在 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 安全性

這些技巧是低階的效能微調。 將這些技巧套用至最忙碌路徑,以及在變更前後測量過影響時,將可增加應用程式的效能。 在大部分情況下,您將遵循的循環如下:

  • 測量配置:判斷配置最多的類型,以及何時可以減少堆積配置。
  • 將類別轉換成結構:在許多情況下,類型可以從 class 轉換為 struct。 您的應用程式會使用堆疊空間,而不是進行堆積配置。
  • 保留語意:將 class 轉換成 struct 可能會影響參數和傳回值的語意。 任何修改其參數的方法現在都應該使用 ref 修飾元來標記這些參數。 這可確保對正確的物件進行修改。 同樣地,如果呼叫端應該修改屬性或方法傳回值,該傳回應該以 ref 修飾元標示。
  • 避免複製:當您將大型結構傳遞為參數時,可以使用 in 修飾元標記參數。 您可以用較少的位元組傳遞參考,並確保方法不會修改原始值。 您也可以透過 readonly ref 傳回值,藉此傳回無法修改的參考。

運用這些技巧,您便可改善程式碼最忙碌路徑中的效能。