做为.net程序员,使用过指针,写过不安全代码吗?html
为何要使用指针,何时须要使用它,以及如何安全、高效地使用它?git
若是能很好地回答这几个问题,那么就能很好地理解今天了主题了。C#构建了一个托管世界,在这个世界里,只要不写不安全代码,不操做指针,那么就能得到.Net相当重要的安全保障,即什么都不用担忧;那若是咱们须要操做的数据不在托管内存中,而是来自于非托管内存,好比位于本机内存或者堆栈上,该如何编写代码支持来自任意区域的内存呢?这个时候就须要写不安全代码,使用指针了;而如何安全、高效地操做任何类型的内存,一直都是C#的痛点,今天咱们就来谈谈这个话题,讲清楚 What、How 和 Why ,让你知其然,更知其因此然,之后有人问你这个问题,就让他看这篇文章吧,呵呵。程序员
回答这个问题前,先总结一下如何用C#操做任何类型的内存:github
托管内存(managed memory )算法
var mangedMemory = new Student();
很熟悉吧,只需使用new
操做符就分配了一块托管堆内存,并且还不用手工释放它,由于它是由垃圾收集器(GC)管理的,GC会智能地决定什么时候释放它,这就是所谓的托管内存。默认状况下,GC经过复制内存的方式分代管理小对象(size < 85000 bytes),而专门为大对象(size >= 85000 bytes)开辟大对象堆(LOH),管理大对象时,并不会复制它,而是将其放入一个列表,提供较慢的分配和释放,并且很容易产生内存碎片。编程
栈内存(stack memory )c#
unsafe{ var stackMemory = stackalloc byte[100]; }
很简单,使用stackalloc
关键字很是快速地就分配好了一块栈内存,也不用手工释放,它会随着当前做用域而释放,好比方法执行结束时,就自动释放了。栈内存的容量很是小( ARM、x86 和 x64 计算机,默认堆栈大小为 1 MB),当你使用栈内存的容量大于1M时,就会报StackOverflowException
异常 ,这一般是致命的,不能被处理,并且会当即干掉整个应用程序,因此栈内存通常用于须要小内存,可是又不得不快速执行的大量短操做,好比微软使用栈内存来快速地记录ETW事件日志。api
本机内存(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); }
经过调用方法Marshal.AllocHGlobal
或Marshal.AllocCoTaskMem
来分配非托管堆内存,非托管就是垃圾回收器(GC)不可见的意思,而且还须要手工调用方法Marshal.FreeHGlobal
or Marshal.FreeCoTaskMem
释放它,千万不能忘记,否则就内存泄漏了。安全
首先咱们设计一个解析完整或部分字符串为整数的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); }
从上面能够看到,为了支持解析来自任何内存区域的字符串,一共写了4个重载方法。
接下来在来设计一个支持复制任何内存块的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); }
脑壳蒙圈没,之前C#操纵各类内存就是这么复杂、麻烦。经过上面的总结如何用C#操做任何类型的内存,相信大多数同窗都可以很好地理解这两个类的设计,但我内心是没底的,由于使用了不安全代码和指针,这些操做是危险的、不可控的,根本没法得到.net相当重要的安全保障,而且可能还会有难以预估的问题,好比堆栈溢出、内存碎片、栈撕裂等等,微软的工程师们早就意识到了这个痛点,因此span诞生了,它就是这个痛点的解决方案。
先来看看,如何使用span操做各类类型的内存(伪代码):
托管内存(managed memory )
var managedMemory = new byte[100]; Span<byte> span = managedMemory;
栈内存(stack memory )
var stackedMemory = stackalloc byte[100]; var span = new Span<byte>(stackedMemory, 100);
本机内存(native memory )
var nativeMemory = Marshal.AllocHGlobal(100); var nativeSpan = new Span<byte>(nativeMemory.ToPointer(), 100);
span就像黑洞同样,可以吸取来自于内存任意区域的数据,实际上,如今,在.Net的世界里,Span
如今重构上面的两个设计,以下:
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); }
上面的方法根本不关心它操做的是哪一种类型的内存,咱们能够自由地从托管内存切换到本机代码,再切换到堆栈上,真正的享受玩转内存的乐趣。
浅析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; }
看到了吗,并无绕过unsafe,之前该如何用,如今仍是同样的,span解决的是下面几点:
它的目标是将来将成为.Net下编写高性能应用程序的重要积木。
若是有什么疑问和看法,欢迎评论区交流。
若是你以为本篇文章对您有帮助的话,感谢您的【推荐】。
若是你对.NET高性能编程感兴趣的话能够【关注我】,我会按期的在博客分享个人学习心得。
欢迎转载,请在明显位置给出出处及连接。
https://www.codemag.com/Article/1807051/Introducing-.NET-Core-2.1-Flagship-Types-Span-T-and-Memory-T
https://github.com/dotnet/coreclr/blob/master/src/System.Private.CoreLib/shared/System/Span.cs
https://blogs.msdn.microsoft.com/dotnet/2017/11/15/welcome-to-c-7-2-and-span
https://docs.microsoft.com/zh-cn/dotnet/api/system.span-1?view=netcore-2.2
https://blog.marcgravell.com/2017/04/spans-and-ref-part-2-spans.html
https://github.com/dotnet/corefxlab/blob/master/docs/specs/span.md
https://blog.marcgravell.com/2017/04/spans-and-ref-part-1-ref.html
https://channel9.msdn.com/Events/Connect/2017/T125
https://msdn.microsoft.com/en-us/magazine/mt814808
https://github.com/dotnet/BenchmarkDotNet/pull/492
https://github.com/dotnet/coreclr/issues/5851