Memory<T> 與 Span<T> 使用指導方針

.NET 包含數個型別,代表任意連續的記憶體區域。 Span<T>ReadOnlySpan<T> 是輕量型記憶體緩衝區,包裝受控或非受控記憶體的參考。 因為這些型別只能儲存在堆疊上,所以有些案例 (包括非同步方法呼叫) 不適合使用它們。 為了解決此問題,.NET 2.1 新增了一些額外的型別,包括 Memory<T>ReadOnlyMemory<T>IMemoryOwner<T>MemoryPool<T>Memory<T> 和其相關類型與 Span<T> 類似,可以由受控和非受控記憶體支援。 Memory<T>Span<T> 不同,可儲存在受控堆積上。

Span<T>Memory<T> 都是結構化資料緩衝區的包裝函式,可以在管線中使用。 也就是說,它們是被設計成能使部分或所有資料都能有效率地被傳遞至管線中的元件,好讓元件可以處理它們或選擇性地修改緩衝區。 由於 Memory<T> 和其相關型別皆可由多個元件或多個執行緒存取,開發人員必須遵循一些標準的使用指導方針,以產生強固的程式碼。

擁有者、取用者及存留期管理

由於緩衝區可在 API 之間傳遞,且緩衝區有時可以從多個執行緒存取,因此請留意如何管理緩衝區的存留期。 有三個核心概念:

  • 所有權。 緩衝區執行個體的擁有者必須負責處理存留期管理,其中包括終結不再使用的緩衝區。 所有緩衝區都具有單一擁有者。 擁有者通常是建立緩衝區,或是從處理站接收緩衝區的元件。 擁有權也可以被轉移;元件 A 可以將緩衝區的控制轉移給元件 B,這會使元件 A 無法繼續使用該緩衝區,且元件 B 需負責在該緩衝區不再使用時終結它。

  • 使用量。 緩衝區執行個體的取用者可以透過從緩衝區執行個體進行讀取,或在某些情況下對它進行寫入,來使用該緩衝區執行個體。 緩衝區一次可以有一個取用者,除非已提供某種外部同步處理機制。 緩衝區的作用中取用者並不一定是該緩衝區的擁有者。

  • 租用。 租用是特定元件可作為緩衝區取用者的時間長度。

下列虛擬程式碼範例會說明這三個概念。 虛擬程式碼中的 Buffer 代表型別 CharMemory<T>Span<T> 緩衝區。 Main 方法具現化緩衝區、呼叫 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 方法建立緩衝區,因此為其擁有者。 因此,Main 必須負責在該緩衝區不再使用時終結它。 虛擬程式碼透過在緩衝區上呼叫 Destroy 方法來說明這一點。 (實際上,Memory<T>Span<T> 都沒有 Destroy 方法。您稍後會在本文中看到真實的程式碼範例。)

該緩衝區具有兩個取用者,WriteInt32ToBufferDisplayBufferToConsole。 一次只會有一個取用者 (先是 WriteInt32ToBuffer,然後是 DisplayBufferToConsole),且這兩個取用者都不是該緩衝區的擁有者。 請注意,「取用者」一詞在此內容中並不代表針對緩衝區的唯讀檢視;如果將緩衝區的讀取/寫入檢視授與取用者,其將能修改緩衝區的內容 (如此範例中的 WriteInt32ToBuffer)。

WriteInt32ToBuffer 方法在介於方法呼叫和方法傳回之間的時間內,針對該緩衝區會具有租用 (可取用該緩衝區)。 同樣地,在緩衝區執行期間,DisplayBufferToConsole 針對該緩衝區會具有租用,而該租用會在該方法回朔時釋放。 (沒有適用於租用管理的 API,「租用」本身是一種概念)。

記憶體<T> 和擁有者/取用者模型

如同擁有者、取用者及存留期管理一節所述,緩衝區一律會有一個擁有者。 .NET 支援兩種擁有權模型:

  • 支援單一擁有權的模型。 緩衝區在其存留期的整個期間,都會有單一的擁有者。

  • 支援擁有權傳輸的模型。 緩衝區的擁有權可以從其原始擁有者 (其建立者) 轉換到另一個元件,而該元件將會接手負責該緩衝區的存留期管理。 該擁有者接著可以再次將擁有權轉換到又另一個元件,依此類推。

您會使用 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
        {
            string? s = Console.ReadLine();

            if (s is null)
                return;

            var value = Int32.Parse(s);

            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
            {
                string? s = Console.ReadLine();

                if (s is null)
                    return;

                var value = Int32.Parse(s);

                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<T> 作為公用 API。 因此,它們是該緩衝區的取用者。 這些方法會一次取用一個緩衝區。

雖然 WriteInt32ToBuffer 方法的目的是要將值寫入緩衝區,但 DisplayBufferToConsole 方法則不會這麼做。 為了反映此情況,它可以接受 ReadOnlyMemory<T>類型的引數。 如需 ReadOnlyMemory<T> 的詳細資訊,請參閱規則 #2:在緩衝區必須是唯讀的情況下使用 ReadOnlySpan<T> 或 ReadOnlyMemory<T>

「無擁有者」的 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: ");
        string? s = Console.ReadLine();

        if (s is null)
            return;

        var value = Int32.Parse(s);

        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> 執行個體來促成該轉移。 (或者,您也可以想像執行階段的記憶體回收行程擁有該緩衝區,而所有方法皆只是取用該緩衝區)。

使用方針

由於記憶體區塊具有擁有者,但又同時會被傳遞至多個元件,且某些元件則會在特定的記憶體區塊上運作,因此有必要針對 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 結合,我們便能進一步將方法簽章重新撰寫為下列項目:

void DisplayBufferToConsole(ReadOnlySpan<char> buffer);

DisplayBufferToConsole 方法現在能與幾乎所有已知的緩衝區類型搭配運作:T[]、搭配 stackalloc 配置的儲存體等。 您甚至可以直接將 String 傳遞給它! 如需詳細資訊,請參閱 GitHub 問題 dotnet/docs #25551

規則 #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)
            {
                string? s = Console.ReadLine();

                if (s is null)
                    return;

                int value = Int32.Parse(s);
                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 會違反其租用,因為在原始方法已傳回之後,它仍然會於背景嘗試使用 Memory<T> 執行個體。 在 Log 嘗試從緩衝區讀取時,Main 方法可能會對緩衝區造成變動,並進一步導致資料損毀。

有幾個方式可以解決此情況:

  • Log 方法可以傳回 Task 而非 void,如下列 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(() => {
            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(() =>
    {
        StreamWriter sw = File.AppendText(@".\input-numbers.dat");
        sw.WriteLine(message);
        sw.Flush();
    });
}

在這裡,「終止狀態」表示工作已轉換為已完成、已發生錯誤,或已取消的狀態。 換句話說,「終止狀態」的意思是「任何會導致等候擲回或繼續執行的項目」。

此指南適用於會傳回 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 的變化。 此規則之所以存在,是因為我們會假設屬性設定者或對等方法會擷取或保留其輸入,好讓相同物件上的執行個體方法能夠運用擷取的狀態。

下列範例會觸發此規則:

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> 執行個體上執行的作業完成時,擁有者必須在 IMemoryOwner<T> 呼叫 Dispose。 或者,擁有者可以將 IMemoryOwner<T> 執行個體的擁有權轉換給不同的元件,而在轉換之後,取得擁有權的元件便必須負責在適當的時機呼叫 Dispose (將於稍後詳述)。

若未在 IMemoryOwner<T> 執行個體上呼叫 Dispose 方法,可能會導致非受控記憶體流失或其他效能降低。

此規則也適用於呼叫 Factory 方法 (例如 MemoryPool<T>.Rent) 的程式碼。 呼叫者會成為傳回 IMemoryOwner<T> 的擁有者,並須負責在完成時處置執行個體。

規則 #8:如果您的 API 介面中具有 IMemoryOwner<T> 參數,便代表您接受該執行個體的擁有權。

接受此類型的執行個體,便代表您的元件意圖取得此執行個體的擁有權。 您的元件必須負責進行規則 #7 中所述的適當處置。

IMemoryOwner<T> 執行個體的擁有權轉換給另一個元件的任何元件,都不應該在方法呼叫完成之後繼續使用該執行個體。

重要

如果您的建構函式接受 IMemoryOwner<T> 做為參數,其型別應實作 IDisposable,而且您的 Dispose 方法應呼叫 IMemoryOwner<T> 物件上的 Dispose

規則 #9:如果您正在包裝同步的 p/invoke 方法,您的 API 應接受 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;
    }
}

在先前的範例中,pbData 在如輸入 span 為空白之類的情況下,可能會是 Null。 如果匯出的方法一定需要 pbData 是非 Null (就算 cbData 是 0),則可以如下所示實作該方法:

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 方法,您的 API 應接受 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;
}

另請參閱