C# Span 源码解读和应用实践

dotNET全栈开发

共 5059字,需浏览 11分钟

 ·

2020-11-25 14:54

一:背景

1. 讲故事

这两天工作上太忙没有及时持续的文章产出,和大家说声抱歉,前几天群里一个朋友在问什么时候可以产出 Span 的下一篇,哈哈,这就来啦!读过上一篇的朋友应该都知道 Span 统一了 .NET 程序 栈 + 托管 + 非托管 实现了三大块内存的统一访问,??,而且在 .net 底层 Library 中也是一等公民的存在,很多现有的类都提供了对 Span / ReadOnlySpan 的支持。

  • String 对 Span / ReadOnlySpan 的支持

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

  • StringBuilder 对 Span / ReadOnlySpan 的支持

    public sealed class StringBuilder : ISerializable
    {
        public unsafe StringBuilder Append(ReadOnlySpan<charvalue)
        {
            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
    {
        internal readonly ByReference _pointer;

        private readonly int _length;
    }

上面代码的 ref struct 可以看出,这个 Span 是只可以分配在栈上的值类型,然后就是里面的 _pointer 和 _length 两个实例字段,不知道看完这两个字段脑子里是不是有一幅图,大概是这样的。

可以清晰的看出,Span 就是用来映射一段可以连续访问的内存地址,空间大小由 length 控制,开始位置由 _pointer 指定,是不是像极了指针???,是的,语言团队要保证你的程序高性能,还得照护你的人身安全,出了各种手段,真是煞费苦心!???

2. Span 用户态分析

虽然图已经画了,但还是有很多朋友希望眼见为实,必须实操演练,嘿嘿,无惧任何挑战,那我先把上面的图化成代码:


        static void Main(string[] args)
        {
            var nums = new int[] { 123456 };

            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 一定大有可为,大家赶紧学起来,???







回复 【关闭】广
回复 【实战】获取20套实战源码
回复 【被删】
回复 【访客】访
回复 【小程序】学获取15套【入门+实战+赚钱】小程序源码
回复 【python】学微获取全套0基础Python知识手册
回复 【2019】获取2019 .NET 开发者峰会资料PPT
回复 【加群】加入dotnet微信交流群

终于GitHub App 已支持简体中文!


微信钱包“免费提现”的方法来了!




浏览 13
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报