.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
方法。您稍後會在本文中看到實際的程式代碼範例。)
緩衝區有兩個取用者, WriteInt32ToBuffer
和 DisplayBufferToConsole
。 一次只有一個消費者(先是 WriteInt32ToBuffer
,然後是 DisplayBufferToConsole
),而且這兩個消費者都不擁有緩衝區。 另請注意,此內容中的「取用者」並不表示緩衝區的只讀檢視;如果指定緩衝區的讀取/寫入檢視,取用者就可以修改緩衝區的內容 WriteInt32ToBuffer
。
方法 WriteInt32ToBuffer
可以在方法呼叫開始與方法返回之前使用緩衝區。 同樣地,DisplayBufferToConsole
在緩衝區執行期間持有緩衝區租約,並在方法結束時釋放租約。 (沒有租賃管理的 API;“租用”是概念性問題。
擁有者、使用者和存留期管理 區段指出,緩衝區始終有一個擁有者。 .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
方法是緩衝區的擁有者。 -
WriteInt32ToBuffer
和DisplayBufferToConsole
方法接受Memory<T>為公用 API。 因此,它們是緩衝區的取用者。 這些方法會逐一取用緩衝區中的元素。
WriteInt32ToBuffer
方法是要將值寫入緩衝區,而DisplayBufferToConsole
方法則不是為此目的設計的。 為了反映這一點,它可以接受類型為ReadOnlyMemory<T>的引數。 如需 的詳細資訊ReadOnlyMemory<T>,請參閱規則 #2:如果緩衝區應該是只讀的,請使用 ReadOnlySpan<T> 或 ReadOnlyMemory<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>
作為參數 -
規則 #2:如果緩衝區應為唯讀,請使用
ReadOnlySpan<T>
或ReadOnlyMemory<T>
-
規則 #3:如果您的方法接受
Memory<T>
並傳回 ,則您不得在方法傳回void
之後使用Memory<T>
實例 -
規則 #4:如果您的方法接受 並傳
Memory<T>
回 Task,則無法在 Task 轉換為終端狀態之後使用Memory<T>
實例 -
規則 #5:如果您的建構函式接受
Memory<T>
做為參數,則建構物件上的實例方法會假設為實例的Memory<T>
取用者 -
規則 #6:如果您的類型上有可
Memory<T>
設定的型別屬性(或對等的實例方法),該對象的實例方法會假設為實例的Memory<T>
取用者 -
規則 #7:如果您有
IMemoryOwner<T>
參考,您必須在某個時間點處置它或轉移其擁有權(但不是兩者) -
規則 #8:如果您的
IMemoryOwner<T>
API 介面中有參數,則會接受該實例的擁有權 -
規則 #9:如果您要包裝同步 P/Invoke 方法,您的 API 應該接受
Span<T>
做為參數 -
規則 #10:如果您要包裝異步 p/invoke 方法,您的 API 應該接受
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> 時所涉及的取捨。
在先前的範例中, DisplayBufferToConsole
方法只會從緩衝區讀取;它不會修改緩衝區的內容。 方法簽章應該變更為以下內容。
void DisplayBufferToConsole(ReadOnlyMemory<char> buffer);
事實上,如果您結合此規則與規則一,我們就能做得更好,重新撰寫方法簽章,如下所示:
void DisplayBufferToConsole(ReadOnlySpan<char> buffer);
現在,DisplayBufferToConsole
方法可以搭配幾乎任何可以想像到的緩衝區類型運作:T[]
、使用 stackalloc 配置的記憶體等等。 您甚至可以直接將 String 傳遞到其中! 如需詳細資訊,請參閱 GitHub 問題 dotnet/docs #25551。
這與稍早所述的「租用」概念有關。 實例上的 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(); }); }
這隻是 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>或任何類似類型的方法。
請考慮下列範例:
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>
實例。
這確實只是規則 #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;
}
由於 Memory<T> 實例可以由 Managed 或 Unmanaged 記憶體支援,當在 Memory<T> 實例上執行的工作完成時,擁有者必須呼叫 Dispose
在 IMemoryOwner<T>。 或者,擁有者可能會將實例的 IMemoryOwner<T> 擁有權轉移至不同的元件,此時取得元件會負責在適當時間呼叫 Dispose
(稍後會有更多內容)。
未能在 IMemoryOwner<T> 實例上呼叫 Dispose
方法可能會導致非受控記憶體洩漏或其他效能下降。
此規則也適用於呼叫 Factory 方法的程式代碼,例如 MemoryPool<T>.Rent。 呼叫者成為所傳回的IMemoryOwner<T>的擁有者,負責在使用完成後釋放該實例。
接受此類型的實例表示您的元件想要取得此實例的擁有權。 您的元件將根據規則第7條負責適當的處置。
將實例擁有權 IMemoryOwner<T> 轉移至不同元件的任何元件,在方法呼叫完成之後,就不應該再使用該實例。
重要
如果您的建構函式接受IMemoryOwner<T>作為參數,其類型應該實作IDisposable,而且您的Dispose方法應該在IMemoryOwner<T>物件上呼叫Dispose
。
根據規則 #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;
}
}
由於您無法跨異步作使用 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;
}