Примечание
Для доступа к этой странице требуется авторизация. Вы можете попробовать войти или изменить каталоги.
Для доступа к этой странице требуется авторизация. Вы можете попробовать изменить каталоги.
.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 и иногда могут быть доступны из нескольких потоков, поэтому помните, как управляется время существования буфера. Существует три основных понятия:
собственность. Владелец экземпляра буфера отвечает за управление жизненным циклом, включая уничтожение буфера, когда он больше не используется. Все буферы имеют одного владельца. Как правило, владельцем является компонент, который создал буфер или получил его из фабрики. Также можно передать владение; Компонент-A может отказаться от управления буфером в Component-B, в котором компонент-A больше не может использовать буфер, и Компонент-B становится ответственным за уничтожение буфера, когда он больше не используется.
Потребление. Потребитель экземпляра буфера может использовать экземпляр буфера, считывая его из него и, возможно, записывая в него. Буферы могут иметь одного потребителя одновременно, если не предоставлен какой-то внешний механизм синхронизации. Активный потребитель буфера не обязательно является владельцем буфера.
Аренда. Срок аренды — это период времени, когда определенный компонент может быть потребителем буфера.
Следующий пример псевдокода иллюстрирует эти три понятия.
Buffer
в псевдокоде представляет собой буфер типа Memory<T>, который может быть Span<T> или Char. Метод 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
. Позже в этой статье вы увидите примеры кода.)
Буфер имеет двух потребителей: WriteInt32ToBuffer
и DisplayBufferToConsole
. Одновременно существует только один потребитель (сначала 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
метод является владельцем буфера. - Методы
WriteInt32ToBuffer
иDisplayBufferToConsole
принимают 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> и Span<T>, также применимы к ReadOnlyMemory<T> и ReadOnlySpan<T>, если не указано иное.
-
Правило #1. Для синхронного API используйте
Span<T>
вместоMemory<T>
параметра, если это возможно. -
Правило 2. Использование
ReadOnlySpan<T>
илиReadOnlyMemory<T>
если буфер должен быть только для чтения -
Правило №3: Если ваш метод принимает
Memory<T>
и возвращаетvoid
, то после возврата из метода не следует использовать экземплярMemory<T>
. -
Правило #4: Если ваш метод принимает
Memory<T>
и возвращает задачу, не используйте экземплярMemory<T>
после того, как задача перейдет в финальное состояние. -
Правило 5. Если конструктор принимает
Memory<T>
в качестве параметра, методы экземпляров в созданном объекте считаются потребителямиMemory<T>
экземпляра. -
Правило #6. Если у вас есть устанавливаемое свойство типа
Memory<T>
(или эквивалентный метод экземпляра) в вашем типе, предполагается, что методы экземпляра этого объекта являются потребителями экземпляраMemory<T>
. -
Правило 7. Если у вас есть
IMemoryOwner<T>
ссылка, вы должны в какой-то момент обязательно удалить её или передать её владение (но не и то, и другое). -
Правило 8. Если у вас есть параметр в области API, вы принимаете право на владение этим экземпляром
IMemoryOwner<T>
. -
Правило 9. Если вы упаковываете синхронный метод P/Invoke, API должен принять
Span<T>
в качестве параметра -
Правило #10. Если вы упаковываете асинхронный метод p/invoke, API должен принимать
Memory<T>
в качестве параметра.
Правило 1. Для синхронного API используйте диапазон<T> вместо памяти<T> в качестве параметра, если это возможно.
Span<T> является более универсальным, чем Memory<T> и может представлять более широкий спектр смежных буферов памяти. Span<T> также обеспечивает более высокую производительность, чем Memory<T>. Наконец, можно использовать свойство Memory<T>.Span для преобразования экземпляра Memory<T> в Span<T>, хотя преобразование Span<T> в Memory<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 issue dotnet/docs #25551.
Правило 3. Если метод принимает память<T> и возвращается void
, после возврата метода не следует использовать экземпляр Memory<T> после возврата метода.
Это относится к концепции аренды, упомянутой ранее. Период использования метода, возвращающего void, для Memory<T> экземпляра начинается в момент входа в метод и заканчивается при выходе из метода. Рассмотрим следующий пример, который вызывает 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> и возвращает задачу, не следует использовать объект 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();
});
}
Здесь "состояние терминала" означает, что задача переходит в завершенное, неисправное или отмененное состояние. Другими словами, "состояние терминала" означает "все, что приведет к возникновению ожидания или продолжению выполнения".
Это руководство относится к методам, возвращающим Task, Task<TResult>, ValueTask<TResult> или любой аналогичный тип.
Правило 5. Если конструктор принимает память<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>
. Это означает, что 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> может поддерживаться управляемой или неуправляемой памятью, владелец должен вызвать Dispose
на IMemoryOwner<T>, когда работа с экземпляром Memory<T> завершена. Кроме того, владелец может передать владение IMemoryOwner<T> экземпляром другому компоненту, и компонент, принимающий, становится ответственным за вызов Dispose
в соответствующее время (подробнее об этом позже).
Отсутствие вызова метода Dispose
на экземпляре IMemoryOwner<T> может привести к утечкам неуправляемой памяти или снижению производительности.
Это правило также применяется к коду, который вызывает фабричные методы, такие как MemoryPool<T>.Rent. Вызывающий становится владельцем возвращаемого IMemoryOwner<T> экземпляра и отвечает за его удаление после завершения использования.
Правило 8. Если у вас есть параметр IMemoryOwner<T> в области API, вы принимаете право на владение этим экземпляром.
Принятие экземпляра этого типа сигнализирует о том, что компонент намерен взять на себя ответственность за этот экземпляр. Ваш компонент становится ответственным за надлежащее удаление в соответствии с правилом №7.
Любой IMemoryOwner<T> компонент, который передает владение экземпляром другому компоненту, больше не должен использовать этот экземпляр после завершения вызова метода.
Это важно
Если ваш конструктор принимает IMemoryOwner<T> в качестве параметра, его тип должен реализовывать IDisposable, а метод Dispose должен вызывать Dispose
на объекте IMemoryOwner<T>.
Правило #9. Если вы упаковываете синхронный метод p/invoke, API должен принять Span<T> в качестве параметра.
Согласно правилу #1, Span<T> обычно правильный тип, используемый для синхронных API. Вы можете закрепить Span<T> экземпляры с помощью ключевого fixed
слова, как показано в следующем примере.
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;
}