作为.net程序员,使用过指针,写过不安全代码吗?为什么要使用指针,什么时候需要使用它,以及如何安全、高效地使用它?
如果能很好地回答这几个问题,那么就能很好地理解今天了主题了。C#构建了一个托管世界,在这个世界里,只要不写不安全代码,不操作指针,那么就能获得.Net至关重要的安全保障,即什么都不用担心;那如果我们需要操作的数据不在托管内存中,而是来自于非托管内存,比如位于本机内存或者堆栈上,该如何编写代码支持来自任意区域的内存呢?这个时候就需要写不安全代码,使用指针了;而如何安全、高效地操作任何类型的内存,一直都是C#的痛点,今天我们就来谈谈这个话题,讲清楚 What、How 和 Why ,让你知其然,更知其所以然,以后有人问你这个问题,就让他看这篇文章吧,呵呵。
what - 痛点是什么?
回答这个问题前,先总结一下如何用C#操作任何类型的内存:
1.托管内存(managed memory )
var mangedMemory = new Student();
2.栈内存(stack memory )
unsafe{ var stackMemory = stackalloc byte[100]; }
3.本机内存(native memory )
IntPtr nativeMemory0 = default(IntPtr), nativeMemory1 = default(IntPtr); try { unsafe { nativeMemory0 = Marshal.AllocHGlobal(256); nativeMemory1 = Marshal.AllocCoTaskMem(256); } } finally { Marshal.FreeHGlobal(nativeMemory0); Marshal.FreeCoTaskMem(nativeMemory1); }
抛砖引玉 - 痛点
首先我们设计一个解析完整或部分字符串为整数的API,如下:
public interface IntParser { // allows us to parse the whole string. int Parse(string managedMemory); // allows us to parse part of the string. int Parse(string managedMemory, int startIndex, int length); // allows us to parse characters stored on the unmanaged heap / stack. unsafe int Parse(char* pointerToUnmanagedMemory, int length); // allows us to parse part of the characters stored on the unmanaged heap / stack. unsafe int Parse(char* pointerToUnmanagedMemory, int startIndex, int length); }
接下来在来设计一个支持复制任何内存块的API,如下:
public interface MemoryblockCopier { void Copy<T>(T[] source, T[] destination); void Copy<T>(T[] source, int sourceStartIndex, T[] destination, int destinationStartIndex, int elementsCount); unsafe void Copy<T>(void* source, void* destination, int elementsCount); unsafe void Copy<T>(void* source, int sourceStartIndex, void* destination, int destinationStartIndex, int elementsCount); unsafe void Copy<T>(void* source, int sourceLength, T[] destination); unsafe void Copy<T>(void* source, int sourceStartIndex, T[] destination, int destinationStartIndex, int elementsCount); }
how - span如何解决这个痛点?
先来看看,如何使用span操作各种类型的内存(伪代码):
1.托管内存(managed memory )
var managedMemory = new byte[100]; Span<byte> span = managedMemory;
var stackedMemory = stackalloc byte[100]; var span = new Span<byte>(stackedMemory, 100);
var nativeMemory = Marshal.AllocHGlobal(100); var nativeSpan = new Span<byte>(nativeMemory.ToPointer(), 100);
现在重构上面的两个设计,如下:
public interface IntParser { int Parse(Span<char> managedMemory); int Parse(Span<char>, int startIndex, int length); } public interface MemoryblockCopier { void Copy<T>(Span<T> source, Span<T> destination); void Copy<T>(Span<T> source, int sourceStartIndex, Span<T> destination, int destinationStartIndex, int elementsCount); }
why - 为什么span能解决这个痛点?
浅析span的工作机制
先来窥视一下源码:
我已经圈出的三个字段:偏移量、索引、长度(使用过ArraySegment<byte> 的同学可能已经大致理解到设计的精髓了),这就是它的主要设计,当我们访问span表示的整体或部分内存时,内部的索引器会按照下面的算法运算指针(伪代码):
ref T this[int index]
{
get => ref ((ref reference + byteOffset) + index * sizeOf(T));
}
整个变化的过程,如图所示:
上面的动画非常清楚了吧,旧span整合它的引用和偏移成新的span的引用,整个过程并没有复制内存,也没有返回相对位置上存在的副本,而是直接返回实际存储位置的引用,因此性能非常高,因为新span获得并更新了引用,所以垃圾回收器(GC)知道如何处理新的span,从而获得了.Net至关重要的安全保障,并且内部还会自动执行边界检查确保内存安全,而这些都是span内部默默完成的,开发人员根本不用担心,非托管世界依然美好。
正是由于span的高性能,目前很多基础设施都开始支持span,甚至使用span进行重构,比如:System.String.Substring方法,我们都知道此方法是非常消耗性能的,首先会创建一个新的字符串,然后再从原始字符串中复制字符集给它,而使用span可以实现Non-Allocating、Zero-coping,下面是我做的一个基准测试:
使用String.SubString和Span.Slice分别截取长度为10和1000的字符串的前一半,从指标Mean可以看出方法SubString的耗时随着字符串长度呈线性增长,而Slice几乎保持不变;从指标Allocated Memory/Op可以看出,方法Slice并没有被分配新的内存,实践出真知,可以预见Span未来将会成为.Net下编写高性能应用程序的重要积木,应用前景也会非常地广,微服务、物联网、云原生都是它发光发热的好地方。
总结
从技术的本质上看,Span<T>是一种ref-like type类似引用的结构体;从应用的场景上看,它是高性能的sliceable type可切片类型;综上所诉,Span是一种类似于数组的结构体,但具有创建数组一部分视图,而无需在堆上分配新对象或复制数据的超能力。
看完本篇博客,如果理解了Span的What、Why、How,那么作者布道的目的就达到了,不懂的同学建议多读几遍,下一篇,我将会进一步畅谈Span的脾气秉性,让大家能够安全高效地使用好它。
补充
从评论区交流发现,有的同学误解了span,表面上认为只是对指针的封装,从而绕过unsafe带来的限制,避免开发人员直接面对指针而已,其实不是,下面我们来看一个示例:
var nativeMemory = Marshal.AllocHGlobal(100); Span<byte> nativeSpan; unsafe { nativeSpan = new Span<byte>(nativeMemory.ToPointer(), 100); } SafeSum(nativeSpan); Marshal.FreeHGlobal(nativeMemory); // 这里不关心操作的内存类型,即不用为一种类型写一个重载方法,就好比上面的设计一样。 static ulong SafeSum(Span<byte> bytes) { ulong sum = 0; for(int i=0; i < bytes.Length; i++) { sum += bytes[i]; } return sum; }
1.高性能,避免不必要的内存分配和复制。
2.高效率,它可以为任何具有无复制语义的连续内存块提供安全和可编辑的视图,极大地简化了内存操作,即不用为每一种内存类型操作写一个重载方法。
3.内存安全,span内部会自动执行边界检查来确保安全地读写内存,但它并不管理如何释放内存,而且也管理不了,因为所有权不属于它,希望大家要明白这一点。
4.它的目标是未来将成为.Net下编写高性能应用程序的重要积木。