.NET性能優化-使用結構體替代類

来源:https://www.cnblogs.com/InCerry/archive/2022/05/05/Dotnet-Opt-Perf-Use-Struct-Instead-Of-Class.html
-Advertisement-
Play Games

前言 我們知道在C#和Java明顯的一個區別就是C#可以自定義值類型,也就是今天的主角struct,我們有了更加方便的class為什麼微軟還加入了struct呢?這其實就是今天要談到的一個優化性能的Tips使用結構體替代類。 那麼使用結構體替代類有什麼好處呢?在什麼樣的場景需要使用結構體來替代類呢? ...


前言

我們知道在C#和Java明顯的一個區別就是C#可以自定義值類型,也就是今天的主角struct,我們有了更加方便的class為什麼微軟還加入了struct呢?這其實就是今天要談到的一個優化性能的Tips使用結構體替代類
那麼使用結構體替代類有什麼好處呢?在什麼樣的場景需要使用結構體來替代類呢?今天的文章為大家一一解答。
註意:本文全部都以x64位平臺為例

現實的案例

舉一個現實系統的例子,大家都知道機票購票的流程,開始選擇起抵城市和機場(這是航線),然後根據自己的需要日期和時間,挑一個自己喜歡的航班和艙位,然後付款。

記憶體占用

那麼全國大約49航司,8000多個航線,平均每個航線有20個航班,每個航班平均有10組艙位價格(經濟艙、頭等還有不同的折扣權益),一般OTA(Online Travel Agency:線上旅游平臺)允許預訂一年內的機票。也就是說平臺可能有8000*20*10*365=~5億的價格數據(以上數據均來源網路,實際中的數據量不方便透露)。
OTA平臺為了能讓你更快的搜索想要的航班,會將熱門的航線價格數據從資料庫拿出來緩存在記憶體中(記憶體比單獨網路和磁碟傳輸快的多得多,詳情見下圖),就取20%也大約有1億數據在記憶體中。

操作 速度
執行指令 1/1,000,000,000 秒 = 1 納秒
從一級緩存讀取數據 0.5 納秒
分支預測失敗 5 納秒
從二級緩存讀取數據 7 納秒
使用Mutex加鎖和解鎖 25 納秒
從主存(RAM記憶體)中讀取數據 100 納秒
在1Gbps速率的網路上發送2Kbyte的數據 20,000 納秒
從記憶體中讀取1MB的數據 250,000 納秒
磁頭移動到新的位置(代指機械硬碟) 8,000,000 納秒
從磁碟中讀取1MB的數據 20,000,000 納秒
發送一個數據包從美國到歐洲然後回來 150 毫秒 = 150,000,000 納秒

假設我們有如下一個類,類裡面有這些屬性(現實中要複雜的多,而且會分航線、日期等各個維度存儲,而且不同航班有不同的售賣規則,這裡演示方便忽略),那麼這1億數據緩存在記憶體中需要多少空間呢?

public class FlightPriceClass
{
    /// <summary>
    /// 航司二字碼 如 中國國際航空股份有限公司:CA
    /// </summary>
    public string Airline { get; set; }

    /// <summary>
    /// 起始機場三字碼 如 上海虹橋國際機場:SHA
    /// </summary>
    public string Start { get; set; }

    /// <summary>
    /// 抵達機場三字碼 如 北京首都國際機場:PEK
    /// </summary>
    public string End { get; set; }

    /// <summary>
    /// 航班號 如 CA0001
    /// </summary>
    public string FlightNo { get; set; }

    /// <summary>
    /// 艙位代碼 如 Y
    /// </summary>
    public string Cabin { get; set; }

    /// <summary>
    /// 價格 單位:元
    /// </summary>
    public decimal Price { get; set; }

    /// <summary>
    /// 起飛日期 如 2017-01-01
    /// </summary>
    public DateOnly DepDate { get; set; }

    /// <summary>
    /// 起飛時間 如 08:00
    /// </summary>
    public TimeOnly DepTime { get; set; }

    /// <summary>
    /// 抵達日期 如 2017-01-01
    /// </summary>
    public DateOnly ArrDate { get; set; }

    /// <summary>
    /// 抵達時間 如 08:00
    /// </summary>
    public TimeOnly ArrTime { get; set; }
}

我們可以寫一個Benchmark,來看看100W的數據需要多少空間,然後在推導出1億的數據

// 隨機預先生成100W的數據 避免計算邏輯導致結果不准確
public static readonly FlightPriceClass[] FlightPrices = Enumerable.Range(0,
        100_0000
    ).Select(index =>
        new FlightPriceClass
        {
            Airline = $"C{(char)(index % 26 + 'A')}",
            Start = $"SH{(char)(index % 26 + 'A')}",
            End = $"PE{(char)(index % 26 + 'A')}",
            FlightNo = $"{index % 1000:0000}",
            Cabin = $"{(char)(index % 26 + 'A')}",
            Price = index % 1000,
            DepDate = DateOnly.FromDateTime(BaseTime.AddHours(index)),
            DepTime = TimeOnly.FromDateTime(BaseTime.AddHours(index)),
            ArrDate = DateOnly.FromDateTime(BaseTime.AddHours(3 + index)),
            ArrTime = TimeOnly.FromDateTime(BaseTime.AddHours(3 + index)),
        }).ToArray();

// 使用類來存儲
[Benchmakr]
public FlightPriceClass[] GetClassStore()
{
    var arrays = new FlightPriceClass[FlightPrices.Length];
    for (int i = 0; i < FlightPrices.Length; i++)
    {
        var item = FlightPrices[i];
        arrays[i] = new FlightPriceClass
        {
            Airline = item.Airline,
            Start = item.Start,
            End = item.End,
            FlightNo = item.FlightNo,
            Cabin = item.Cabin,
            Price = item.Price,
            DepDate = item.DepDate,
            DepTime = item.DepTime,
            ArrDate = item.ArrDate,
            ArrTime = item.ArrTime
        };
    }
    return arrays;
}

來看看最終的結果,圖片如下所示。

從上面的圖可以看出來100W數據大約需要107MB的記憶體存儲,那麼一個占用對象大約就是112byte了,那麼一億的對象就是約等於10.4GB。這個大小已經比較大了,那麼還有沒有更多的方案可以減少一些記憶體占用呢?有小伙伴就說了一些方案。

  • 可以用int來編號字元串
  • 可以使用long來存儲時間戳
  • 可以想辦法用zip之類演算法壓縮一下
  • 等等
    我們暫時也不用這些方法,對照本文的的標題,大家應該能想到用什麼辦法,嘿嘿,那就是使用結構體來替代類,我們定義了一個一樣的結構體,如下所示。
[StructLayout(LayoutKind.Auto)]
public struct FlightPriceStruct
{
    // 屬性與類一致
    ......
}

我們可以使用Unsafe.SizeOf來查看值類型所需要的記憶體大小,比如像下麵這樣。

可以看到這個結構體只需要88byte,比類所需要的112byte少了27%。來實際看看能節省多少記憶體。

結果很不錯呀,記憶體確實如我們計算的一樣少了27%,另外賦值速度快了57%,而且更重要的是GC發生的次數也少了。
那麼為什麼結構體可以節省那麼多的記憶體呢?這裡需要聊一聊結構體和類存儲數據的區別,下圖是類數組的存儲格式。
文章配圖-類.drawio
我們可以看到類數組只存放指向數組引用元素的指針,不直接存儲數據,而且每個引用類型的實例都有以下這些東西。

  • 對象頭:大小為8Byte,CoreCLR上的描述是存儲“需要負載到對象上的所有附加信息”,比如存儲對象的lock值或者HashCode緩存值。
  • 方法表指針:大小為8Byte,指向類型的描述數據,也就是經常提到的(Method Table),MT裡面會存放GCInfo,欄位以及方法定義等等。
  • 對象占位符:大小為8Byte,當前的GC要求所有的對象至少有一個當前指針大小的欄位,如果是一個空類,除了對象頭和方法表指針以外,還會占用8Byte,如果不是空類,那就是存放第一個欄位。
    也就是說一個空類不定義任何東西,也至少需要24byte的空間,8byte對象頭+8byte方法表指針+8byte對象占位符
    回到本文中,由於不是一個空類,所以每個對象除了數據存儲外需要額外的16byte存儲對象頭和方法表,另外數組需要8byte存放指向對象的指針,所以一個對象存儲在數組中需要額外占用24byte的空間。我們再來看看值類型(結構體)。
    文章配圖-結構體.drawio
    從上圖中,我們可以看到如果是值類型的數組,那麼數據是直接存儲在數組上,不需要引用。所以存儲相同的數據,每個空結構體都能省下24byte(無需對象頭、方法表和指向實例的指針)。
    另外結構體數組當中的數組,數組也是引用類型,所以它也有24byte的數據,它的對象占位符用來存放數組類型的第一個欄位-數組大小。
    我們可以使用ObjectLayoutInspector這個Nuget包列印對象的佈局信息,類定義的佈局信息如下,可以看到除了數據存儲需要的88byte以外,還有16byte額外空間。

    結構體定義的佈局信息如下,可以看到每個結構體都是實際的數據存儲,不包含額外的占用。

那可不可以節省更多的記憶體呢?我們知道在64位平臺上一個引用(指針)是8byte,而在C#上預設的字元串使用Unicode-16,也就是說2byte代表一個字元,像航司二字碼、起抵機場這些小於4個字元的完全可以使用char數組來節省記憶體,比一個指針占用還要少,那我們修改一下代碼。

// 跳過本地變數初始化
[SkipLocalsInit]
// 調整佈局方式 使用Explicit自定義佈局
[StructLayout(LayoutKind.Explicit, CharSet = CharSet.Unicode)]
public struct FlightPriceStructExplicit
{
    // 需要手動指定偏移量
    [FieldOffset(0)]
    // 航司使用兩個字元存儲
    public unsafe fixed char Airline[2];

    // 由於航司使用了4byte 所以起始機場偏移4byte
    [FieldOffset(4)]
    public unsafe fixed char Start[3];

    // 同理起始機場使用6byte 偏移10byte
    [FieldOffset(10)]
    public unsafe fixed char End[3];

    [FieldOffset(16)]
    public unsafe fixed char FlightNo[4];

    [FieldOffset(24)]
    public unsafe fixed char Cabin[2];

    // decimal 16byte
    [FieldOffset(28)]
    public decimal Price;

    // DateOnly 4byte
    [FieldOffset(44)]
    public DateOnly DepDate;

    // TimeOnly 8byte
    [FieldOffset(48)]
    public TimeOnly DepTime;
    [FieldOffset(56)]
    public DateOnly ArrDate;
    [FieldOffset(60)]
    public TimeOnly ArrTime;

}

在來看看這個新結構體對象的佈局信息。

可以看到現在只需要68byte了,最後4byte是為了地址對齊,因為CPU字長是64bit,我們不用管。按照我們的計算能比88Byte節省了29%的空間。當然使用unsafe fixed char以後就不能直接賦值了,需要進行數據拷貝才行,代碼如下。

// 用於設置string值的擴展方法
[MethodImpl(MethodImplOptions.AggressiveInlining)]  
public static unsafe void SetTo(this string str, char* dest)  
{  
    fixed (char* ptr = str)  
    {  
        Unsafe.CopyBlock(dest, ptr, (uint)(Unsafe.SizeOf<char>() * str.Length));  
    }  
}

// Benchmark的方法
public static unsafe FlightPriceStructExplicit[] GetStructStoreStructExplicit()  
{  
    var arrays = new FlightPriceStructExplicit[FlightPrices.Length];  
    for (int i = 0; i < FlightPrices.Length; i++)  
    {  
        ref var item = ref FlightPrices[i];  
        arrays[i] = new FlightPriceStructExplicit  
        {  
            Price = item.Price,  
            DepDate = item.DepDate,  
            DepTime = item.DepTime,  
            ArrDate = item.ArrDate,  
            ArrTime = item.ArrTime  
        };  
        ref var val = ref arrays[i];  
        // 需要先fixed 然後再賦值
        fixed (char* airline = val.Airline)  
        fixed (char* start = val.Start)  
        fixed (char* end = val.End)  
        fixed (char* flightNo = val.FlightNo)  
        fixed (char* cabin = val.Cabin)  
        {  
            item.Airline.SetTo(airline);  
            item.Start.SetTo(start);  
            item.End.SetTo(end);  
            item.FlightNo.SetTo(flightNo);  
            item.Cabin.SetTo(cabin);  
        }  
    }  
    return arrays;  
}

再來跑一下,看看這樣存儲提升是不是能節省29%的空間呢。

是吧,從84MB->65MB節省了大約29%的記憶體,不錯不錯,基本可以達到預期了。
但是我們發現這個Gen0 Gen1 Gen2這些GC發生了很多次,在實際中的話因為這些都是使用的托管記憶體,GC在進行回收的時候會掃描這65MB的記憶體,可能會讓它的STW變得更久;既然這些是緩存的數據,一段時間內不會回收和改變,那我們能讓GC別掃描這些嘛?答案是有的,我們可以直接使用非托管記憶體,使用Marshal類就可以申請和管理非托管記憶體,可以達到你寫C語言的時候用的malloc函數類似的效果。

// 分配非托管記憶體 
// 傳參是所需要分配的位元組數
// 返回值是指向記憶體的指針
IntPtr Marshal.AllocHGlobal(int cb);

// 釋放分配的非托管記憶體
// 傳參是由Marshal分配記憶體的指針地址
void Marshal.FreeHGlobal(IntPtr hglobal);

再修改一下Benchmark的代碼,將它改成使用非托管記憶體。

// 定義了out ptr參數,用於將指針傳回
public static unsafe int GetStructStoreUnManageMemory(out IntPtr ptr)  
{  
    // 使用AllocHGlobal分配記憶體,大小使用SizeOf計算結構體大小乘需要的數量
    var unManagerPtr = Marshal.AllocHGlobal(Unsafe.SizeOf<FlightPriceStructExplicit>() * FlightPrices.Length);  
    ptr = unManagerPtr;  
    // 將記憶體空間指派給FlightPriceStructExplicit數組使用
    var arrays = new Span<FlightPriceStructExplicit>(unManagerPtr.ToPointer(), FlightPrices.Length);  
    for (int i = 0; i < FlightPrices.Length; i++)  
    {  
        ref var item = ref FlightPrices[i];  
        arrays[i] = new FlightPriceStructExplicit  
        {  
            Price = item.Price,  
            DepDate = item.DepDate,  
            DepTime = item.DepTime,  
            ArrDate = item.ArrDate,  
            ArrTime = item.ArrTime  
        };  
        ref var val = ref arrays[i];  
        fixed (char* airline = val.Airline)  
        fixed (char* start = val.Start)  
        fixed (char* end = val.End)  
        fixed (char* flightNo = val.FlightNo)  
        fixed (char* cabin = val.Cabin)  
        {  
            item.Airline.SetTo(airline);  
            item.Start.SetTo(start);  
            item.End.SetTo(end);  
            item.FlightNo.SetTo(flightNo);  
            item.Cabin.SetTo(cabin);  
        }  
    }  
    // 返回長度
    return arrays.Length;  
}

// 切記,非托管記憶體不使用的時候 需要手動釋放
[Benchmark]  
public void GetStructStoreUnManageMemory()  
{  
    _ = FlightPriceCreate.GetStructStoreUnManageMemory(out var ptr);  
    // 釋放非托管記憶體
    Marshal.FreeHGlobal(ptr);  
}

再來看看Benchmark的結果。

結果非常Amazing呀,沒有在托管記憶體上分配空間,賦值的速度也比原來快了很多,後面發生GC的時候也無需掃描這一段記憶體,降低了GC壓力。這樣的結果基本就比較滿意了。
到現在的話存儲1億的數據差不多6.3GB,如果使用上文中提高的其它方法,應該還能降低一些,比如像如下代碼一樣,使用枚舉來替換字元串,金額使用'分'存儲,只存時間戳。

[StructLayout(LayoutKind.Explicit, CharSet = CharSet.Unicode)]
[SkipLocalsInit]
public struct FlightPriceStructExplicit
{
	// 使用byte標識航司  byte範圍0~255
	[FieldOffset(0)]
	public byte Airline;

	// 使用無符號整形表示起抵機場和航班號 2^16次方
	[FieldOffset(1)]
	public UInt16 Start;

	[FieldOffset(3)]
	public UInt16 End;

	[FieldOffset(5)]
	public UInt16 FlightNo;

	[FieldOffset(7)]
	public byte Cabin;

	// 不使用decimal 價格精確到分存儲
	[FieldOffset(8)]
	public long PriceFen;

	// 使用時間戳替代
	[FieldOffset(16)]
	public long DepTime;

	[FieldOffset(24)]
	public long ArrTime;
}

最後的出來的結果,每個數據只需要32byte的空間存儲,這樣存儲一億的的話也不到3GB

本文就不繼續討論這些方式了。

計算速度

那麼使用結構體有什麼問題嗎?我們來看看計算,這個計算很簡單,就是把符合條件的航線篩選出來,首先類和結構體都定義瞭如下代碼的方法,Explicit結構體比較特殊,我們使用Span比較。

// 類和結構體定義的方法 當然實際中的篩選可能更加複雜
// 比較航司
public bool EqulasAirline(string airline)  
{  
    return Airline == airline;  
}  
// 比較起飛機場
public bool EqualsStart(string start)  
{  
    return Start == start;  
}  
// 比較抵達機場
public bool EqualsEnd(string end)  
{  
    return End == end;  
}
// 比較航班號
public bool EqualsFlightNo(string flightNo)  
{  
    return FlightNo == flightNo;  
}
// 價格是否小於指定值
public bool IsPriceLess(decimal min)  
{  
    return Price < min;  
}
// 對於Explicit結構體 定義了EqualsSpan方法
[MethodImpl(MethodImplOptions.AggressiveInlining)]  
public static unsafe bool SpanEquals(this string str, char* dest, int length)  
{  
    // 使用span來比較兩個數組
    return new Span<char>(dest, length).SequenceEqual(str.AsSpan());  
}

// 實現的方法如下所示
public static unsafe bool EqualsAirline(FlightPriceStructExplicit item, string airline)  
{  
    // 傳需要比較的長度
    return airline.SpanEquals(item.Airline, 2);  
}
// 下麵的方式類似,不再贅述
public static unsafe bool EqualsStart(FlightPriceStructExplicit item, string start)  
{  
    return start.SpanEquals(item.Start, 3);  
}  
public static unsafe bool EqualsEnd(FlightPriceStructExplicit item, string end)  
{  
    return end.SpanEquals(item.End, 3);  
}  
public static unsafe bool EqualsFlightNo(FlightPriceStructExplicit item, string flightNo)  
{  
    return flightNo.SpanEquals(item.FlightNo, 4);  
}  
public static unsafe bool EqualsCabin(FlightPriceStructExplicit item, string cabin)  
{  
    return cabin.SpanEquals(item.Cabin, 2);  
}  
public static bool IsPriceLess(FlightPriceStructExplicit item, decimal min)  
{  
    return item.Price < min;  
}

最後Benchmark的代碼如下所示,對於每種存儲結構都是同樣的代碼邏輯,由於100W數據一下就跑完了,每種存儲方式的數據量都為150W

// 將需要的數據初始化好  避免對測試造成影響
private static readonly FlightPriceClass[] FlightPrices = FlightPriceCreate.GetClassStore();  
private static readonly FlightPriceStruct[] FlightPricesStruct = FlightPriceCreate.GetStructStore();  
private static readonly FlightPriceStructUninitialized[] FlightPricesStructUninitialized =  
    FlightPriceCreate.GetStructStoreUninitializedArray();  
private static readonly FlightPriceStructExplicit[] FlightPricesStructExplicit =  
    FlightPriceCreate.GetStructStoreStructExplicit(); 
// 非托管記憶體比較特殊 只需要存儲指針地址即可
private static IntPtr _unManagerPtr;  
private static readonly int FlightPricesStructExplicitUnManageMemoryLength =  
    FlightPriceCreate.GetStructStoreUnManageMemory(out _unManagerPtr);  
[Benchmark(Baseline = true)]  
public int GetClassStore()  
{  
    var caAirline = 0;  
    var shaStart = 0;  
    var peaStart = 0;  
    var ca0001FlightNo = 0;  
    var priceLess500 = 0;  
    for (int i = 0; i < FlightPrices.Length; i++)  
    {  
        // 簡單的篩選數據
        var item = FlightPrices[i];  
        if (item.EqualsAirline("CA"))caAirline++;  
        if (item.EqualsStart("SHA"))shaStart++;  
        if (item.EqualsEnd("PEA"))peaStart++;  
        if (item.EqualsFlightNo("0001"))ca0001FlightNo++;  
        if (item.IsPriceLess(500))priceLess500++;  
    }  
    Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}");  
    return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500;  
}  
[Benchmark]  
public int GetStructStore()  
{  
    var caAirline = 0;  
    var shaStart = 0;  
    var peaStart = 0;  
    var ca0001FlightNo = 0;  
    var priceLess500 = 0;  
    for (int i = 0; i < FlightPricesStruct.Length; i++)  
    {  
        var item = FlightPricesStruct[i];  
        if (item.EqualsAirline("CA"))caAirline++;  
        if (item.EqualsStart("SHA"))shaStart++;  
        if (item.EqualsEnd("PEA"))peaStart++;  
        if (item.EqualsFlightNo("0001"))ca0001FlightNo++;  
        if (item.IsPriceLess(500))priceLess500++;  
    }  
    Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}");  
    return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500;  
}  
[Benchmark]  
public int GetFlightPricesStructExplicit()  
{  
    var caAirline = 0;  
    var shaStart = 0;  
    var peaStart = 0;  
    var ca0001FlightNo = 0;  
    var priceLess500 = 0;  
    for (int i = 0; i < FlightPricesStructExplicit.Length; i++)  
    {  
        var item = FlightPricesStructExplicit[i];  
        if (FlightPriceStructExplicit.EqualsAirline(item,"CA"))caAirline++;  
        if (FlightPriceStructExplicit.EqualsStart(item,"SHA"))shaStart++;  
        if (FlightPriceStructExplicit.EqualsEnd(item,"PEA"))peaStart++;  
        if (FlightPriceStructExplicit.EqualsFlightNo(item,"0001"))ca0001FlightNo++;  
        if (FlightPriceStructExplicit.IsPriceLess(item,500))priceLess500++;  
    }  
    Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}");  
    return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500;  
}  
[Benchmark]  
public unsafe int GetFlightPricesStructExplicitUnManageMemory()  
{  
    var caAirline = 0;  
    var shaStart = 0;  
    var peaStart = 0;  
    var ca0001FlightNo = 0;  
    var priceLess500 = 0;  
    var arrays = new Span<FlightPriceStructExplicit>(_unManagerPtr.ToPointer(), FlightPricesStructExplicitUnManageMemoryLength);  
    for (int i = 0; i < arrays.Length; i++)  
    {  
        var item = arrays[i];  
        if (FlightPriceStructExplicit.EqualsAirline(item,"CA"))caAirline++;  
        if (FlightPriceStructExplicit.EqualsStart(item,"SHA"))shaStart++;  
        if (FlightPriceStructExplicit.EqualsEnd(item,"PEA"))peaStart++;  
        if (FlightPriceStructExplicit.EqualsFlightNo(item,"0001"))ca0001FlightNo++;  
        if (FlightPriceStructExplicit.IsPriceLess(item,500))priceLess500++;  
    }  
    Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}");  
    return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500;  
}

Benchmark的結果如下。

我們看到單獨使用結構體比類要慢一點點,但是後面那些使用Explicit佈局方式和非托管記憶體的就慢很多很多了,有一倍多的差距,魚和熊掌真的不可兼得嗎?
我們來分析一下後面2種方式比較慢的原因,原因是因為值拷貝,我們知道在C#中預設引用類型是引用傳遞,而值類型是值傳遞。

  • 引用類型調用方法傳遞時只需要拷貝一次,長度為CPU字長,32位系統就是4byte,64位就是8byte
  • 值類型調用方法是值傳遞,比如值需要占用4byte,那麼就要拷貝4byte,在小於等於CPU字長時有優勢,大於時優勢就變為劣勢。
    而我們的結構體都遠遠大於CPU字長64位8byte,而我們的後面的代碼實現發生了多次值拷貝,這拖慢了整體的速度。
    那麼有沒有什麼辦法不發生值拷貝呢?當然,值類型在C#中也可以引用傳遞,我們有ref關鍵字,只需要在值拷貝的地方加上就好了,代碼如下所示。
// 改造比較方法,使其支持引用傳遞
// 加入ref
public static unsafe bool EqualsAirlineRef(ref FlightPriceStructExplicit item, string airline)  
{  
    // 傳遞的是引用 需要fixed獲取指針
    fixed(char* ptr = item.Airline)  
    {  
        return airline.SpanEquals(ptr, 2);  
    }  
}

// Benchmark內部代碼也修改為引用傳遞
[Benchmark]  
public unsafe int GetStructStoreUnManageMemoryRef()  
{  
    var caAirline = 0;  
    var shaStart = 0;  
    var peaStart = 0;  
    var ca0001FlightNo = 0;  
    var priceLess500 = 0;  
    var arrays = new Span<FlightPriceStructExplicit>(_unManagerPtr.ToPointer(), FlightPricesStructExplicitUnManageMemoryLength);  
    for (int i = 0; i < arrays.Length; i++)  
    {  
        // 從數組裡面拿直接引用
        ref var item = ref arrays[i];
        // 傳參也直接傳遞引用
        if (FlightPriceStructExplicit.EqualsAirlineRef(ref item,"CA"))caAirline++;  
        if (FlightPriceStructExplicit.EqualsStartRef(ref item,"SHA"))shaStart++;  
        if (FlightPriceStructExplicit.EqualsEndRef(ref item,"PEA"))peaStart++;  
        if (FlightPriceStructExplicit.EqualsFlightNoRef(ref item,"0001"))ca0001FlightNo++;  
        if (FlightPriceStructExplicit.IsPriceLessRef(ref item,500))priceLess500++;  
    }  
    Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}");  
    return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500;  
}

我們再來跑一下結果,我們的Explicit結構體遙遙領先,比使用類足足快33%,而上一輪中使用非托管記憶體表現也很好,排在了第二的位置。

那麼同樣是引用傳遞,使用類會更慢一些呢?這就要回到更加底層的CPU相關的知識了,我們CPU裡面除了基本的計算單元以外,還有L1、L2、L3這些數據緩存,如下圖所示。


這個和CPU的性能掛鉤,記得文章開頭那一個圖嗎?CPU內部的緩存是速度最快的,所以第一個原因就是對於結構體數組數據是存放的連續的地址空間,非常利於CPU緩存;而類對象,由於是引用類型,需要指針訪問,對於CPU緩存不是很有利
第二個原因是因為引用類型在訪問時,需要進行解引用操作,也就是說需要通過指針找到對應記憶體中的數據,而結構體不需要
那麼如何驗證我們的觀點呢,其實BenchmarkDotNet提供了這樣的指標展示,只需要引入BenchmarkDotNet.Diagnostics.WindowsNuget包,然後在需要評測的類上面加入以下代碼。

[HardwareCounters(
    HardwareCounter.LlcMisses, // 緩存未命中次數  
    HardwareCounter.LlcReference)]  // 解引用次數
public class SpeedBench : IDisposable  
{
    ......
}

結果如下所示,由於需要額外的統計Windows ETW的信息,所以跑的會稍微慢一點。

我們可以從上圖看出,使用引用類型緩存未命中的次數最多,解引用的次數也很多,這些拖慢了性能。
如下圖所示,順序存儲的結構體要比跳躍式的引用類型記憶體訪問效率高。另外對象的體積越小,對於緩存就越友好。
文章配圖-類Cache.drawio

文章配圖-結構體Cache.drawio

總結

在本文章中,我們討論瞭如何使用結構體替換類,達到降低大量記憶體占用和提升幾乎一半計算性能的目的。也討論了非托管記憶體在.NET中的簡單使用。結構體是我非常喜歡的東西,它有著相當高效的存儲結構和相當優異的性能。但是你不應該將所有的類都轉換為結構體,因為它們有不同的適用場景。
那麼我們在什麼時候需要使用結構體,什麼時候需要使用類呢?微軟官方給出了答案。

✔️ 如果類型的實例比較小並且通常生存期較短或者常嵌入在其他對象中,則考慮定義結構體而不是類。
❌ 避免定義結構,除非具有所有以下特征:

  • 它邏輯上表示單個值,類似於基元類型(intdouble 等等)- 比如我們的緩存數據,基本都是基元類型。
  • 它的實例大小小於16位元組 - 值拷貝的代價是巨大的,不過現在有了ref能有更多的適用場景。
  • 它是不可變的 - 在我們今天的例子中,緩存的數據是不會改變的,所以具有這個特征。
  • 它不必頻繁裝箱 - 頻繁裝拆箱對性能有較大的損耗,在我們的場景中,函數都做了ref適配,所以也不存在這種情況。

在所有其他情況下,都應將類型定義為類。

其實大家從這些方式也能看出來,C#是一門入門簡單但是上限很高的語言,平時可以利用C#的語法特性,快速的進行需求變現;而如果有了性能瓶頸,你完全可以像寫C++代碼一樣寫C#代碼,獲得和C++媲美的性能。

附錄

本文源碼鏈接-晚點會上傳
選擇結構體還是類
結構體設計原則
.NET Marshal類
.NET Span類
CPU不同硬體的速度


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • HandlerInterceptor源碼 ##3種方法: preHandle:攔截於請求剛進入時,進行判斷,需要boolean返回值,如果返回true將繼續執行,如果返回false,將不進行執行。一般用於登錄校驗。 postHandle:攔截於方法成功返回後,視圖渲染前,可以對modelAndVie ...
  • 在文章的開頭,先說下NPE問題,NPE問題就是,我們在開發中經常碰到的NullPointerException.假設我們有兩個類,他們的UML類圖如下圖所示 在這種情況下,有如下代碼 user.getAddress().getProvince(); 這種寫法,在user為null時,是有可能報Nul ...
  • 前言 大家都喜歡搞笑視頻倒放吧,視頻倒放會給人不一樣的感覺,比起按照原本的劇本,倒放的效果給人的感覺更出乎意料。所以, 我就想可不可以用Python實現視頻或者圖片倒放呢?於是,我做到了,我想把它分享給大家,讓大家跟我一起玩。 開始分享今天的技術之前,先來看幾個動圖(gif) (原圖1) (倒放1) ...
  • 寫文件有三種模式: 截斷寫,文件打開之後立即清空原有內容 附加寫,文件打開之後不清空原有內容,每次只能在文件最後寫入 覆蓋寫,文件打開之後不清空原有內容,可以在文件任意位置寫入 例如:文件原有內容為 123456,在開始位置覆蓋寫入 abcd,最後文件內容為 abcd56。首先使用下麵的代碼進行嘗試 ...
  • 1.什麼是Elasticserach? 一個由Java語言開發的全文搜索引擎,全文檢索就是根據用戶輸入查詢字元的片段,能查詢出包含片段的數據,簡單來說就是一個分散式的搜索與分析引擎,它可以完成分散式部署,結構化檢索,以及數據分析功能,主要是應用在微服務系統中。 我們使用大白話簡單的形式解釋,舉個例子 ...
  • 在Excel中,可對單元格中的字元串設置多種不同樣式,通常只需要獲取到單元格直接設置樣式即可,該方法設置的樣式會應用於該單元格中的所有字元。如果需要對單元格中某些字元設置樣式,則可以參考本文中的方法。本文,將以C#及VB.NET代碼為例,介紹如何在Excel同一個單元格中應用多種字體樣式,包括字體加 ...
  • “五一”期間用了一下Swagger,碰到了以下問題: 如何在Docker中顯示OpenApiInfo的中文內容; 如何顯示xml註釋; 如何顯示Header; 如何隱藏ApiController、Action、類或者屬性,如何顯示枚舉 現將解決辦法記下留存。 一、在Docker中顯示OpenApiI ...
  • C#在調用海康威視CHCNetSDK出現 未能從程式集中載入類型“WIFI_AUTH_PARAM”,因為它在 0 偏移位置處包含一個對象欄位,該欄位已由一個非對象欄位不正確地對齊或重疊。 詳細解決辦法 1、 需要把整個文件介面體頭部的LayoutKind.Explicit改為LayoutKind.A ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...