[譯]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或最新版本。

 

系列文章:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章