教學課程:使用安全機制減少記憶體分配
通常,.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度量。 它比需要在執行效能關鍵路徑中複製的理想大小還要大。 不過,該決定意味著大量的分配。 將 型別從 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 類型變更為結構之後,您必須使用不同的測試方法。 這個 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類型被傳遞至方法時,方法中對自變數所做的修改會作用在該自變數的複製品上。 這表示任何依設計修改其參數的方法都應該更新,以在您已從 ref 變更為 class 的任何參數類型上使用 struct 修飾。
此 SensorMeasurement 類型不包含任何變更狀態的方法,因此這不是此範例中的問題。 您可以藉由將 readonly 修飾詞新增至 SensorMeasurement 結構來證明:
public readonly struct SensorMeasurement
編譯程式會強制執行 readonlySensorMeasurement 結構體的本質。 如果您的程式代碼檢查遺漏了某些修改狀態的方法,編譯程式會告訴您。 您的應用程式仍會建置而不會發生錯誤,因此此類型為 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安全規則不允許。 您可以在 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 型別從 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
這些技術是低階效能微調。 當您套用至熱點路徑並在變更前後測量影響時,它們會提升應用程式的效能。 在大部分情況下,您將會遵循的迴圈如下:
- 量值配置:決定配置最多的類型,以及何時可減少堆積配置。
-
將類別轉換成結構:許多時候,型別會從
class轉換為struct。 您的應用程式會使用堆疊空間,而不是進行堆積配置。 -
保留語意:將
classstruct轉換成 可能會影響參數和傳回值的語意。 任何修改其參數的方法現在都應該使用ref修飾詞標記這些參數。 這可確保對正確的物件進行修改。 同樣地,如果呼叫端應該修改屬性或方法傳回值,該傳回應該以ref修飾詞標示。 -
避免複製:當您將大型結構當做參數傳遞時,您可以使用 修飾詞標記參數
in。 您可以以較少的位元組傳遞參考,並確保方法不會修改原始值。 您也可以傳readonly ref回值,以傳回無法修改的參考。
使用這些技術,您可以改善程式碼熱路徑中的效能。