前言
作为. net 程序员, 使用过指针, 写过不安全代码吗?
为什么要使用指针, 什么时候需要使用它?
如果能很好地回答这两个问题, 那么就能很好地理解今天了主题了. C# 构建了一个托管世界, 在这个世界里, 只要不写不安全代码, 不操作指针, 那么就能获得. Net 至关重要的安全保障, 即什么都不用担心; 那如果我们需要操作的数据不在托管内存中, 而是来自于非托管内存, 比如位于本机内存或者堆栈上, 该如何编写代码支持来自任意区域的内存呢? 这个时候就需要写不安全代码, 使用指针了; 而如何安全, 高效地操作任何类型的内存, 一直都是 C# 的痛点, 今天我们就来谈谈这个话题, 讲清楚 What,How 和 Why , 让你知其然, 更知其所以然, 以后有人问你这个问题, 就让他看这篇文章吧, 呵呵.
what - 痛点是什么?
回答这个问题前, 先总结一下如何用 C# 操作任何类型的内存:
托管内存(managed memory )
var mangedMemory = new Student();
很熟悉吧, 只需使用 new 操作符就分配了一块托管堆内存, 而且还不用手工释放它, 因为它是由垃圾收集器 (GC) 管理的, GC 会智能地决定何时释放它, 这就是所谓的托管内存. 默认情况下, GC 通过复制内存的方式分代管理小对象 (size <85000 bytes), 而专门为大对象(size>= 85000 bytes) 开辟大对象堆(LOH), 管理大对象时, 并不会复制它, 而是将其放入一个列表, 提供较慢的分配和释放, 而且很容易产生内存碎片.
栈内存(stack memory )
- unsafe{
- var stackMemory = stackalloc byte[100];
- }
很简单, 使用 stackalloc 关键字非常快速地就分配好了一块栈内存, 也不用手工释放, 它会随着当前作用域而释放, 比如方法执行结束时, 就自动释放了. 栈内存的容量非常小( ARM,x86 和 x64 计算机, 默认堆栈大小为 1 MB), 当你使用栈内存的容量大于 1M 时, 就会报 StackOverflowException 异常 , 这通常是致命的, 不能被处理, 而且会立即干掉整个应用程序, 所以栈内存一般用于需要小内存, 但是又不得不快速执行的大量短操作, 比如微软使用栈内存来快速地记录 ETW 事件日志.
本机内存(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 诞生了, 它就是这个痛点的解决方案.
how - 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 就是所有类型内存的抽象化身, 表示一段连续的内存, 它的 API 设计和性能就像数组一样, 所以我们完全可以像使用数组一样地操作各种内存, 真的是太方便了.
现在重构上面的两个设计, 如下:
- 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 的 What,Why,How 了如指掌了, 那么我的目的就达到了, 不懂的同学可以多读几遍, 下一篇, 我将会畅谈 Span 的应用场景, 优缺点, 让大家能够安全高效地使用好它, 大家也可以在评论留言自己的应用场景, 我会在写下一篇博客时多多参考.
最后
如果有什么疑问和见解, 欢迎评论区交流.
如果你觉得本篇文章对您有帮助的话, 感谢您的[推荐] .
如果你对高性能编程感兴趣的话可以关注我, 我会定期的在博客分享我的学习心得.
- https://msdn.microsoft.com/en-us/magazine/mt814808
- https://github.com/dotnet/BenchmarkDotNet/pull/492
- https://github.com/dotnet/coreclr/issues/5851
- https://adamsitnik.com/Span
来源: https://www.cnblogs.com/justmine/p/10006621.html