閱讀英文

共用方式為


<記憶體T>和Span<T>使用指導方針

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

兩者 Span<T>Memory<T> 都是包裹在結構化數據緩衝區上的包裝器,可用於管線中。 也就是說,其設計目的是讓部分或所有數據可以有效率地傳遞至管線中的元件,以處理這些元件,並選擇性地修改緩衝區。 由於 Memory<T> 及其相關類型可由多個元件或多個線程存取,因此請務必遵循一些標準使用方針來產生健全的程序代碼。

擁有者、消費者和生命週期管理

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

  • 所有權。 緩衝區實例的擁有者負責存留期管理,包括不再使用時終結緩衝區。 所有緩衝區都有單一擁有者。 一般而言,擁有者是建立緩衝區或從處理站接收緩衝區的元件。 擁有權也可以轉移; Component-A 可以將緩衝區的控制權放棄給 Component-B,此時 Component-A 可能不再使用緩衝區, 而 Component-B 會在不再使用時負責終結緩衝區。

  • 消費。 緩衝區實例的取用者可藉由讀取緩衝區實例,並可能寫入緩衝區實例來使用緩衝區實例。 除非有一些外部同步機制,否則緩衝區一次只能有一個消費者。 緩衝區的活躍使用者不一定是緩衝區的擁有者。

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

下列虛擬程式碼範例說明這三個概念。 在虛擬程式代碼中,Buffer代表Char型緩衝區的Memory<T>Span<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 會建立緩衝區,因此是其擁有者。 因此, 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>

「無擁有者」記憶體<T> 實例

您可以建立 Memory<T> 實體,而不使用 IMemoryOwner<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> 和 的指引也適用於 ReadOnlyMemory<T>Span<T>ReadOnlySpan<T> ,除非另有說明。

規則 #1:針對同步 API,請盡可能使用 Span<T> 而非 Memory<T> 做為參數

Span<T>Memory<T> 更具多功能性,且能代表更廣泛的連續記憶體緩衝區。 Span<T> 也提供比 Memory<T>更好的效能。 最後,您可以使用Memory<T>.Span屬性將Memory<T>實例轉換成Span<T>,但是無法進行 Span<T> 到記憶體<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);

事實上,如果您結合此規則與規則一,我們就能做得更好,重新撰寫方法簽章,如下所示:

void DisplayBufferToConsole(ReadOnlySpan<char> buffer);

現在,DisplayBufferToConsole 方法可以搭配幾乎任何可以想像到的緩衝區類型運作:T[]、使用 stackalloc 配置的記憶體等等。 您甚至可以直接將 String 傳遞到其中! 如需詳細資訊,請參閱 GitHub 問題 dotnet/docs #25551

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

這與稍早所述的「租用」概念有關。 實例上的 Memory<T> void 傳回方法租用會在輸入 方法時開始,並在方法結束時結束。 請考慮下列範例,其會根據主控台的輸入,在循環中呼叫 Log

// <Snippet1>
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;
    }
}
// </Snippet1>

// Possible implementation of Log:
    // private static void Log(ReadOnlyMemory<char> message)
    // {
    //     Console.WriteLine(message);
    // }

如果 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> 實例。 Main方法可能會在Log嘗試從緩衝區讀取時變動緩衝區,這可能會導致數據損毀。

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

  • 方法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> 實例。

這隻是 Rule #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();
    });
}

在這裡,「終端機狀態」表示工作會轉換成已完成、錯誤或已取消的狀態。 換句話說,「終端狀態」表示「任何會導致 await 發生錯誤或繼續執行的情形」。

本指南適用於傳回 Task、、 Task<TResult>ValueTask<TResult>或任何類似類型的方法。

規則 #5:如果您的建構函式接受 Memory<T> 做為參數,則建構物件上的實例方法會假設為記憶體<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方法,TryReadNextOddValue仍會消耗ReadOnlyMemory<int>實例。

規則 #6:如果您的類型上有可設定的 Memory<T> 型別屬性(或對等的實例方法),該對象的實例方法會假設為 Memory<T> 實例的取用者

這確實只是規則 #5 的變體。 此規則存在,因為假設屬性 setter 或對等方法會擷取並保存其輸入,因此相同物件上的實例方法可能會利用擷取的狀態。

下列範例會觸發此規則:

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> 實例可以由 Managed 或 Unmanaged 記憶體支援,當在 Memory<T> 實例上執行的工作完成時,擁有者必須呼叫 DisposeIMemoryOwner<T>。 或者,擁有者可能會將實例的 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 如果輸入範圍是空的,可以是 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 應該接受記憶體<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;
}

另請參閱