前言 我們知道在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發生的次數也少了。
那麼為什麼結構體可以節省那麼多的記憶體呢?這裡需要聊一聊結構體和類存儲數據的區別,下圖是類數組的存儲格式。
我們可以看到類數組只存放指向數組引用元素的指針,不直接存儲數據,而且每個引用類型的實例都有以下這些東西。
- 對象頭:大小為8Byte,CoreCLR上的描述是存儲“需要負載到對象上的所有附加信息”,比如存儲對象的lock值或者HashCode緩存值。
- 方法表指針:大小為8Byte,指向類型的描述數據,也就是經常提到的(Method Table),MT裡面會存放GCInfo,欄位以及方法定義等等。
- 對象占位符:大小為8Byte,當前的GC要求所有的對象至少有一個當前指針大小的欄位,如果是一個空類,除了對象頭和方法表指針以外,還會占用8Byte,如果不是空類,那就是存放第一個欄位。
也就是說一個空類不定義任何東西,也至少需要24byte的空間,8byte對象頭+8byte方法表指針+8byte對象占位符。
回到本文中,由於不是一個空類,所以每個對象除了數據存儲外需要額外的16byte存儲對象頭和方法表,另外數組需要8byte存放指向對象的指針,所以一個對象存儲在數組中需要額外占用24byte的空間。我們再來看看值類型(結構體)。
從上圖中,我們可以看到如果是值類型的數組,那麼數據是直接存儲在數組上,不需要引用。所以存儲相同的數據,每個空結構體都能省下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.Windows
Nuget包,然後在需要評測的類上面加入以下代碼。
[HardwareCounters(
HardwareCounter.LlcMisses, // 緩存未命中次數
HardwareCounter.LlcReference)] // 解引用次數
public class SpeedBench : IDisposable
{
......
}
結果如下所示,由於需要額外的統計Windows ETW的信息,所以跑的會稍微慢一點。
我們可以從上圖看出,使用引用類型緩存未命中的次數最多,解引用的次數也很多,這些拖慢了性能。
如下圖所示,順序存儲的結構體要比跳躍式的引用類型記憶體訪問效率高。另外對象的體積越小,對於緩存就越友好。
總結
在本文章中,我們討論瞭如何使用結構體替換類,達到降低大量記憶體占用和提升幾乎一半計算性能的目的。也討論了非托管記憶體在.NET中的簡單使用。結構體是我非常喜歡的東西,它有著相當高效的存儲結構和相當優異的性能。但是你不應該將所有的類都轉換為結構體,因為它們有不同的適用場景。
那麼我們在什麼時候需要使用結構體,什麼時候需要使用類呢?微軟官方給出了答案。
✔️ 如果類型的實例比較小並且通常生存期較短或者常嵌入在其他對象中,則考慮定義結構體而不是類。
❌ 避免定義結構,除非具有所有以下特征:
- 它邏輯上表示單個值,類似於基元類型(
int
、double
等等)- 比如我們的緩存數據,基本都是基元類型。 - 它的實例大小小於16位元組 - 值拷貝的代價是巨大的,不過現在有了
ref
能有更多的適用場景。 - 它是不可變的 - 在我們今天的例子中,緩存的數據是不會改變的,所以具有這個特征。
- 它不必頻繁裝箱 - 頻繁裝拆箱對性能有較大的損耗,在我們的場景中,函數都做了
ref
適配,所以也不存在這種情況。
在所有其他情況下,都應將類型定義為類。
其實大家從這些方式也能看出來,C#是一門入門簡單但是上限很高的語言,平時可以利用C#的語法特性,快速的進行需求變現;而如果有了性能瓶頸,你完全可以像寫C++代碼一樣寫C#代碼,獲得和C++媲美的性能。
附錄
本文源碼鏈接-晚點會上傳
選擇結構體還是類
結構體設計原則
.NET Marshal類
.NET Span類
CPU不同硬體的速度