netcore高階知識點,記憶體對齊,原理與示例

IT规划师發表於2024-09-02

最近幾年一直從事物聯網開發,與硬體打交道越來越多,發現越接近底層開發對效能的追求越高,畢竟硬體資源相對上層應用來實在是太缺乏了。今天想和大家一起分享關於C#中的記憶體對齊,希望透過理解和最佳化記憶體對齊,可以幫助大家更好的提高程式效能以及資源利用效率。

什麼是記憶體對齊

記憶體對齊指把資料儲存在記憶體中時,需要按照某種特定規則進行儲存,使其記憶體儲存在符合特定邊界要求的記憶體地址上。而記憶體對齊主要目的則是減少CPU記憶體操作次數,提高記憶體操作效率,並提升CPU快取命中率,從而提升整體效能。

記憶體對齊原則

記憶體對齊原則包含兩部分:記憶體對齊邊界和記憶體對齊規則。

記憶體對齊邊界:資料儲存在記憶體中的起始記憶體地址必須滿足條件。例如,8位元組對齊則要求資料的起始記憶體地址必須是8的倍數;

記憶體對齊規則:不同的硬體平臺記憶體對齊規則也各有差異,比如:x86、x64架構在記憶體對齊方面比較寬鬆,而ARM、RISC-V架構則相對比較嚴格;一般32位處理器要求4位元組對齊,而64位處理器要求8位元組對齊;

因此不同的CPU架構和平臺則記憶體對齊規則也各有不同,而這些差異也都是為了使資料在記憶體中的佈局更加符合CPU操作方式,從而提高程式執行效率。

C#中的記憶體對齊

1、“託管程式碼”和“非託管程式碼”

託管程式碼:執行過程交給執行時CLR管理的程式碼,執行時CLR負責提取託管程式碼並編譯成機器程式碼最後執行,同時執行時CLR還負責自動記憶體管理、安全邊界和型別安全等重要服務。

“非託管程式碼”:即不被執行時CLR管理的程式碼,比如執行C/C++語言編寫的程式碼,而此時開發任意就需要親自處理很多事情,比如記憶體管理、垃圾回收、安全問題等等。

因此一般對於託管程式碼來說,記憶體的分配以及對齊策略都被執行時CLR一手包辦了,無需我們過多關注,而如果需要透過P/Invoke和COM互操作來呼叫非託管程式碼則需要開發者自己處理記憶體對齊策略了。

當然也不是說純託管程式碼就沒有對記憶體對齊操作空間了,只是相對來說與非託管程式碼互動時使用記憶體對齊操作空間更大。

2、StructLayoutAttribute特性

無論託管記憶體還是非託管記憶體,都可以用StructLayoutAttribute特性來對其進行記憶體佈局控制,簡單來說對於託管程式碼可以使用LayoutKind列舉值Explicit進行顯示控制,而對於非託管程式碼LayoutKind列舉值都可以控制。

示例-欄位順序影響記憶體佔用大小

我們用StructLayout(LayoutKind.Sequential標記OriginalLayout結構體,看看每個欄位的佈局情況及其與佔用記憶體總大小之間的關係,先來看下面一段程式碼:

using System.Runtime.InteropServices;
namespace CSharp
{
    public class MemoryLayout
    {
        [StructLayout(LayoutKind.Sequential)]
        public struct OriginalLayout
        {
            public long LongField1;
            public short ShortField;
            public byte ByteField1;
        }
        public static void Run()
        {
            Console.WriteLine($"OriginalLayout LongField1 偏移量: {Marshal.OffsetOf(typeof(OriginalLayout), "LongField1")} ");
            Console.WriteLine($"OriginalLayout ShortField 偏移量: {Marshal.OffsetOf(typeof(OriginalLayout), "ShortField")} ");
            Console.WriteLine($"OriginalLayout ByteField1 偏移量: {Marshal.OffsetOf(typeof(OriginalLayout), "ByteField1")} ");
            Console.WriteLine($"OriginalLayout 總大小: {Marshal.SizeOf(typeof(OriginalLayout))} bytes");
            Console.ReadKey();
        }
    }
}

我們使用Marshal.OffsetOf計算每個欄位偏移量,即第一個欄位偏移量表示其記憶體地址為0,則第二個欄位偏移量表示為其相對第一個欄位記憶體地址值的相對值,使用Marshal.SizeOf計算型別所佔記憶體總大小。

如下圖是上面程式碼執行結果:

首先說下long型別為8位元組、short型別為2位元組、byte型別為1位元組,再來詳細說下每個值怎麼來的。

首先因為LongField1是第一個欄位所以為0,並且因為long型別為8位元組,所以LongField1使用了0-7記憶體地址段,所有第二個欄位ShortField偏移量為8,因此ShortField使用了8-9記憶體地址段,所以第三個欄位ByteField1偏移量為10。

那為什麼總大小不是8+2+1=11位元組,而16位元組呢?這是因為對於型別的對齊方式預設會以其最大的元素對齊方式為準,並且整個型別大小是最大元素大小的整數倍,因此這裡的總大小是8的倍數,因為2+1並沒有佔滿8位元組,因此ByteField1後面被自動填充了5個位元組,以此達到對齊要求。所以最後就是8+2+1+5(自動填充)=16位元組。

然後我們把LongField1和ShortField兩個欄位調整一下位置,再來看看執行結果:

public class MemoryLayout
{
    [StructLayout(LayoutKind.Sequential)]
    public struct OriginalLayout
    {
        public short ShortField;
        public long LongField1;
        public byte ByteField1;
    }
    public static void Run()
    {
        Console.WriteLine($"OriginalLayout ShortField 偏移量: {Marshal.OffsetOf(typeof(OriginalLayout), "ShortField")} ");
        Console.WriteLine($"OriginalLayout LongField1 偏移量: {Marshal.OffsetOf(typeof(OriginalLayout), "LongField1")} ");
        Console.WriteLine($"OriginalLayout ByteField1 偏移量: {Marshal.OffsetOf(typeof(OriginalLayout), "ByteField1")} ");
        Console.WriteLine($"OriginalLayout 總大小: {Marshal.SizeOf(typeof(OriginalLayout))} bytes");
        Console.ReadKey();
    }
}

這裡為什麼又是24位元組呢?

首先雖然ShortField只佔了2位元組,使用了0-1記憶體地址段,但是LongField1並不能從2記憶體地址值開始排版,因為每個欄位必須與其自身大小的欄位或型別的對齊方式對齊,也就是說LongField1佔8位元組,那麼其記憶體地址起始值也要是8的整數倍,因此LongFiled1使用了8-15記憶體地址段,而ShortField和LongFiled1之間會被自動填充6個位元組,同樣的ByteField1後面也被自動填充7個位元組,因此總大小為24位元組。

這裡只是舉了個小例子來展示欄位順序不同,對最終型別所佔記憶體總大小的,這也給我們設計低記憶體消耗程式設計提供了空間。

當然這裡只是簡單使用了StructLayout,還Pack屬性,以及Explicit和FieldOffset,還有CharSet、MarshalAs等複雜的功能都沒有介紹,有興趣的可以深入研究研究。本文只是簡單記憶體對齊的原理原則以及簡單的記憶體最佳化,後面有機會再給大家深入介紹。

相關文章