C#7.2——編寫安全高效的C#代碼


原文地址:https://docs.microsoft.com/zh-cn/dotnet/csharp/write-safe-efficient-code?view=netcore-2.1
值類型的優勢能避免堆分配。而劣勢就是往往伴隨的數據的拷貝。這就導致了在大量的值類型數據很難的最大化優化這些算法操作(因為伴隨着大量數據的拷貝)。而在C#7.2 中就提供了一種機制,它通過對值類型的引用來使代碼更加安全高效。使用這個特性能夠最大化的減小內存分配和數據復制操作。

這個新特性主要是以下幾個方面:

  1. 聲明一個 readonly struct 來表示這個類型是不變的,能讓編譯器當它做參數輸入時,會保存它的拷貝。
  2. 使用 ref readonly 。當返回一個值類型,且大於 IntPtr.Size 時以及存儲的生命周期要大於這方法返回的值的時候。
  3. 當用 readonly struct 修飾的變量/類大小大於 IntPtr.Size ,那么就應該作為參數輸入來傳遞它來提高性能。
  4. 除非用 readonly 修飾符來聲明,永遠不要傳遞一個 struct 作為一個輸入參數(in parameter),因為它可能會產生副作用,從而導致它的行為變得模糊。
  5. 使用 ref struct 或者 readonly ref struct,例如 SpanReadOnlySpan 以字節流的形式來處理內存。

這些技術你要面對權衡這值類型和引用類型這兩個方面帶來的影響。引用類型的變量會分配內存到堆內存上。值類型變量只包含值。他們兩個對於管理資源內存來說都是重要的。值類型當傳遞到一個方法或是從方法中返回時都會拷貝數據。當調用這個值類型的成員時還會拷貝該值類型的值( This behavior includes copying the value of this when calling members of a value type. )。這個開銷視這個值類型對象數據的大小而定。引用類型是分配在堆內存的,每一個新的對象都會重新分配內存到堆上。這兩個(值類型和引用)操作都會花費時間。

readonly struct來申明一個不變的值類型結構

用 readonly 修飾符聲明一個結構體,編譯器會知道你的目的就是建立一個不變的結構體類型。編譯器就會根據兩個規則來執行這個設計決定:

  1. 所有的字段必須是只讀的 readonly。
  2. 所有的屬性必須是只讀的 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 關鍵字補充了已經存在的 refout 關鍵字來按引用傳遞參數。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 修飾符也很好的補充了 outref 其他方面。你不能創建僅修飾符(in,out,ref)不同的方法重載。這個新的特性拓展了已經存在 outref 參數原來相同的行為。像 refout 修飾符,值類型由於應用了 in 修飾符而無法裝箱。

in 修飾符能應用在任何成員信息上:方法,委托,lambda表達式,本地函數,索引,操作符。

in 修飾符還有在其他方面的特性,在參數上用 in 修飾的參數值你能使用字面量的值或者常數。不像 refout 參數,你不必在調用方用 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 structreadonly struct 兩者的收益。通過只讀的元素內存被限制在單個的棧中,並且只讀元素內存無法被修改。

總結

使用值類型能最小化的內存分配:

  • 在局部變量和方法參數中值類型存儲在棧上分配
  • 對象的值類型成員做為這個對象的一部分分配在棧上,並不是一個單獨的分配操作。
  • 存儲返回的值類型是在棧上分配

不同於引用類型在相同場景下:

  • 存儲局部變量和方法參數的引用類型分配在堆上,。引用存在棧。
  • 存儲對象的成員變量是引用類型,它作為這個對象的一部分在堆上分配內存。而不是單獨的分配這個引用。
  • 存儲返回的值是引用類型,堆分配內存。存儲引用的值存儲在棧上。

最小化的內存分配要權衡。當結構體內存大小超過引用大小時,就要拷貝更多的內存。一個引用類型指定 64 字節或者是 32 字節,它取決於平台架構。

這些權衡/折中通常對性能影響很小。然而大對象結構體或大對象集合,對性能影響是遞增的。特別在循環和經常調用的地方影響特別明顯。

這些C#語言的增強是為了關鍵算法的性能而設計的,內存分配問題成為了主要的優化點。你會發現你無需經常使用這些特性在你寫的代碼中。然而,這些增強在 .NET 中接受。越來越多的 API 會運用到這些特性,你將看到你的應用程序性能的提升。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM