99% 的.NET 程序员都忽略的性能利器:Span<T>

93 阅读8分钟

前言

作为.NET程序员,你是否使用过指针?是否编写过不安全代码?

为什么要使用指针?在什么场景下需要使用它?又该如何安全、高效地使用指针?

如果你能清晰地回答这几个问题,那么你就已经为理解本文的主题做好了准备。C# 构建了一个托管世界,在这个世界里,只要不编写不安全代码、不操作指针,就能获得.NET至关重要的安全保障——即“什么都不用担心”。然而,当我们需要操作的数据并不在托管内存中,而是位于非托管内存(如本机内存或堆栈上)时,该如何编写代码来支持来自任意区域的内存呢?这时就需要编写不安全代码,使用指针了。

如何安全、高效地操作任何类型的内存,一直是C#开发中的痛点。今天,我们就来深入探讨这个话题,讲清楚 What(是什么)How(怎么做)Why(为什么),让你不仅知其然,更知其所以然。以后有人问你这个问题,就让他来看这篇文章吧,呵呵。

What - 痛点是什么?

在回答这个问题之前,我们先来总结一下如何用C#操作不同类型的内存。

托管内存(Managed Memory)

var managedMemory = new Student();

这是最熟悉的场景。只需使用 new 操作符,就分配了一块托管堆内存。你不需要手动释放它,因为垃圾收集器(GC)会智能地决定何时释放。这就是所谓的托管内存。

GC通过复制内存的方式分代管理小对象(size < 85000 bytes),而专门为大对象(size >= 85000 bytes)开辟大对象堆(LOH)。管理大对象时,并不会复制它,而是将其放入一个列表,提供较慢的分配和释放,而且很容易产生内存碎片。

栈内存(Stack Memory)

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

使用 stackalloc 关键字可以非常快速地分配一块栈内存,也不需要手动释放。它会随着当前作用域的结束而自动释放,比如方法执行结束后自动释放。

栈内存的容量非常小(ARM、x86 和 x64 计算机,默认堆栈大小为 1 MB)。当你使用的栈内存超过1MB时,就会抛出 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.AllocHGlobalMarshal.AllocCoTaskMem 方法来分配非托管堆内存。非托管意味着垃圾回收器(GC)不可见,且必须手动调用 Marshal.FreeHGlobalMarshal.FreeCoTaskMem 释放内存。千万不能忘记释放,否则会导致内存泄漏。

抛砖引玉 - 痛点

让我们设计一个解析完整或部分字符串为整数的API:

public interface IntParser
{
    // 解析整个字符串
    int Parse(string managedMemory);

    // 解析字符串的一部分
    int Parse(string managedMemory, int startIndex, int length);

    // 解析位于非托管堆/栈上的字符
    unsafe int Parse(char* pointerToUnmanagedMemory, int length);

    // 解析非托管内存中字符的一部分
    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#操作各种内存就是这么复杂和麻烦。虽然大多数开发者能理解这些类的设计,但使用不安全代码和指针存在巨大风险:操作危险、不可控,无法获得.NET至关重要的安全保障,还可能引发堆栈溢出、内存碎片、栈撕裂等问题。

微软的工程师早已意识到这一痛点,于是 Span<T> 诞生了——它正是为解决这一问题而生的。

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<T> 就是所有类型内存的抽象化身,表示一段连续的内存。它的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的工作机制

先来窥视一下源码:

94c5c1fb8f82d73f8933fb3f20a142be_1082769-20181127220822341-1752279127.png

我已经圈出的三个字段:偏移量索引长度(使用过 ArraySegment<byte> 的同学可能已经大致理解到设计的精髓了)。这就是它的主要设计。

当我们访问 Span 表示的整体或部分内存时,内部的索引器会按照以下算法运算指针(伪代码):

ref T this[int index]
{
    get => ref ((ref reference + byteOffset) + index * sizeOf(T));
}

整个变化过程,如图所示:

0b6de7c8fc89924ba6f54fc08c476392_1082769-20181127220916777-930660188.gif

上面的动画非常清楚地展示了:旧 Span 整合它的引用和偏移,形成新 Span 的引用。整个过程没有复制内存,也没有返回相对位置上的副本,而是直接返回实际存储位置的引用,因此性能非常高。

由于新 Span 获得并更新了引用,垃圾回收器(GC)知道如何处理它,从而获得.NET至关重要的安全保障。内部还会自动执行边界检查以确保内存安全——这些都由 Span 内部默默完成,开发人员完全无需担心。

正是由于 Span 的高性能,目前许多基础设施已经开始支持它,甚至使用它进行重构。例如 System.String.Substring 方法,众所周知它性能消耗较大:首先创建新字符串,再从原始字符串复制字符集。而使用 Span 可以实现 Non-AllocatingZero-copying

使用 String.SubstringSpan.Slice 分别截取长度为10和1000的字符串的前一半。从指标 Mean 可以看出,Substring 的耗时随字符串长度呈线性增长,而 Slice 几乎保持不变;从 Allocated Memory/Op 指标看,Slice 没有分配新内存。

实践出真知。可以预见,Span 未来将成为 .NET 下编写高性能应用程序的重要积木,广泛应用于微服务、物联网、云原生等场景。

补充说明

从交流发现,有些同学误解了 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 解决的是以下几点:

  • 高性能:避免不必要的内存分配和复制。

  • 高效率:为任何具有无复制语义的连续内存块提供安全和可编辑的视图,极大简化内存操作(无需为每种内存类型写重载方法)。

  • 内存安全Span 内部自动执行边界检查,确保安全读写内存。但它不管理内存释放,因为所有权不属于它。

它的目标是成为未来 .NET 高性能编程的重要基石。

总结

从技术本质上看,Span<T> 是一种 ref-like type(类似引用的结构体);从应用场景看,它是 高性能的 sliceable type(可切片类型)

综上所述,Span 是一种类似于数组的结构体,但具有创建数组一部分视图而无需在堆上分配新对象或复制数据的“超能力”。

看完本文,如果你理解了 SpanWhatWhyHow,那么作者的布道目的就达到了。对于还不理解的同学,建议多读几遍。

关键词

Span、.NET高性能、内存操作、托管内存、栈内存、本机内存、unsafe、指针、GC、内存安全、边界检查、Zero-copy、Non-Allocating、ref-like type、可切片类型、ArraySegment、Marshal、stackalloc、内存碎片、基准测试

最后

如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享知识的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。

也可以加入微信公众号 [DotNet技术匠] 社区,与其他热爱技术的同行一起交流心得,共同成长!

优秀是一种习惯,欢迎大家留言学习!

作者: justmine 

出处:cnblogs.com/justmine/p/10006621.html

声明:网络内容,仅供学习,尊重版权,侵权速删,歉意致谢!