[译]C# 7系列,Part 10: Span<T> and universal memory management Span<T>和统一内存管理

原文:https://blogs.msdn.microsoft.com/mazhou/2018/03/25/c-7-series-part-10-spant-and-universal-memory-management/

译注:这是本系列最后一篇文章

背景

.NET是一个托管平台,这意味着内存访问和管理是安全的、自动的。所有类型都是由.NET完全管理的,它在执行栈或托管堆上分配内存。

在互操作的事件或低级别开发中,你可能希望访问本机对象和系统内存,这就是为什么会有互操作这部分了,有一部分类型可以封送进入本机世界,调用本机api,转换托管/本机类型和在托管代码中定义一个本机结构。

问题1:内存访问模式

在.NET世界中,你可能会对3种内存类型感兴趣。

  • 托管堆内存,如数组;
  • 栈内存,如使用stackalloc创建的对象;
  • 本机内存,例如本机指针引用。

上面每种类型的内存访问可能需要使用为它设计的语言特性:

  • 要访问堆内存,请在支持的类型(如字符串)上使用fixed(固定)指针,或者使用其他可以访问它的适当.NET类型,如数组或缓冲区;
  • 要访问堆栈内存,请使用stackalloc创建指针;
  • 要访问非托管系统内存,请使用Marshal api创建指针。

你看,不同的访问模式需要不同的代码,对于所有连续的内存访问没有单一的内置类型。

问题2:性能

在许多应用程序中,最消耗CPU的操作是字符串操作。如果你对你的应用程序运行一个分析器会话,你可能会发现95%的CPU时间都用于调用字符串和相关函数。

Trim、IsNullOrWhiteSpace和SubString可能是最常用的字符串api,它们也很重:

  • Trim()或SubString()返回一个新的字符串对象,该对象是原始字符串的一部分,如果有办法切片并返回原始字符串的一部分来保存一个副本,其实没有必要这样做。
  • IsNullOrWhiteSpace()获取一个需要内存拷贝的字符串对象(因为字符串是不可变的)。
  • 特别的,字符串连接很昂贵(译注:指消耗很多CPU),需要n个字符串对象,产生n个副本,生成n-1个临时字符串对象,并返回一个字符串对象,那n-1个副本本可以排除的如果有办法直接访问返回字符串内存和执行顺序写入。

Span<T>

System.Span<T>是一个只在栈上的类型(ref struct),它封装了所有的内存访问模式,它是一种用于通用连续内存访问的类型。你可以认为Span<T>的实现包含一个虚拟引用和一个长度,接受全部3种内存访问类型。

你可以使用Span<T>的构造函数重载或来自数组、stackalloc的指针和非托管指针的隐式操作符来创建Span<T>。

// 使用隐式操作 Span<char>(char[])。
Span<char> span1 = new char[] { 's', 'p', 'a', 'n' };

// 使用stackalloc。
Span<byte> span2 = stackalloc byte[50];

// 使用构造函数。
IntPtr array = new IntPtr();
Span<int> span3 = new Span<int>(array.ToPointer(), 1);

一旦你有了一个Span<T>对象,你可以用指定的索引来设置值,或者返回Span的一部分:

// 创建一个实例:
Span<char> span = new char[] { 's', 'p', 'a', 'n' };
// 访问第一个元素的引用。
ref char first = ref span[0];
// 给引用设置一个新的值。
first = 'S';
// 新的字符串"Span".
Console.WriteLine(span.ToArray());
// 返回一个新的span从索引1到末尾.
// 得到"pan"。
Span<char> span2 = span.Slice(1);
Console.WriteLine(span2.ToArray());

你可以使用Slice()方法编写一个高性能Trim()方法:

private static void Main(string[] args)
{
    string test = "   Hello, World! ";
    Console.WriteLine(Trim(test.ToCharArray()).ToArray());
}

private static Span<char> Trim(Span<char> source)
{
    if (source.IsEmpty)
    {
        return source;
    }

    int start = 0, end = source.Length - 1;
    char startChar = source[start], endChar = source[end];

    while ((start < end) && (startChar == ' ' || endChar == ' '))
    {
        if (startChar == ' ')
        {
            start++;
        }

        if (endChar == ' ')
        {
            end—;
        }

        startChar = source[start];
        endChar = source[end];
    }

    return source.Slice(start, end - start + 1);
}

上面的代码不复制字符串,也不生成新的字符串,它通过调用Slice()方法返回原始字符串的一部分。

因为Span<T>是一个ref结构,所以所有的ref结构限制都适用。也就是说,你不能在字段、属性、迭代器和异步方法中使用Span<T>。

Memory<T>

System.Memory<T>是一个System.Span<T>的包装。使其在迭代器和异步方法中可访问。使用Memory<T>上的Span属性来访问底层内存,这在异步场景中非常有用,比如文件流和网络通信(HttpClient等)。

下面的代码展示了这种类型的简单用法。

private static async Task Main(string[] args)
{
    Memory<byte> memory = new Memory<byte>(new byte[50]);
    int count = await ReadFromUrlAsync("https://www.microsoft.com", memory).ConfigureAwait(false);
    Console.WriteLine("Bytes written: {0}", count);
}

private static async ValueTask<int> ReadFromUrlAsync(string url, Memory<byte> memory)
{
    using (HttpClient client = new HttpClient())
    {
        Stream stream = await client.GetStreamAsync(new Uri(url)).ConfigureAwait(false);
        return await stream.ReadAsync(memory).ConfigureAwait(false);
    }
}

框架类库/核心框架(FCL/CoreFx)将在.NET Core 2.1中为流、字符串等添加基于类Span类型的api。

ReadOnlySpan<T> 和 ReadOnlyMemory<T>

System.ReadOnlySpan<T>是System.Span<T>的只读版本。其中,索引器返回一个只读的ref对象,而不是ref对象。在使用System.ReadOnlySpan<T>这个只读的ref结构时,你可以获得只读的内存访问权限

这对于string类型非常有用,因为string是不可变的,所以它被视为只读的span。

我们可以重写上面的代码来实现Trim()方法,使用ReadOnlySpan<T>:

private static void Main(string[] args)
{
    // Implicit operator ReadOnlySpan(string).
    ReadOnlySpan<char> test = "   Hello, World! ";
    Console.WriteLine(Trim(test).ToArray());
}

private static ReadOnlySpan<char> Trim(ReadOnlySpan<char> source)
{
    if (source.IsEmpty)
    {
        return source;
    }

    int start = 0, end = source.Length - 1;
    char startChar = source[start], endChar = source[end];

    while ((start < end) && (startChar == ' ' || endChar == ' '))
    {
        if (startChar == ' ')
     {
            start++;
        }

        if (endChar == ' ')
        {
            end—;
        }

        startChar = source[start];
        endChar = source[end];
    }

    return source.Slice(start, end - start + 1);
}

如你所见,方法体中没有任何更改;我只是将参数类型从Span<T>更改为ReadOnlySpan<T>,并使用隐式操作符将字符串直接转换为ReadOnlySpan<char>。

Memory扩展方法

System.MemoryExtensions类包含针对不同类型的扩展方法,这些方法使用span类型进行操作,下面是常用的扩展方法列表,其中许多是使用span类型的现有api的等效实现。

  • AsSpan, AsMemory:将数组转换成Span<T>或Memory<T>或它们的只读副本。
  • BinarySearch, IndexOf, LastIndexOf:搜索元素和索引。
  • IsWhiteSpace, Trim, TrimStart, TrimEnd, ToUpper, ToUpperInvariant, ToLower, ToLowerInvariant:类似字符串的Span<char>操作。

内存封送

在某些情况下,你可能希望对内存类型和系统缓冲区有较低级别的访问权限,并在span和只读span之间进行转换。System.Runtime.InteropServices.MemoryMarshal静态类提供了此类功能,允许你控制这些访问场景。下面的代码展示了使用span类型来做首字母大写,这个实现性能高,因为没有临时的字符串分配。

private static void Main(string[] args)
{
    string source = "span like types are awesome!";
    // source.ToMemory() 转换变量 source 从字符串类型为 ReadOnlyMemory<char>,
    // and MemoryMarshal.AsMemory 转换 ReadOnlyMemory<char> 为 Memory<char>
    // 这样你就可以修改元素了。
    TitleCase(MemoryMarshal.AsMemory(source.AsMemory()));
    // 得到 "Span like types are awesome!";
    Console.WriteLine(source);
}

private static void TitleCase(Memory<char> memory)
{
    if (memory.IsEmpty)
    {
        return;
    }

    ref char first = ref memory.Span[0];
    if (first >= 'a' && first <= 'z')
    {
        first = (char)(first - 32);
    }
}

结论

Span<T>和Memory<T>支持以统一的方式访问连续内存,而不管内存是如何分配的。它对本地开发场景以及高性能场景非常有帮助。特别是,在使用span类型处理字符串时,你将获得显著的性能改进。这是C# 7.2中一个非常好的创新特性。

注意:要使用此功能,你需要使用Visual Studio 2017.5和C#语言版本7.2或最新版本。

 

系列文章:

posted @ 2020-01-02 16:28  wenhx  阅读(...)  评论(...编辑  收藏