內存包裝類 Memory 和 Span 相關類型

1. 前言

此文章是官方文檔的翻譯,由於官方文檔中文版是機器翻譯的,有些部分有疏漏和錯誤,所以本人進行了翻譯供大家學習,如有問題歡迎指正。

參考資料
memory-and-spans — Microsoft

2. 簡介

.NET 包含多個相互關聯的類型,它們表示任意內存的連續的強類型區域。 這些方法包括:

  • System.Span<T>

    • 用於訪問連續的內存區域
    • 得到該類型的實例:
      • 1個T類型的數組
      • 1個String
      • 1個使用 stackalloc 分配的緩衝區
      • 1個指向非託管內存的指針
    • 實例必須存儲在堆棧(stack)上,因此有很對限制
      • 類的字段不能是此類型
      • 不能在異步操作中使用
  • System.ReadOnlySpan<T>

    • Span<T> 結構體的不可變版本
  • System.Memory<T>

    • 連續的內存區域的包裝器
    • 實例創建
      • T 類型數組
      • String
      • 內存管理器
      • 實例可以存儲在託管堆(managed heap)上,所以它沒有 Span<T> 的限制
  • System.ReadOnlyMemory<T>

    • Memory<T> 結構的不可變版本。
  • System.Buffers.MemoryPool<T>

    • 它將強類型內存塊從內存池分配給所有者
      • IMemoryOwner<T> 實例可以通過調用 MemoryPool<T>.Rent 從池中租用
      • 通過調用 MemoryPool<T>.Dispose() 將其釋放回池中
  • System.Buffers.IMemoryOwner<T>

    • 表示內存塊的所有者,管理其生命周期
  • MemoryManager<T>

    • 一個抽象基類,可用於替換 Memory<T> 的實現,以便 Memory<T> 可以由其他類型(如安全句柄(safe handles))提供支持
    • MemoryManager<T> 適用於高級方案。
  • ArraySegment<T>

    • 是數組的包裝,對應數組中,從特定索引開始的特定數量的一系列元素
  • System.MemoryExtensions

    • 用於將String、數組和數組段(ArraySegment<T>)轉換為 Memory<T> 塊的擴展方法集

System.Span<T>System.Memory<T> 及其對應的只讀類型被設計為:

  • 避免不必要地複製內存或在託管堆上進行內存分配
  • 通過 Slice 方法或這些類型的的構造函數創建它們, 並不涉及複製底層緩衝(underlying buffers): 只更新相關引用和偏移
    • 形象的說就是,只更新我們可以訪問到的內存的位置和範圍,而不是將這些內存數據複製出來

備註:
對於早期框架,Span<T>Memory<T>System.Memory NuGet 包中提供。

使用 memory 和 span

  • 由於 memory 和 span 相關類型通常用於在處理 pipeline 中存儲數據,因此開發人員在使用 Span<T>Memory<T> 和相關類型時要務必遵循一套最佳做法。 Memory<T>Span<T> 使用準則中介紹了這些最佳做法。

3. Memory<T>和Span<T>使用準則

  • Span<T>ReadOnlySpan<T>
    • 是可由託管或非託管內存提供支持的輕量級內存緩衝區
  • Memory<T> 及其相關類型
    • 由託管和非託管內存提供支持
    • Span<T> 不同,Memory<T> 可以存儲在託管堆上

Span<T>Memory<T> 都是可用於 pipeline 的結構化數據的緩衝區。

  • 它們設計的目的是將某些或所有數據有效地傳遞到 pipeline 中的組件,這些組件可以對其進行處理並修改(可選)緩衝區
  • 由於 Memory<T> 及其相關類型可由多個組件或多個線程訪問,因此開發人員必須遵循一些標準使用準則才能生成可靠的代碼

3.1. 所有者, 消費者和生命周期管理

由於可以在各個 API 之間傳送緩衝區,以及由於緩衝區有時可以從多個線程進行訪問,因此請務必考慮生命周期管理。 下面介紹三個核心概念:

  • 所有權:
    • 緩衝區實例的所有者負責生命周期管理,包括當不再使用緩衝區時將其銷毀
    • 所有緩衝區都擁有一個所有者
    • 通常,所有者是創建緩衝區或從工廠接收緩衝區的組件
    • 所有權也可以轉讓;
      • 組件 A 可以將緩衝區的控制權轉讓給組件 B,此時組件 A 就無法再使用該緩衝區,組件 B 將負責在不再使用緩衝區時將其銷毀。
  • 消費:
    • 允許緩衝區實例的消費者通過讀取和寫入來使用緩衝區實例
    • 緩衝區一次可以擁有一個消費者,除非提供了某些外部同步機制
    • 緩衝區的活躍消費者不一定是緩衝區的所有者
  • 租約:
    • 租約是指允許特定組件在一個時間長度範圍內成為緩衝區消費者

以下偽代碼示例闡釋了這三個概念。 它包括:

  • 實例化類型為 CharMemory<T> 緩衝區的
  • 調用 WriteInt32ToBuffer 方法以將整數的字符串表示形式寫入緩衝區
  • 然後調用 DisplayBufferToConsole 方法以顯示緩衝區的值。
using System;

class Program
{
    // Write 'value' as a human-readable string to the output buffer.
    void WriteInt32ToBuffer(int value, Buffer buffer);

    // Display the contents of the buffer to the console.
    void DisplayBufferToConsole(Buffer buffer);

    // Application code
    static void Main()
    {
        var buffer = CreateBuffer();
        try
        {
            int value = Int32.Parse(Console.ReadLine());
            WriteInt32ToBuffer(value, buffer);
            DisplayBufferToConsole(buffer);
        }
        finally
        {
            buffer.Destroy();
        }
    }
}
  • 所有者
    • Main 方法創建緩衝區(在此示例中為 Span<T> 實例),因此它是其所有者。 因此,Main 將負責在不再使用緩衝區時將其銷毀。
  • 消費者
    • WriteInt32ToBufferDisplayBufferToConsole
    • 一次只能有一個消費者
      • 先是 WriteInt32ToBuffer ,然後是 DisplayBufferToConsole
    • 這兩個消費者都不擁有緩衝區
    • 此上下文中的「消費者」並不意味着以只讀形式查看緩衝區;如果提供了以讀/寫形式查看緩衝區的權限,則消費者可以像 WriteInt32ToBuffer 那樣修改緩衝區的內容
  • 租約
    • WriteInt32ToBuffer 方法在方法調用的開始時間和方法返回的時間之間會租用(能消費的)緩衝區
    • DisplayBufferToConsole 在執行時會租用緩衝區,方法返回時將解除租用
    • 沒有用於租約管理的 API,「租用」是概念性內容

3.2. Memory<T> 和所有者/消費者模型

.NET Core 支持以下兩種所有權模型:

  • 支持單個所有權的模型
    • 緩衝區在其整個生存期內擁有單個所有者。
  • 支持所有權轉讓的模型
    • 緩衝區的所有權可以從其原始所有者(其創建者)轉讓給其他組件,該組件隨後將負責緩衝區的生存期管理
    • 該所有者可以反過來將所有權轉讓給其他組件等

使用 System.Buffers.IMemoryOwner<T> 接口顯式的管理緩衝區的所有權。

  • IMemoryOwner<T> 支持上述這兩種所有權模型
  • 具有 IMemoryOwner<T> 引用的組件擁有緩衝區
  • 以下示例使用 IMemoryOwner<T> 實例反映 Memory<T> 緩衝區的所有權。
using System;
using System.Buffers;

class Example
{
    static void Main()
    {
        IMemoryOwner<char> owner = MemoryPool<char>.Shared.Rent();

        Console.Write("Enter a number: ");
        try {
            var value = Int32.Parse(Console.ReadLine());

            var memory = owner.Memory;

            WriteInt32ToBuffer(value, memory);

            DisplayBufferToConsole(owner.Memory.Slice(0, value.ToString().Length));
        }
        catch (FormatException) {
            Console.WriteLine("You did not enter a valid number.");
        }
        catch (OverflowException) {
            Console.WriteLine($"You entered a number less than {Int32.MinValue:N0} or greater than {Int32.MaxValue:N0}.");
        }
        finally {
            owner?.Dispose();
        }
    }

    static void WriteInt32ToBuffer(int value, Memory<char> buffer)
    {
        var strValue = value.ToString();

        var span = buffer.Span;
        for (int ctr = 0; ctr < strValue.Length; ctr++)
            span[ctr] = strValue[ctr];
    }

    static void DisplayBufferToConsole(Memory<char> buffer) =>
        Console.WriteLine($"Contents of the buffer: '{buffer}'");
}

也可以使用 using 編寫此示例:

using System;
using System.Buffers;

class Example
{
    static void Main()
    {
        using (IMemoryOwner<char> owner = MemoryPool<char>.Shared.Rent())
        {
            Console.Write("Enter a number: ");
            try {
                var value = Int32.Parse(Console.ReadLine());

                var memory = owner.Memory;
                WriteInt32ToBuffer(value, memory);
                DisplayBufferToConsole(memory.Slice(0, value.ToString().Length));
            }
            catch (FormatException) {
                Console.WriteLine("You did not enter a valid number.");
            }
            catch (OverflowException) {
                Console.WriteLine($"You entered a number less than {Int32.MinValue:N0} or greater than {Int32.MaxValue:N0}.");
            }
        }
    }

    static void WriteInt32ToBuffer(int value, Memory<char> buffer)
    {
        var strValue = value.ToString();

        var span = buffer.Slice(0, strValue.Length).Span;
        strValue.AsSpan().CopyTo(span);
    }

    static void DisplayBufferToConsole(Memory<char> buffer) =>
        Console.WriteLine($"Contents of the buffer: '{buffer}'");
}

在此代碼中:

  • Main 方法保持對 IMemoryOwner<T> 實例的引用,因此 Main 方法是緩衝區的所有者
  • WriteInt32ToBufferDisplayBufferToConsole 方法接受 “Memory 參數作為公共 API。 因此,它們是緩衝區的消費者。 並且它們同一時間僅有一個消費者

儘管 WriteInt32ToBuffer 方法用於將數據寫入緩衝區,但 DisplayBufferToConsole 方法並不如此。

  • 若要反映此情況,方法參數類型可改為 ReadOnlyMemory<T>

3.3. 「缺少所有者」 的Memory<T> 實例

無需使用 IMemoryOwner<T> 即可創建 Memory<T> 實例。 在這種情況下,緩衝區的所有權是隱式的,並且僅支持單所有者模型。 可以通過以下方式達到此目的:

  • 直接調用 Memory<T> 構造函數之一,傳入 T[],如下面的示例所示
  • 調用 String.AsMemory 擴展方法以生成 ReadOnlyMemory<char> 實例
using System;

class Example
{
    static void Main()
    {
        Memory<char> memory = new char[64];

        Console.Write("Enter a number: ");
        var value = Int32.Parse(Console.ReadLine());

        WriteInt32ToBuffer(value, memory);
        DisplayBufferToConsole(memory);
    }

    static void WriteInt32ToBuffer(int value, Memory<char> buffer)
    {
        var strValue = value.ToString();
        strValue.AsSpan().CopyTo(buffer.Slice(0, strValue.Length).Span);
    }

    static void DisplayBufferToConsole(Memory<char> buffer) =>
        Console.WriteLine($"Contents of the buffer: '{buffer}'");
}
  • 最初創建 Memory<T> 實例的方法是緩衝區的隱式所有者。 無法將所有權轉讓給任何其他組件, 因為沒有 IMemoryOwner<T> 實例可用於進行轉讓
    • 也可以假設運行時的垃圾回收器擁有緩衝區,全部的方法只消費緩衝區

3.4. 使用準則

因為擁有一個內存塊,但打算將其傳遞給多個組件,其中一些組件可能同時在特定的內存塊上運行,所以建立使用Memory<T>Span<T>的準則是很必要的,因為:

  • 所有者釋放它之後,一個組件還可能會保留對該存儲塊的引用。
  • 兩個組件可能並發的同時在緩衝區上進行操作,從而破壞了緩衝區中的數據。
  • 儘管Span<T>堆棧分配性質優化了性能,而且使Span<T>成為在內存塊上運行的首選類型,但它也使Span<T>受到一些主要限制
    • 重要的是要知道何時使用Span<T>以及何時使用 Memory<T>

下面介紹成功使用 Memory<T> 及其相關類型的建議。 除非另有明確說明,否則適用於 Memory<T>Span<T> 的指南也適用於 ReadOnlyMemory<T>ReadOnlySpan<T>

規則 1:對於同步 API,如有可能,請使用 Span<T>(而不是 Memory<T>)作為參數。

Span<T>Memory<T> 更多功能:

  • 可以表示更多種類的連續內存緩衝區
  • Span<T> 還提供比 Memory<T> 更好的性能
  • 無法進行 Span<T>Memory<T> 的轉換
  • 可以使用 Memory<T>.Span 屬性將 Memory<T> 實例轉換為 Span<T>
    • 如果調用方恰好具有 Memory<T> 實例,則它們不管怎樣都可以使用 Span<T> 參數調用你的方法

使用類型 Span<T>(而不是類型 Memory<T>)作為方法的參數類型還可以幫助你編寫正確的消費方法實現。 你將自動進行編譯時檢查,以確保不會企圖訪問此方法租約之外的緩衝區

有時,必須使用 Memory<T> 參數(而不是 Span<T> 參數),即使完全同步也是如此。 所依賴的 API 可能僅接受 Memory<T> 參數。 這沒有問題,但當使用同步的 Memory<T> 時,應注意權衡利弊

規則 2:如果緩衝區應為只讀,則使用 ReadOnlySpan<T> 或 ReadOnlyMemory<T>

在前面的示例中,DisplayBufferToConsole 方法僅從緩衝區讀取數據;它不修改緩衝區的內容。 方法簽名應進行修改如下。

void DisplayBufferToConsole(ReadOnlyMemory<char> buffer);

事實上,如果我們結合 規則1 和 規則2 ,我們可以做得更好,並重寫方法簽名如下:

void DisplayBufferToConsole(ReadOnlySpan<char> buffer);

DisplayBufferToConsole 方法現在幾乎適用於每一個能夠想到的緩衝區類型:

  • T[]、使用 stackalloc 分配的存儲 等等
  • 甚至可以向其直接傳遞 String

規則 3:如果方法接受 Memory<T> 並返回 void,則在方法返回之後不得使用 Memory<T> 實例。

這與前面提到的「租約」概念相關。 返回 void 的方法對 Memory<T> 實例的租用將在進入該方法時開始,並在退出該方法時結束。 請考慮以下示例,該示例會基於控制台中的輸入在循環中調用 Log。

using System;
using System.Buffers;

public class Example
{
    // implementation provided by third party
    static extern void Log(ReadOnlyMemory<char> message);

    // user code
    public static void Main()
    {
        using (var owner = MemoryPool<char>.Shared.Rent())
        {
            var memory = owner.Memory;
            var span = memory.Span;
            while (true)
            {
                int value = Int32.Parse(Console.ReadLine());
                if (value < 0)
                    return;

                int numCharsWritten = ToBuffer(value, span);
                Log(memory.Slice(0, numCharsWritten));
            }
        }
    }

    private static int ToBuffer(int value, Span<char> span)
    {
        string strValue = value.ToString();
        int length = strValue.Length;
        strValue.AsSpan().CopyTo(span.Slice(0, length));
        return length;
    }
}

如果 Log 是完全同步的方法,則此代碼將按預期運行,因為在任何給定時間只有一個活躍的內存實例消費者。 但是,請想像Log具有此實現。

// !!! INCORRECT IMPLEMENTATION !!!
static void Log(ReadOnlyMemory<char> message)
{
    // Run in background so that we don't block the main thread while performing IO.
    Task.Run(() =>
    {
        StreamWriter sw = File.AppendText(@".\input-numbers.dat");
        sw.WriteLine(message);
    });
}

在此實現中,Log 違反租約,因為它在 return 之後仍嘗試在後台使用 Memory<T> 實例。 Main 方法可能會在 Log 嘗試從緩衝區進行讀取時更改緩衝區數據,這可能導致消費者在使用緩存區數據時數據已經被修改。

有多種方法可解決此問題:

  • Log 方法可以按以下所示,返回 Task,而不是 void。
    // An acceptable implementation.
    static Task Log(ReadOnlyMemory<char> message)
    {
        // Run in the background so that we don't block the main thread while performing IO.
        return Task.Run(() => {
                    StreamWriter sw = File.AppendText(@".\input-numbers.dat");
            sw.WriteLine(message);
            sw.Flush();
        });
    }
    
  • 也可以改為按如下所示實現 Log:
    // An acceptable implementation.
    static void Log(ReadOnlyMemory<char> message)
    {
        string defensiveCopy = message.ToString();
        // Run in the background so that we don't block the main thread while performing IO.
        Task.Run(() => {
            StreamWriter sw = File.AppendText(@".\input-numbers.dat");
            sw.WriteLine(defensiveCopy);
            sw.Flush();
        });
    }
    

規則 4:如果方法接受 Memory<T> 並返回某個Task,則在Task轉換為終止狀態之前不得使用 Memory<T> 實例。

這個是 規則3 的異步版本。 以下示例是遵守此規則,按上面例子編寫的 Log 方法:

// An acceptable implementation.
static Task Log(ReadOnlyMemory<char> message)
{
    // Run in the background so that we don't block the main thread while performing IO.
    return Task.Run(() => {
        string defensiveCopy = message.ToString();
        StreamWriter sw = File.AppendText(@".\input-numbers.dat");
        sw.WriteLine(defensiveCopy);
        sw.Flush();
    });
}

此處的「終止狀態」表示任務轉換為 completed, faulted, canceled 狀態。

此指南適用於返回 TaskTask<TResult>ValueTask<TResult> 或任何類似類型的方法。

規則5:如果構造函數接受Memory <T>作為參數,則假定構造對象上的實例方法是Memory<T>實例的消費者。

請看以下示例:

class OddValueExtractor
{
    public OddValueExtractor(ReadOnlyMemory<int> input);
    public bool TryReadNextOddValue(out int value);
}

void PrintAllOddValues(ReadOnlyMemory<int> input)
{
    var extractor = new OddValueExtractor(input);
    while (extractor.TryReadNextOddValue(out int value))
    {
      Console.WriteLine(value);
    }
}

此處的 OddValueExtractor 構造函數接受 ReadOnlyMemory<int> 作為構造函數參數,因此構造函數本身是 ReadOnlyMemory<int> 實例的消費者,並且該實例的所有實例方法也是原始 ReadOnlyMemory<int> 實例的消費者。 這意味着 TryReadNextOddValue 消費 ReadOnlyMemory<int> 實例,即使該實例未直接傳遞到 TryReadNextOddValue 方法。

規則 6:如果一個類型具有可寫的 Memory<T> 類型的屬性(或等效的實例方法),則假定該對象上的實例方法是 Memory<T> 實例的消費者。

這是 規則5 的變體。之所以存在此規則,是因為假定使用了可寫屬性或等效方法來捕獲並保留輸入的 Memory<T> 實例,因此同一對象上的實例方法可以利用捕獲的實例。

以下示例觸發了此規則:

class Person
{
    // Settable property.
    public Memory<char> FirstName { get; set; }

    // alternatively, equivalent "setter" method
    public SetFirstName(Memory<char> value);

    // alternatively, a public settable field
    public Memory<char> FirstName;
}

規則 7:如果具有 IMemoryOwner<T> 的引用,則必須在某些時候對其進行處理或轉讓其所有權(但不同時執行兩個操作)。

  • 由於 Memory<T> 實例可能由託管或非託管內存提供支持,因此在對 Memory<T> 實例執行的工作完成之後,所有者必須調用 MemoryPool<T>.Dispose
  • 此外,所有者可能會將 IMemoryOwner<T> 實例的所有權轉讓給其他組件,同時獲取所有權的組件將負責在適當時間調用 MemoryPool<T>.Dispose
  • 調用 Dispose 方法失敗可能會導致非託管內存泄漏或其他性能降低問題
  • 此規則也適用於調用工廠方法的代碼(如 MemoryPool<T>.Rent)。 調用方將成為工廠生產的 IMemoryOwner<T> 的所有者,並負責在完成後 Dispose 該實例。

規則 8:如果 API 接口中具有 IMemoryOwner<T> 參數,即表示你接受該實例的所有權。

接受此類型的實例表示組件打算獲取此實例的所有權。 該組件將負責根據 規則7 進行正確處理。

在方法調用完成後,將 IMemoryOwner<T> 實例的所有權轉讓給其他組件,之後該組件將不再使用該實例。

重要:
構造函數接受 IMemoryOwner<T> 作為參數的類應實現接口 IDisposable,並且 Dispose 方法中應調用 MemoryPool<T>.Dispose

規則 9:如果要封裝同步的 p/invoke 方法,則應接受 Span<T> 作為參數

根據 規則1,Span<T> 通常是用於同步 API 的合規類型。 可以通過 fixed 關鍵字固定 Span<T> 實例,如下面的示例所示。

using System.Runtime.InteropServices;

[DllImport(...)]
private static extern unsafe int ExportedMethod(byte* pbData, int cbData);

public unsafe int ManagedWrapper(Span<byte> data)
{
    fixed (byte* pbData = &MemoryMarshal.GetReference(data))
    {
        int retVal = ExportedMethod(pbData, data.Length);

        /* error checking retVal goes here */

        return retVal;
    }
}

在上一示例中,如果輸入 span 為空,則 pbData 可以為 Null。 如果 ExportedMethod 方法參數 pbData 不能為 Null,可以按如下示例實現該方法:

public unsafe int ManagedWrapper(Span<byte> data)
{
    fixed (byte* pbData = &MemoryMarshal.GetReference(data))
    {
        byte dummy = 0;
        int retVal = ExportedMethod((pbData != null) ? pbData : &dummy, data.Length);

        /* error checking retVal goes here */

        return retVal;
    }
}

規則 10:如果要包裝異步 p/invoke 方法,則應接受 Memory<T> 作為參數

由於 fixed 關鍵字不能在異步操作中使用,因此使用 Memory<T>.Pin 方法固定 Memory<T> 實例,無論實例代表的連續內存是哪種類型。 下面的示例演示了如何使用此 API 執行異步 p/invoke 調用。

using System.Runtime.InteropServices;

[UnmanagedFunctionPointer(...)]
private delegate void OnCompletedCallback(IntPtr state, int result);

[DllImport(...)]
private static extern unsafe int ExportedAsyncMethod(byte* pbData, int cbData, IntPtr pState, IntPtr lpfnOnCompletedCallback);

private static readonly IntPtr _callbackPtr = GetCompletionCallbackPointer();

public unsafe Task<int> ManagedWrapperAsync(Memory<byte> data)
{
    // setup
    var tcs = new TaskCompletionSource<int>();
    var state = new MyCompletedCallbackState
    {
        Tcs = tcs
    };
    var pState = (IntPtr)GCHandle.Alloc(state);

    var memoryHandle = data.Pin();
    state.MemoryHandle = memoryHandle;

    // make the call
    int result;
    try
    {
        result = ExportedAsyncMethod((byte*)memoryHandle.Pointer, data.Length, pState, _callbackPtr);
    }
    catch
    {
        ((GCHandle)pState).Free(); // cleanup since callback won't be invoked
        memoryHandle.Dispose();
        throw;
    }

    if (result != PENDING)
    {
        // Operation completed synchronously; invoke callback manually
        // for result processing and cleanup.
        MyCompletedCallbackImplementation(pState, result);
    }

    return tcs.Task;
}

private static void MyCompletedCallbackImplementation(IntPtr state, int result)
{
    GCHandle handle = (GCHandle)state;
    var actualState = (MyCompletedCallbackState)(handle.Target);
    handle.Free();
    actualState.MemoryHandle.Dispose();

    /* error checking result goes here */

    if (error)
    {
        actualState.Tcs.SetException(...);
    }
    else
    {
        actualState.Tcs.SetResult(result);
    }
}

private static IntPtr GetCompletionCallbackPointer()
{
    OnCompletedCallback callback = MyCompletedCallbackImplementation;
    GCHandle.Alloc(callback); // keep alive for lifetime of application
    return Marshal.GetFunctionPointerForDelegate(callback);
}

private class MyCompletedCallbackState
{
    public TaskCompletionSource<int> Tcs;
    public MemoryHandle MemoryHandle;
}

注:
Memory<T>.Pin 方法返回內存句柄,且垃圾回收器將不會移動此處內存,直到釋放該方法返回的 MemoryHandle 對象為止。這使您可以檢索和使用該內存地址。