用 Span 對 C# 程式中三大記憶體區域進行統一訪問 ,太厲害了!

一線碼農發表於2020-10-26

一:背景

1. 講故事

前段時間寫了幾篇 C# 漫文,評論留言中有很多朋友多次提到 Span,週末抽空看了下,確實是一個非常??的新結構,讓我想到了當年的WCF,它統一了.NET下各種零散的分散式技術,包括:.NET Remoteing,WebService,NamedPipe,MSMQ,而這裡的 Span 統一了 C# 程式中的三大塊記憶體訪問,包括:棧記憶體, 託管堆記憶體, 非託管堆記憶體,畫個圖如下:

接下來就和大傢俱體聊聊這三大塊的記憶體統一訪問。

二: 程式中的三大塊記憶體解析

1. 棧記憶體

大家應該知道方法內的區域性變數是存放在棧上的,而且每一個執行緒預設會被分配 1M 的記憶體空間,我舉個例子:


        static void Main(string[] args)
        {
            int i = 10;
            long j = 20;
            List<string> list = new List<string>();
        }

上面 i,j 的值都是存於棧上,list的堆上記憶體地址也是存於棧上,為了看個究竟,可以用 windbg 驗證一下:


0:000> !clrstack -l
OS Thread Id: 0x2708 (0)
        Child SP               IP Call Site
00000072E47CE558 00007ff89cf7c184 [InlinedCallFrame: 00000072e47ce558] Interop+Kernel32.ReadFile(IntPtr, Byte*, Int32, Int32 ByRef, IntPtr)
00000072E47CE558 00007ff7c7c03fd8 [InlinedCallFrame: 00000072e47ce558] Interop+Kernel32.ReadFile(IntPtr, Byte*, Int32, Int32 ByRef, IntPtr)
00000072E47CE520 00007FF7C7C03FD8 ILStubClass.IL_STUB_PInvoke(IntPtr, Byte*, Int32, Int32 ByRef, IntPtr)
00000072E47CE7B0 00007FF8541E530D System.Console.ReadLine()
00000072E47CE7E0 00007FF7C7C0101E DataStruct.Program.Main(System.String[]) [E:\net5\ConsoleApp2\ConsoleApp1\Program.cs @ 22]
    LOCALS:
        0x00000072E47CE82C = 0x000000000000000a
        0x00000072E47CE820 = 0x0000000000000014
        0x00000072E47CE818 = 0x0000018015aeab10

通過 clrstack -l 檢視執行緒棧,最後三行可以明顯的看到 0a -> 10, 14 -> 20 , 0xxxxxxb10 => list堆地址,除了這些簡單型別,還可以在棧上分配複雜型別,這裡就要用到 stackalloc 關鍵詞, 如下程式碼:


 int* ptr = stackalloc int[3] { 10, 11, 12 };

問題就在這裡,指標型別雖然靈活,但是做任何事情都比較繁瑣,比如說:

  • 查詢某一個數是否在 int[] 中
  • 反轉 int[]
  • 剔除尾部的某一個數字(比如 12)

就拿第一個問題來說,操作指標的程式碼如下:


            //指標接收
            int* ptr = stackalloc int[3] { 10, 11, 12 };

            //包含判斷
            for (int i = 0; i < 3; i++)
            {
                if (*ptr++ == 11)
                {
                    Console.WriteLine(" 11 存在 陣列中");
                }
            }

後面的兩個問題就更加複雜了,既然 Span 是統一訪問,就應該用 Span 來接 stackalloc,程式碼如下:


            Span<int> span = stackalloc int[3] { 10, 11, 12 };

            //1. 是否包含
            var hasNum = span.Contains(11);

            //2. 反轉
            span.Reverse();

            //3. 剔除尾部
            span.Trim(12);

這就很??了,你既不需要接觸指標,又能完成指標的大部分操作,而且還特別便捷,佩服,最後來驗證一下 int[] 是否真的在 執行緒棧 上。


0:000> !clrstack -l
000000ED7737E4B0 00007FF7C4EA16AD DataStruct.Program.Main(System.String[]) [E:\net5\ConsoleApp2\ConsoleApp1\Program.cs @ 28]
    LOCALS:
        0x000000ED7737E570 = 0x000000ed7737e4d0
        0x000000ED7737E56C = 0x0000000000000001
        0x000000ED7737E558 = 0x000000ed7737e4d0

0:000> dp 0x000000ed7737e4d0
000000ed`7737e4d0  0000000b`0000000c 00000000`0000000a

從 Locals 處的 0x000000ED7737E570 = 0x000000ed7737e4d0 可以看到 key / value 是非常相近的,說明在棧上無疑。

從最後一行 a,b,c 可看出對應的就是陣列中的 10,11,12。

2. 非託管堆記憶體

說到非託管記憶體,讓我想起了當年 C# 呼叫 C++ 的場景,程式碼到處充斥著類似下面的語句:


        private bool SendMessage(int messageType, string ip, string port, int length, byte[] messageBytes)
        {
            bool result = false;
            if (windowHandle != 0)
            {
                var bytes = new byte[Const.MaxLengthOfBuffer];
                Array.Copy(messageBytes, bytes, messageBytes.Length);

                int sizeOfType = Marshal.SizeOf(typeof(StClientData));

                StClientData stData = new StClientData
                {
                    Ip = GlobalConvert.IpAddressToUInt32(IPAddress.Parse(ip)),
                    Port = Convert.ToInt16(port),
                    Length = Convert.ToUInt32(length),
                    Buffer = bytes
                };


                int sizeOfStData = Marshal.SizeOf(stData);

                IntPtr pointer = Marshal.AllocHGlobal(sizeOfStData);

                Marshal.StructureToPtr(stData, pointer, true);

                CopyData copyData = new CopyData
                {
                    DwData = (IntPtr)messageType,
                    CbData = Marshal.SizeOf(sizeOfType),
                    LpData = pointer
                };

                SendMessage(windowHandle, WmCopydata, 0, ref copyData);

                Marshal.FreeHGlobal(pointer);

                string data = GlobalConvert.ByteArrayToHexString(messageBytes);
                CommunicationManager.Instance.SendDebugInfo(new DataSendEventArgs() { Data = data });

                result = true;
            }
            return result;
        }

上面程式碼中的: IntPtr pointer = Marshal.AllocHGlobal(sizeOfStData);Marshal.FreeHGlobal(pointer) 就用到了非託管記憶體,從現在開始你就可以用 Span 來接 Marshal.AllocHGlobal 分配的非託管記憶體啦!??‍?,如下程式碼所示:


    class Program
    {
        static unsafe void Main(string[] args)
        {
            var ptr = Marshal.AllocHGlobal(3);

            //將 ptr 轉換為 span
            var span = new Span<byte>((byte*)ptr, 3) { [0] = 10, [1] = 11, [2] = 12 };

            //然後在  span 中可以進行各種操作了。。。

            Marshal.FreeHGlobal(ptr);
        }
    }

這裡我也用 windbg 給大家看一下 未託管記憶體 在記憶體中是個什麼樣子。


0:000> !clrstack -l
OS Thread Id: 0x3b10 (0)
        Child SP               IP Call Site
000000A51777E758 00007ff89cf7c184 [InlinedCallFrame: 000000a51777e758] Interop+Kernel32.ReadFile(IntPtr, Byte*, Int32, Int32 ByRef, IntPtr)
000000A51777E758 00007ff7c4654dd8 [InlinedCallFrame: 000000a51777e758] Interop+Kernel32.ReadFile(IntPtr, Byte*, Int32, Int32 ByRef, IntPtr)
000000A51777E720 00007FF7C4654DD8 ILStubClass.IL_STUB_PInvoke(IntPtr, Byte*, Int32, Int32 ByRef, IntPtr)
000000A51777E9E0 00007FF7C46511D0 DataStruct.Program.Main(System.String[]) [E:\net5\ConsoleApp2\ConsoleApp1\Program.cs @ 26]
    LOCALS:
        0x000000A51777EA58 = 0x0000027490144760
        0x000000A51777EA48 = 0x0000027490144760
        0x000000A51777EA38 = 0x0000027490144760

0:000> dp 0x0000027490144760
00000274`90144760  abababab`ab0c0b0a abababab`abababab        

最後一行的 0c0b0a 這就是低位到高位的 10,11,12 三個數,接下來從 Locals 處 0x000000A51777EA58 = 0x0000027490144760 可以看出,這個key,value 相隔十萬八千里,說明肯定不在棧記憶體中,繼續用 windbg 鑑別一下 0x0000027490144760 是否是託管堆上,可以用 !eeheap -gc 檢視託管堆地址範圍,如下程式碼:


0:000> !eeheap -gc
Number of GC Heaps: 1
generation 0 starts at 0x00000274901B1030
generation 1 starts at 0x00000274901B1018
generation 2 starts at 0x00000274901B1000
ephemeral segment allocation context: none
         segment             begin         allocated              size
00000274901B0000  00000274901B1000  00000274901C5370  0x14370(82800)
Large object heap starts at 0x00000274A01B1000
         segment             begin         allocated              size
00000274A01B0000  00000274A01B1000  00000274A01B5480  0x4480(17536)
Total Size:              Size: 0x187f0 (100336) bytes.
------------------------------
GC Heap Size:    Size: 0x187f0 (100336) bytes.


從上面資訊可以看到,0x0000027490144760 明顯不在:3代堆:00000274901B1000 ~ 00000274901C5370 和 大物件堆:00000274A01B1000 ~ 00000274A01B5480 區間範圍內。

3. 託管堆記憶體

用 Span 統一託管記憶體訪問那是相當簡單了,如下程式碼所示:


   Span<byte> span = new byte[3] { 10, 11, 12 };

同樣,你有了Span,你就可以使用 Span 自帶的各種方法,這裡就不多介紹了,大家有興趣可以實操一下。

三: 總結

總的來說,這一篇主要是從思想上帶大家一起認識 Span,以及如何用 Span 對接 三大區域記憶體,關於 Span 的好處以及原始碼解析,後面上專門的文章吧!

更多高質量乾貨:參見我的 GitHub: dotnetfly

圖片名稱

相關文章