前言

作为.net程序员,使用过指针,写过不安全代码吗?

为什么要使用指针,什么时候需要使用它,以及如何安全、高效地使用它?

如果能很好地回答这几个问题,那么就能很好地理解今天了主题了。C#构建了一个托管世界,在这个世界里,只要不写不安全代码,不操作指针,那么就能获得.Net至关重要的安全保障,即什么都不用担心;那如果我们需要操作的数据不在托管内存中,而是来自于非托管内存,比如位于本机内存或者堆栈上,该如何编写代码支持来自任意区域的内存呢?这个时候就需要写不安全代码,使用指针了;而如何安全、高效地操作任何类型的内存,一直都是C#的痛点,今天我们就来谈谈这个话题,讲清楚 What、How 和 Why ,让你知其然,更知其所以然,以后有人问你这个问题,就让他看这篇文章吧,呵呵。

what - 痛点是什么?

回答这个问题前,先总结一下如何用C#操作任何类型的内存:

  1. 托管内存(managed memory )

    var mangedMemory = new Student();

    很熟悉吧,只需使用new操作符就分配了一块托管内存,而且还不用手工释放它,因为它是由垃圾收集器(GC)管理的,GC会智能地决定何时释放它,这就是所谓的托管内存。默认情况下,GC通过复制内存的方式分代管理小对象(size < 85000 bytes),而专门为大对象(size >= 85000 bytes)开辟大对象堆(LOH),管理大对象时,并不会复制它,而是将其放入一个列表,提供较慢的分配和释放,而且很容易产生内存碎片。

  2. 栈内存(stack memory )

    unsafe{
    var stackMemory = stackalloc byte[100];
    }

    很简单,使用stackalloc关键字非常快速地就分配好了一块内存,也不用手工释放,它会随着当前作用域而释放,比如方法执行结束时,就自动释放了。栈内存的容量非常小( ARM、x86 和 x64 计算机,默认堆栈大小为 1 MB),当你使用栈内存的容量大于1M时,就会报StackOverflowException 异常 ,这通常是致命的,不能被处理,而且会立即干掉整个应用程序,所以栈内存一般用于需要小内存,但是又不得不快速执行的大量短操作,比如微软使用栈内存来快速地记录ETW事件日志。

  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);
    }

    通过调用方法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操作各种类型的内存(伪代码):

  1. 托管内存(managed memory )

    var managedMemory = new byte[100];
    Span<byte> span = managedMemory;
  2. 栈内存(stack memory )

    var stackedMemory = stackalloc byte[100];
    var span = new Span<byte>(stackedMemory, 100);
  3. 本机内存(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<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解决的是下面几点:

  1. 高性能,避免不必要的内存分配和复制
  2. 高效率,它可以为任何具有无复制语义的连续内存块提供安全和可编辑的视图,极大地简化了内存操作,即不用为每一种内存类型操作写一个重载方法
  3. 内存安全,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

https://github.com/joeduffy/slice.net

https://adamsitnik.com/Span

.NET高性能编程 - C#如何安全、高效地玩转任何种类的内存之Span的本质(一)。的更多相关文章

  1. 通俗易懂,C#如何安全、高效地玩转任何种类的内存之Span的脾气秉性(二)。 异步委托 微信小程序支付证书及SSL证书使用 SqlServer无备份下误删数据恢复 把list集合的内容写入到Xml中,通过XmlDocument方式写入Xml文件中 通过XDocument方式把List写入Xml文件

    通俗易懂,C#如何安全.高效地玩转任何种类的内存之Span的脾气秉性(二).   前言 读完上篇<通俗易懂,C#如何安全.高效地玩转任何种类的内存之Span的本质(一).>,相信大家对sp ...

  2. (转)Android高性能编程(1)--基础篇

    关于专题     本专题将深入研究Android的高性能编程方面,其中涉及到的内容会有Android内存优化,算法优化,Android的界面优化,Android指令级优化,以及Android应用内存占 ...

  3. Python猫荐书系列之五:Python高性能编程

    稍微关心编程语言的使用趋势的人都知道,最近几年,国内最火的两种语言非 Python 与 Go 莫属,于是,隔三差五就会有人问:这两种语言谁更厉害/好找工作/高工资…… 对于编程语言的争论,就是猿界的生 ...

  4. Netty高性能编程备忘录(下)

    估计很快就要被拍砖然后修改,因此转载请保持原文链接,否则视为侵权... http://calvin1978.blogcn.com/articles/netty-performance.html 前文再 ...

  5. 《Python高性能编程》——列表、元组、集合、字典特性及创建过程

    这里的内容仅仅是本人阅读<Python高性能编程>后总结的一些知识,用于自己更好的了解Python机制.本人现在并不从事计算密集型工作:人工智能.数据分析等.仅仅只是出于好奇而去阅读这本书 ...

  6. python高性能编程方法一

    python高性能编程方法一   阅读 Zen of Python,在Python解析器中输入 import this. 一个犀利的Python新手可能会注意到"解析"一词, 认为 ...

  7. [问题解决]《GPU高性能编程CUDA实战》中第4章Julia实例“显示器驱动已停止响应,并且已恢复”问题的解决方法

    以下问题的出现及解决都基于"WIN7+CUDA7.5". 问题描述:当我编译运行<GPU高性能编程CUDA实战>中第4章所给Julia实例代码时,出现了显示器闪动的现象 ...

  8. GPGPU OpenCL/CUDA 高性能编程的10大注意事项

    转载自:http://hc.csdn.net/contents/content_details?type=1&id=341 1.展开循环 如果提前知道了循环的次数,可以进行循环展开,这样省去了 ...

  9. JDK 高性能编程之容器

    高性能编程在对不同场景下对于容器的选择有着非常苛刻的条件,这里记录下前人总结的经验,并对源码进行调试 JDK高性能编程之容器 读书笔记内容部分来源书籍深入理解JVM.互联网等 先放一个类图util,点 ...

随机推荐

  1. Entity Framework分页扩展

    Entity Framework分页在我初入门时总是困扰这我,无论是SQL分页还是Entity Framework的分页,总是显得那么麻烦,因此对于Entity Framework单独封装了分页. 一 ...

  2. ES6(阮一峰)学习总结

    1.块级作用域的引入 在ES6之前,js只有全局作用域和函数作用域,ES6中let关键字为其引入了块级作用域. { var a = 5; let b = 6; } console.log(a); co ...

  3. java集合及其方法

    1.集合框架 我们已经学习过使用数组来批量存储某一类数据: 但是,数组还是存在一些不足,比如长度不可变(建立对象的时候就已经定义好长度): 查找某一个数据时,要依靠索引值来遍历数组进行条件查找,数据量 ...

  4. 设计一个卖不同种类车的4s店

    # 定义奔驰车类 class BenchiCar(object): # 定义车的方法 def move(self): print('---奔驰车在移动---') def stop(self): pri ...

  5. cocos2d-x学习之路之工作吐槽

    经过大半年的cocos2d-x的学习,目前已在一个游戏创业公司实习,负责客户端的代码编写和维护.公司做了一款网游.比较给力,马上就要发布了.希望能够大卖.比较坑的是,居然电脑不给联网.查资料都不好查, ...

  6. 面试真题--------spring源码解析IOC

    spring是我经常使用的框架,可是你真的对spring理解吗? 还是只知道它得使用.如果你想知道它真实的面目请仔细向下看. 1.spring是如何知道哪些Bean需要实例化的? 容器启动过程中,首先 ...

  7. 利用Swagger2自动生成对外接口的文档

    一直以来做对外的接口文档都比较原始,基本上都是手写的文档传来传去,最近发现了一个新玩具,可以在接口上省去不少麻烦. swagger是一款方便展示的API文档框架.它可以将接口的类型最全面的展示给对方开 ...

  8. Python中’__main__’模块的作用

    Python不同于C/C++,程序执行并不需要主程序,如main(),而是文件自上而下的执行.但很多Python程序中都有 if __name__ == '__main__': statements ...

  9. 节流throttle和防抖debounce

    underscore.js提供了很多很有用的函数,今天想说说其中的两个.这两个函数都用于限制函数的执行. debounce 在解释这个函数前,我们先从一个例子看下这个函数的使用场景.假设我们网站有个搜 ...

  10. seo优化做起来不是哪么简单,其实需要的是思维

          SEO百科:随着人们互联网的认识水平的提升,SEO似乎也已经得到了更多的认识.无论是浅显还是深入,SEO一直被大众认为是简单的,甚至是不值得一提的东西,甚至认为SEO无非就是作弊,SEO并 ...