原文地址:https://docs.microsoft.com/zh cn/dotnet/csharp/write safe efficient code?view=netcore 2.1 值類型的優勢能避免堆分配。而劣勢就是往往伴隨的數據的拷貝。這就導致了在大量的值類型數據很難的最大化優化這些演算法 ...
原文地址:https://docs.microsoft.com/zh-cn/dotnet/csharp/write-safe-efficient-code?view=netcore-2.1
值類型的優勢能避免堆分配。而劣勢就是往往伴隨的數據的拷貝。這就導致了在大量的值類型數據很難的最大化優化這些演算法操作(因為伴隨著大量數據的拷貝)。而在C#7.2 中就提供了一種機制,它通過對值類型的引用來使代碼更加安全高效。使用這個特性能夠最大化的減小記憶體分配和數據複製操作。
這個新特性主要是以下幾個方面:
- 聲明一個
readonly struct
來表示這個類型是不變的,能讓編譯器當它做參數輸入時,會保存它的拷貝。 - 使用
ref readonly
。當返回一個值類型,且大於 IntPtr.Size 時以及存儲的生命周期要大於這方法返回的值的時候。 - 當用
readonly struct
修飾的變數/類大小大於 IntPtr.Size ,那麼就應該作為參數輸入來傳遞它來提高性能。 - 除非用
readonly
修飾符來聲明,永遠不要傳遞一個struct
作為一個輸入參數(in parameter
),因為它可能會產生副作用,從而導致它的行為變得模糊。 - 使用
ref struct
或者readonly ref struct
,例如 Span 或 ReadOnlySpan 以位元組流的形式來處理記憶體。
這些技術你要面對權衡這值類型和引用類型這兩個方面帶來的影響。引用類型的變數會分配記憶體到堆記憶體上。值類型變數只包含值。他們兩個對於管理資源記憶體來說都是重要的。值類型當傳遞到一個方法或是從方法中返回時都會拷貝數據。這個行為還包括拷貝值類型成員時,該值的值( This behavior includes copying the value of this
when calling members of a value type. )。這個開銷視這個值類型對象數據的大小而定。引用類型是分配在堆記憶體的,每一個新的對象都會重新分配記憶體到堆上。這兩個(值類型和引用)操作都會花費時間。
readonly struct
來申明一個不變的值類型結構
用 readonly 修飾符聲明一個結構體,編譯器會知道你的目的就是建立一個不變的結構體類型。編譯器就會根據兩個規則來執行這個設計決定:
- 所有的欄位必須是只讀的 readonly。
- 所有的屬性必須是只讀的 readonly,包括自動實現屬性。
以上兩條足已確保沒有readonly struct 修飾符的成員來修改結構的狀態—— struct 是不變的
readonly public struct ReadonlyPoint3D {
public ReadonlyPoint3D (double x, double y, double z) {
this.X = x;
this.Y = y;
this.Z = z;
}
public double X { get; }
public double Y { get; }
public double Z { get; }
}
儘可能面對大對象結構體使用 ref readonly struct
語句
當這個值不是這個返回方法的本地值時,可以通過引用返回值。通過引用返回的意思是說只拷貝了它的引用,而不是整個結構。下麵的例子中 Origin
屬性不能使用 ref
返回,因為這個值是正在返回的本地變數:
public ReadonlyPoint3D Origin => new ReadonlyPoint3D(0,0,0);
然而,下麵這個例子的屬性就能按引用返回,因為返回的值一個靜態成員:
private static ReadonlyPoint3D origin = new ReadonlyPoint3D(0,0,0);
//註意:這裡返回是內部存儲的易變的引用
public ref ReadonlyPoint3D Origin => ref origin;
你如果不想調用者修改原始值,你可以通過 readonly ref
來修飾返回值:
public ref readonly ReadonlyPoint3D Origin3 => ref origin;
返回 ref readonly
能夠讓你保存大對象結構的引用以及能夠保護你內部不變的成員數據。
作為調用方,調用者能夠選擇 Origin
屬性是作為一個值還是 按引用只讀的值(ref readonly
):
var originValue = Point3D.Origin;
ref readonly var originReference = ref Point3D.Origin;
在上面這段代碼的第一行,把 Point3D 的原始屬性的常數值 Origin
拷貝並複製數據給originValue。第二段代碼只分配了引用。要註意,readonly
修飾符必須是聲明這個變數的一部分。因為這個引用是不允許被修改的。不然,就會引起編譯器編譯錯誤。
readonly
修飾符在申明的 originReference 是必須的。
編譯器要求調用者不能修改引用。企圖直接修改該值會引發編譯器的錯誤。然而,編譯器卻無法知道成員方法修改了結構的狀態。為了確定對象沒有被修改,編譯器會創建一個副本並用它來調用成員信息的引用。任何修改都是對防禦副本(defensive copy)的修改。
對大於 System.IntPtr.Size
的參數應用 in
修飾符到 readonly struct
in
關鍵字補充了已經存在的 ref
和 out
關鍵字來按引用傳遞參數。in
關鍵字也是按引用傳遞參數,但是調用這個參數的方法不能修改這個值。
值類型作為方法簽名參數傳到調用的方法中,且沒有用下麵的修飾符時,是會發生拷貝操作的。每一個修飾符指定這個變數是按引用傳遞的,避免了拷貝。以及每個修飾符都表達不同的意圖:
out
:這個方法設置參數的值來作為參數。ref
:這個方法也可以設置參數的值來作為參數。in
:這個方法作為參數無法修改這個參數的值。
增加 in
修飾符按引用傳遞參數以及申明通過按引用傳值來避免數據的拷貝的意圖。說明你不打算修改這個作為參數的對象。
對於只讀的那些大小超過 IntPtr.Size
的值類型來說,這個經驗經常能提高性能。例如有這些值類型(sbyte,byte,short,ushort,int,uint,long,ulong,char,float,double,decimal 以及 bool 和 enum),任何潛在的性能收益都是很小的。實際上,如果對於小於 IntPtr.Size
的類型使用按引用個傳遞,性能可能會下降。
下麵這段 demo 展示了計算兩個點的3D空間的距離
public static double CalculateDistance ( in Point3D point1, in Point3D point2) {
double xDifference = point1.X - point2.X;
double yDifference = point1.Y - point2.Y;
double zDifference = point1.Z - point2.Z;
return Math.Sqrt (xDifference * xDifference + yDifference * yDifference + zDifference * zDifference);
}
這個方法有兩個參數結構體,每個都有三個 double
欄位。1個 double 8 個位元組,所以每個參數含有 24 位元組。通過指定 in
修飾符,你傳遞了 4 個位元組或 8 個位元組的參數引用,4 還是 8位元組取決平臺結構(32位 一個引用 2 位元組,64位一個引用 4位元組)。這看似大小差異很小,但是當你的應用程式在高併發,高迴圈的情況下調用這個函數,那麼性能上的差距就很明顯了。
in
修飾符也很好的補充了 out
和 ref
其他方面。你不能創建僅修飾符(in,out,ref)不同的方法重載。這個新的特性拓展了已經存在 out
和 ref
參數原來相同的行為。像 ref
和 out
修飾符,值類型由於應用了 in
修飾符而無法裝箱。
in
修飾符能應用在任何成員信息上:方法,委托,lambda表達式,本地函數,索引,操作符。
in
修飾符還有在其他方面的特性,在參數上用 in
修飾的參數值你能使用字面量的值或者常數。不像 ref
和 out
參數,你不必在調用方用 in
。下麵這段代碼展示了兩個調用 CalculateDistance
的方法。第一個變數使用兩個按引用傳遞的局部變數。第二個包括了作為這個方法調用的一部分創建的臨時變數。
var distance = CalculateDistance (point1,point2);
var fromOrigin = CalculateDistance(point1,new Point3D());
這裡有一些方法,編譯器會強制執行 read-only 簽名的 in 參數。第一個,被調用的方法不能直接分配一個 in 參數。它不能分配到任何 in 欄位,當這個值是值類型的時候。另外,你也不能通過 ref 和 out 修飾符來傳遞一個 in 參數到任何方法上。這些規則都應用在 in 修飾符的參數,前提是提供一個值類型的欄位以及這個參數也是值類型的。事實上,這些規則適用於多個成員訪問,前提是所有級別的成員訪問的類型都是結構體。編譯器強制執行在參數中傳遞的 struct 類型,當它們的 struct 成員用作其他方法的參數時,它們是只讀變數。
使用 in 參數能避免潛在拷貝方面的性能開銷。它不會改變任何方法調用的語義。因此,你無需在調用方(call site)指定 in 修飾符。在調用站省略 in 修飾符會讓編譯器進行參數拷貝操作,有以下幾種原因:
- 存在隱式轉換,但不存在從參數類型到參數類型的標識轉換。
- 參數是一個表達式,但是沒有已知的存儲變數。
- 存在一個不同於已經存在或者是不存在 in 的重載。這種情況下,通過值重載會更好匹配。
這些規則當你更新那些已有的並且已經用 read-only 引用參數的代碼非常有用。在調用方法裡面,你可以通過值參數(value paramters)調用任意成員方法。在那些實例中,會拷貝 in 參數。因為編譯器會對 in 參數創建一個臨時的變數,你可以用 in 指定預設參數的值。下麵這段代碼指定了origins(point 0,0)作為預設值作為第二個參數:
private static double CalculateDistance2 ( in Point3D point1, in Point3D point2 = default) {
double xDifference = point1.X - point2.X;
double yDifference = point1.Y - point2.Y;
double zDifference = point1.Z - point2.Z;
return Math.Sqrt (xDifference * xDifference + yDifference * yDifference + zDifference * zDifference);
}
編譯器會通過引用傳遞只讀參數,指定 in 修飾符在調用方法的參數上,就像下麵展示的代碼:
private static void DemoCalculateDistanceForExplicit (Point3D point1, Point3D point2) {
var distance = CalculateDistance ( in point1, in point2);
distance = CalculateDistance ( in point1, new Point3D ());
distance = CalculateDistance (point1, in Point3D.origin);
}
這種行為能夠更容易的接受 in 參數,隨著時間的推移,大型代碼庫中性能會獲得提高。首先就要添加 in 到方法簽名上。然後你可以在調用端添加 in 修飾符以及新建一個 readonly struct
類型來使編譯器避免在更多未知創建防禦拷貝的副本。
in 參數被設計也能使用在引用類型或數字值。然而,在這種情況的性能收益是很小的。
不要使用易變的結構體作為 in 參數
下麵描述的技術主要解釋了怎樣通過返回引用以及傳遞的值引用避免數據拷貝。當參數類型是已經申明的 readonly struct
類型時,這些技術都能很好的工作。否則,編譯器在很多非只讀參數的場景下必須新建一個防禦拷貝(defensive copies)副本。考慮下麵這段代碼,他計算 3D 點到原地=點的距離:
private static double CalculateDistance ( in Point3D point1, in Point3D point2 = default) {
double xDifference = point1.X - point2.X;
double yDifference = point1.Y - point2.Y;
double zDifference = point1.Z - point2.Z;
return Math.Sqrt (xDifference * xDifference + yDifference * yDifference + zDifference * zDifference);
}
Point3D
是非只讀結構類型(readonly-ness struct)。在這個方法體中,有 6 個不同的屬性訪問調用。第一次檢查時,你可能覺得這些訪問都是安全的。在這之後,一個 get 讀取器不能修改這個對象的狀態。但是這裡沒有語義規則讓編譯器這樣做。它只是一個通用的約束。任何類型都能實現 get 讀取器來修改這個內部狀態。沒有這些語言保證,在調用任何成員之前,編譯器必須新建這個參數的拷貝副本來作為臨時變數。這個臨時變數存儲在棧上,這個參數的值的副本在這個臨時變數中存儲,並且每個成員訪問的值都會拷貝到棧上,作為參數。在很多情況下,當參數類型不是 readonly struct
時,這些拷貝都會對性能有害,以至於通過值傳遞要比通過只讀引用(readonly reference)傳遞快。
相反,如果距離計算方法使用不變結構,ReadonlyPoint3D
,就不需要臨時變數:
private static double CalculateDistance3(in ReadonlyPoint3D point1, in ReadonlyPoint3D point2 = default)
{
double xDifference = point1.X - point2.X;
double yDifference = point1.Y - point2.Y;
double zDifference = point1.Z - point2.Z;
return Math.Sqrt(xDifference * xDifference + yDifference * yDifference + zDifference * zDifference);
}
當你用 readonly struct
修飾的成員時,編譯器會自動生成更多高效代碼:this
引用,而不是接受者的副本拷貝,in 參數總是按引用傳遞到成員方法中。當你使用 readonly struct
作為 in 參數時,這種優化會節省記憶體。
你可以查看程式的demo,在實例代碼倉庫 samples repository 中,它展示了使用 Benchmark.net 比較性能的差異。它比較了傳遞易變結構的值和引用,易變結構的按值傳遞和按引用傳遞。使用不變結構體的按引用傳遞是最快的。
使用 ref struct
類型在單個堆棧幀上處理塊和記憶體
一個語言相關的特性是申明值類型的能力,該值類型必須約束在單個堆棧對上。這個限制能讓編譯器做一些優化。主要推動這個特性體檢在 Span<T>
以及相關的結構。你從使用這些新添加的以及更新的.NET API,如 Span<T>
類型來完成性能的提升。
你可能有相同的要求,在記憶體中使用 stackalloc
或者當使用來自於記憶體的交互操作API。你就為這些需求能定義你自己的 ref struct 類型。
readonly ref struct
類型
聲明一個 readonly ref
結構體,它聯合了 ref struct
和 readonly struct
兩者的收益。通過只讀的元素記憶體被限制在單個的棧中,並且只讀元素記憶體無法被修改。
總結
使用值類型能最小化的記憶體分配:
- 在局部變數和方法參數中值類型存儲在棧上分配
- 對象的值類型成員做為這個對象的一部分分配在棧上,並不是一個單獨的分配操作。
- 存儲返回的值類型是在棧上分配
不同於引用類型在相同場景下:
- 存儲局部變數和方法參數的引用類型分配在堆上,。引用存在棧。
- 存儲對象的成員變數是引用類型,它作為這個對象的一部分在堆上分配記憶體。而不是單獨的分配這個引用。
- 存儲返回的值是引用類型,堆分配記憶體。存儲引用的值存儲在棧上。
最小化的記憶體分配要權衡。當結構體記憶體大小超過引用大小時,就要拷貝更多的記憶體。一個引用類型指定 64 位元組或者是 32 位元組,它取決於平臺架構。
這些權衡/折中通常對性能影響很小。然而大對象結構體或大對象集合,對性能影響是遞增的。特別在迴圈和經常調用的地方影響特別明顯。
這些C#語言的增強是為了關鍵演算法的性能而設計的,記憶體分配問題成為了主要的優化點。你會發現你無需經常使用這些特性在你寫的代碼中。然而,這些增強在 .NET 中接受。越來越多的 API 會運用到這些特性,你將看到你的應用程式性能的提升。