C# Span 原始碼解讀和應用實踐

一線碼農發表於2020-11-14

一:背景

1. 講故事

這兩天工作上太忙沒有及時持續的文章產出,和大家說聲抱歉,前幾天群裡一個朋友在問什麼時候可以產出 Span 的下一篇,哈哈,這就來啦!讀過上一篇的朋友應該都知道 Span 統一了 .NET 程式 棧 + 託管 + 非託管 實現了三大塊記憶體的統一訪問,??,而且在 .net 底層 Library 中也是一等公民的存在,很多現有的類都提供了對 Span / ReadOnlySpan 的支援。

  • String 對 Span / ReadOnlySpan 的支援

    public sealed class String
    {
        [MethodImpl(MethodImplOptions.InternalCall)]
        [NullableContext(0)]
        public extern String(ReadOnlySpan<char> value);
    }

  • StringBuilder 對 Span / ReadOnlySpan 的支援

    public sealed class StringBuilder : ISerializable
    {
        public unsafe StringBuilder Append(ReadOnlySpan<char> value)
        {
            if (value.Length > 0)
            {
                fixed (char* value2 = &MemoryMarshal.GetReference(value))
                {
                    Append(value2, value.Length);
                }
            }
            return this;
        }
    }

  • Int 對 Span / ReadOnlySpan 的支援

    public readonly struct Int32
    {
        public static int Parse(ReadOnlySpan<char> s, NumberStyles style = NumberStyles.Integer, IFormatProvider? provider = null)
        {
            NumberFormatInfo.ValidateParseStyleInteger(style);
            return Number.ParseInt32(s, style, NumberFormatInfo.GetInstance(provider));
        }
    }

怎麼樣,這些通用 & 基礎的類都在大力對接 Span / ReadOnlySpan,更別說複雜型別了,其地位不言自明哈,接下來我們就從 Span 本身的機制聊起。

二: Span 原理探究

1. Span 原始碼分析

靈活運用 Span 解決工作中的實際問題我相信大家應該沒什麼毛病了,有了這個基礎再從 Span 的原始碼 和 使用者態 和大家一起深度剖析,從原始碼開始吧。


    public readonly ref struct Span<T>
    {
        internal readonly ByReference<T> _pointer;

        private readonly int _length;
    }

上面程式碼的 ref struct 可以看出,這個 Span 是隻可以分配在棧上的值型別,然後就是裡面的 _pointer 和 _length 兩個例項欄位,不知道看完這兩個欄位腦子裡是不是有一幅圖,大概是這樣的。

可以清晰的看出,Span 就是用來對映一段可以連續訪問的記憶體地址,空間大小由 length 控制,開始位置由 _pointer 指定,是不是像極了指標???,是的,語言團隊要保證你的程式高效能,還得照護你的人身安全,出了各種手段,真是煞費苦心! ???

2. Span 使用者態分析

雖然圖已經畫了,但還是有很多朋友希望眼見為實,必須實操演練,嘿嘿,無懼任何挑戰,那我先把上面的圖化成程式碼:


        static void Main(string[] args)
        {
            var nums = new int[] { 1, 2, 3, 4, 5, 6 };

            var span = new Span<int>(nums);

            Console.ReadLine();
        }

接下來我用 windbg 把執行緒棧中的 span 也找出來。


0:000> !clrstack -l
OS Thread Id: 0x181c (0)
        Child SP               IP Call Site
000000963277E5D0 00007ffc3e601434 ConsoleApp1.Program.Main(System.String[]) [E:\net5\ConsoleApp2\ConsoleApp1\Program.cs @ 13]
    LOCALS:
        0x000000963277E618 = 0x000001e956b8ab10
        0x000000963277E608 = 0x000001e956b8ab20

從最後一行程式碼可以看出:span 的棧地址是 0x000000963277E608,棧內容是:0x000001e956b8ab20,按照圖的理論: 0x000001e956b8ab20 應該是 nums 陣列元素 1 的記憶體地址,可以用 dp 驗證一下。


0:000> dp 0x000001e956b8ab20
000001e9`56b8ab20  00000002`00000001 00000004`00000003
000001e9`56b8ab30  00000006`00000005 00000000`00000000
000001e9`56b8ab40  00007ffc`3e6c4388 00000000`00000000

從上面三行記憶體地址來看,陣列的:1,2,3,4,5,6 依次排列,有些朋友可能有點小疑問,為啥 nums 的記憶體地址不是指向陣列元素 1 的呢? 那我來普及一下吧,先用 dp 喚出陣列的記憶體地址。


0:000> dp 0x000001e956b8ab10
000001e9`56b8ab10  00007ffc`3e69f090 00000000`00000006
000001e9`56b8ab20  00000002`00000001 00000004`00000003
000001e9`56b8ab30  00000006`00000005 00000000`00000000

可以看出,第一排為: 00007ffc3e69f090 0000000000000006, 前面的 8 byte 表示 陣列 的 方法表地址,後面的 8byte 表示 6 ,也就是說陣列有 6個元素,不信的話我截一張圖:

span 是由 _pointer + length 組成的,剛才的 _pointer 也給大家演示了,那 length 的值在哪裡呢? 因為 span 是 struct,所以需要用 dp 把剛才的執行緒棧最小的棧地址打出來就可以了。

到這裡,我覺得我講的已經夠清楚了,如果還有點懵的話可以仔細想一想哈。

三:Span 在 String 和 List 的實踐

Span的應用場景真的是太多了,不可能在這篇一一列舉,這裡我就舉兩個例子吧,讓大家能夠感受到 Span 的強大即可。

1. 在 String 上的應用

案例:如何高效的計算出使用者輸入的值 10+20 ?

1) 傳統 Substring 做法

傳統的做法很簡單,擷取唄,程式碼如下:


        static void Main(string[] args)
        {
            var word = "10+20";

            var splitIndex = word.IndexOf("+");

            var num1 = int.Parse(word.Substring(0, splitIndex));

            var num2 = int.Parse(word.Substring(splitIndex + 1));

            var sum = num1 + num2;

            Console.WriteLine($"{num1}+{num2}={sum}");

            Console.ReadLine();
        }

結果是很輕鬆的算出來了,但你仔細想想這裡是不是有點什麼問題,比如說為了從 word 中扣出 num,我用了兩次 SubString,就意味著會在 託管堆 上生成兩個 string,如果說我執行 1w 次話,那託管堆上會不會有 2w 個 string 呢? 修改程式碼如下:


            for (int i = 0; i < 10000; i++)
            {
                var num1 = int.Parse(word.Substring(0, splitIndex));

                var num2 = int.Parse(word.Substring(splitIndex + 1));

                var sum = num1 + num2; 
            }

然後看一下 託管堆 上 String 的個數


0:000> !dumpheap -type String -stat
Statistics:
              MT    Count    TotalSize Class Name
00007ffc53a81e18    20167       556538 System.String

託管堆上有 20167 個,挺恐怖的,真的是給 GC 添麻煩哈,這裡還有 167 個是系統自帶的,接下來的問題是有沒有辦法替換 SubString 從而不生成臨時string呢?

2) 新式 Span 做法

如果看懂了 Span 結構圖,你就應該會使用 _pointer + length 將 string 進行切片處理,對不對,程式碼如下:


            for (int i = 0; i < 10000; i++)
            {
                var num1 = int.Parse(word.AsSpan(0, splitIndex));

                var num2 = int.Parse(word.AsSpan(splitIndex));

                var sum = num1 + num2; 
            }

然後在 託管堆 驗證一下,是不是沒有 臨時 string 了?


0:000> !dumpheap -type String -stat
Statistics:
              MT    Count    TotalSize Class Name
00007ffc53a51e18      167        36538 System.String

可以看到就只有 167 個系統字串,效能也得到了不小的提升,???。

2. 在 List 上的應用

平時用 Span 的時候,更多的會應用到 Array 上面,畢竟 Array 在託管堆上是連續記憶體,方便 Span 在上面畫一個可視視窗,其實不僅僅是 Array,從 .NET5 開始在 List 上畫一個檢視也是可以的,截圖如下:

因為 List 的 CURD 會導致底層的 Array 忽長忽短或重新分配,也就無法實現物理上的連續記憶體,所以 Span 應用到 List 之後,希望List是不可變的,這也是官方的建議。

四:總結

總的來說,Span 在 .NET 底層框架中的地位是越來越顯著了,相信 netCore 追求更高更快的效能上 Span 一定大有可為,大家趕緊學起來,???

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

圖片名稱

相關文章