C#如何安全、高效地玩转任何种类的内存之Span的本质

目录
  • C#如何安全、高效地玩转任何种类的内存之Span的本质
    • 一、what - 痛点是什么?
      • 1、托管内存(managed memory )
      • 2、栈内存(stack memory )
      • 3、本机内存(native memory )
    • 二、抛砖引玉 - 痛点
    • 三、how - span如何解决这个痛点?
      • 1、托管内存(managed memory )
      • 2、栈内存(stack memory )
      • 3、本机内存(native memory )
    • 四、why - 为什么span能解决这个痛点?
      • 1、浅析span的工作机制

C#如何安全、高效地玩转任何种类的内存之Span的本质

一、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能解决这个痛点?

1、浅析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,表面上认为只是对指针的封装,从而绕过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内部会自动执行边界检查来确保安全地读写内存,但它并不管理如何释放内存,而且也管理不了,因为所有权不属于它,希望大家要明白这一点。

以上就是C#如何安全、高效地玩转任何种类的内存之Span的本质的详细内容,更多关于C#语言的资料请关注我们其它相关文章!,希望大家以后多多支持我们!

(0)

相关推荐

  • C#圆形头像框制作并从数据库读取

    现在只要是有关头像的框基本都是圆形的了,C#提供的PictureBox控键默认情况下是方形的非常大的影响美观 PictureBox默认情况下 比起上面的还是有一点不太好看的- 使用C#提供的类进行圆形: 工具箱拉出PictureBox 设置图片的显示模式为ZOOM 特别注意: 框的比例必须为一样不然会变椭圆的 窗体加载时: pictureBox1.Image = Image.FromFile(@"C:\Users\Administrator\Desktop\1.png"); 为了方便演

  • C# InitializeComponent()方法案例详解

    在每一个窗体生成的时候,都会针对于当前的窗体定义InitializeComponent()方法,该方法实际上是由系统生成的对于窗体界面的定义方法. //位于.cs文件之中的InitializeComponent()方法 public Form011() { InitializeComponent(); } 在每一个Form文件建立后,都会同时产生程序代码文件.CS文件,以及与之相匹配的.Designer.CS文件,业务逻辑以及事件方法等被编写在.CS文件之中,而界面设计规则被封装在.Design

  • C# DateTime.Compare()方法案例详解

    C#中的DateTime.Compare()方法用于比较两个DateTime实例.它返回一个整数值, <0-如果date1早于date2 0-如果date1与date2相同 > 0-如果date1晚于date2 语法 以下是语法- public static int Compare (DateTime d1, DateTime d2); 上面的d1和d2是要比较的两个日期. 示例 现在让我们看一个实现DateTime.Compare()方法的示例- using System; public c

  • C# Request.Form用法案例详解

    在CS文件中获得对应页面中的下拉框DropDownList_sitebranch值可以有以下几种方法获得: siteInfo.FZJGID = DropDownList_sitebranch.SelectedItem.Value.Trim(); siteInfo.FZJGID = Request.Form["DropDownList_sitebranch"].ToString(); siteInfo.FZJGID = Request["DropDownList_sitebra

  • C# 控件属性和InitializeComponent()关系案例详解

    namespace Test22 { partial class Form1 { /// <summary> /// 必需的设计器变量. /// </summary> private System.ComponentModel.IContainer components = null; /// <summary> /// 清理所有正在使用的资源. /// </summary> /// <param name="disposing"&

  • C#如何安全、高效地玩转任何种类的内存之Span的本质

    目录 C#如何安全.高效地玩转任何种类的内存之Span的本质 一.what - 痛点是什么? 1.托管内存(managed memory ) 2.栈内存(stack memory ) 3.本机内存(native memory ) 二.抛砖引玉 - 痛点 三.how - span如何解决这个痛点? 1.托管内存(managed memory ) 2.栈内存(stack memory ) 3.本机内存(native memory ) 四.why - 为什么span能解决这个痛点? 1.浅析span的

  • JavaScript面向对象的两种书写方法以及差别

    javascript中的对象JS作为一种动态语言,在语法上有相当大的自由度,所以造成了一种功能,有N种写法的局面. 在JS中实现OOP,一般来说有两种方法: 第一种:使用this关键字 function Class1() {     this.onclick = function(e)     {         for (var i=0; i < 1000; i++)         {             var a = new Date();         }     } } 使用t

  • Mysql IO 内存方面的优化

    这里使用的是mysql Ver 14.14 Distrib 5.6.19, for Linux (i686) using EditLine wrapper 一.mysql目录文件 ibdata1:系统表空间 包含数据字典.回滚日志/undolog等 (insert buffer segment/double write segment/rollback segment/index segment/dictionary segment/undo segment) ib_logfile0/ib_lo

  • Redis实现分布式队列浅析

    Redis是什么? Redis是一个简单的,高效的,分布式的,基于内存的缓存工具. 假设好服务器后,通过网络连接(类似数据库),提供Key-Value式缓存服务. 简单,是Redis突出的特色. 简单可以保证核心功能的稳定和优异. redis的安装和配置 Linux系统下: apt-get install redis-server Windows下: 下载链接 下载安装msi文件就好了 配置主从同步 需要实现分布式队列,至少要有一个master(192.168.45.1)和一个slave(192

  • C语言之栈和堆(Stack && Heap)的优缺点及其使用区别

    一.前言 直到现在,我们已经知道了我们如何声明常量类型,例如int,double,等等,还有复杂的例如数组和结构体等.我们声明他们有各种语言的语法,例如Matlab,Python等等.在C语言中,把这些变量放在栈内存中. 二.基础 1.栈 什么是栈,它是你的电脑内存的一个特别区域,它用来存储被每一个function(包括mian()方法)创建的临时变量.栈是FILO,就是先进后出原则的结构体,它密切的被CPU管理和充分利用.每次function声明一个新的变量,它就会被"推"到栈中.然

  • Java GC 机制与内存分配策略详解

    Java GC 机制与内存分配策略详解 收集算法是内存回收的方法论,垃圾收集器是内存回收的具体实现 自动内存管理解决的是:给对象分配内存 以及 回收分配给对象的内存 为什么我们要了解学习 GC 与内存分配呢? 在 JVM 自动内存管理机制的帮助下,不再需要为每一个new操作写配对的delete/free代码.但出现内存泄漏和溢出的问题时,如果不了解虚拟机是怎样使用内存的,那么排查错误将是一项非常艰难的工作. GC(垃圾收集器)在对堆进行回收前,会先确定哪些对象"存活",哪些已经&quo

  • 企业级路由器产品概述

    大家知道,路由器是工作在OSI参考模型的第三层--网络层的数据包转发设备,路由器通过转发数据包来实现网络互连.路由器通常用于节点众多的大型企业网络环境,与交换机和网桥相比,在实现骨干网的互联方面,路由器.特别是高端路由器有着明显的优势.路由器高度的智能化,对各种路由协议.网络协议和网络接口的广泛支持,还有其独具的安全性和访问控制等功能和特点是网桥和交换机等其他互联设备所不具备的. 企业路由器用于连接多个逻辑上分开的网络,所谓的逻辑网络就是代表一个单独的网络或者一个子网.当数据从一个子网传输到另一

  • Java堆内存又溢出了!教你一招必杀技(推荐)

    JAVA堆内存管理是影响性能主要因素之一. 堆内存溢出是JAVA项目非常常见的故障,在解决该问题之前,必须先了解下JAVA堆内存是怎么工作的. 先看下JAVA堆内存是如何划分的,如图: 1.JVM内存划分为堆内存和非堆内存,堆内存分为年轻代(Young Generation).老年代(Old Generation),非堆内存就一个永久代(Permanent Generation). 2.年轻代又分为Eden和Survivor区.Survivor区由FromSpace和ToSpace组成.Eden

  • Android 优化之存储优化的实现

    交换数据格式 Google 推出的 Protocal Buffers 是一种更轻便高效的存储结构,但消耗内存较大. FlatBuffers同样由 Google 推出,专注性能,适合移动端.占用存储比 Protocal 要大. SharePreferences 优化 当 SharedPreferences 文件还没有被加载到内存时,调用 getSharedPreferences 方法会初始化文件并读入内存,这容易导致 耗时更长. Editor 的 commit 或者 apply 方法的区别在于同步

  • 解析JDK14中的java tools简介

    1.1 JDK 14详细概述 JDK 8 已经在 2014年 3月 18日正式可用,JDK 8作为长期支持(Long-Term-Support)版本,距离现在已经 5年多时间过去了.5年时间里很多企业也都换上了 JDK 8,2018年09月25日作为下一个LTS的JDK版本:JDK 11也应运而生,Oracle表示会对JDK 11提供大力支持.长期支持.之后陆续发布了JDK 12 和JDK 13,JDK 14在 2020年 3月17日正式发布. 1.2 JDK 14总体概览 Oracle在202

随机推荐