Compartir a través de


Directrices de uso de memoria<T> y span<T>

.NET incluye una serie de tipos que representan una región de memoria contigua arbitraria. Span<T> y ReadOnlySpan<T> son búferes de memoria ligeros que encapsulan referencias a memoria administrada o no administrada. Dado que estos tipos solo se pueden almacenar en la pila, no son adecuados para escenarios como llamadas a métodos asincrónicos. Para solucionar este problema, .NET 2.1 agregó algunos tipos adicionales, como Memory<T>, ReadOnlyMemory<T>, IMemoryOwner<T>y MemoryPool<T>. Al igual que Span<T>, Memory<T> y sus tipos relacionados pueden estar respaldados por la memoria administrada y no administrada. A diferencia de Span<T>, Memory<T> se puede almacenar en el montón administrado.

Span<T> y Memory<T> son los contenedores de los búferes de datos estructurados que se pueden usar en las canalizaciones. Es decir, están diseñados para que algunos o todos los datos se puedan pasar eficazmente a los componentes de la canalización, lo que puede procesarlos y, opcionalmente, modificar el búfer. Dado Memory<T> que varios componentes o varios subprocesos pueden tener acceso a sus tipos relacionados, es importante seguir algunas directrices de uso estándar para generar código sólido.

Propietarios, consumidores y administración del ciclo de vida

Los búferes pueden pasar de una API a otra y, en ocasiones, se puede acceder a ellos desde varios subprocesos, por lo que debe tener en cuenta cómo se administra la duración de un búfer. Hay tres conceptos básicos:

  • Propiedad. El propietario de una instancia de búfer es responsable de la gestión del ciclo de vida, incluyendo la destrucción del búfer cuando ya no se está utilizando. Todos los búferes tienen un único propietario. Por lo general, el propietario es el componente que creó el búfer o que recibió el búfer de una fábrica. También se puede transferir la propiedad; Component-A puede renunciar al control del búfer a Component-B, en cuyo punto Component-A ya no puede usar el búfer, y Component-B se hace responsable de destruir el búfer cuando ya no está en uso.

  • Consumo. El consumidor de una instancia de búfer puede usar la instancia de búfer leyéndolo y, posiblemente, escribir en él. Los buffers pueden tener un consumidor a la vez hasta que se proporcione algún mecanismo de sincronización externo. El consumidor activo de un búfer no es necesariamente el propietario del búfer.

  • Concesión. La concesión es el período durante el cual un componente concreto puede ser el consumidor del búfer.

En el siguiente ejemplo de pseudocódigo se muestran estos tres conceptos. Buffer en el pseudocódigo representa un búfer Memory<T> o Span<T> del tipo Char. El Main método crea una instancia del búfer, llama al WriteInt32ToBuffer método para escribir la representación de cadena de un entero en el búfer y, a continuación, llama al DisplayBufferToConsole método para mostrar el valor del búfer.

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();
        }
    }
}

El Main método crea el búfer y, por tanto, es su propietario. Por lo tanto, Main es responsable de destruir el búfer cuando ya no está en uso. El pseudocódigo ilustra esto llamando a un método Destroy en el búfer. (Ni Memory<T> ni Span<T> tiene realmente un método Destroy. Verás ejemplos de código reales más adelante en este artículo.)

El búfer tiene dos consumidores, WriteInt32ToBuffer y DisplayBufferToConsole. Solo hay un consumidor a la vez (primero WriteInt32ToBuffer, luego DisplayBufferToConsole), y ninguno de los consumidores posee el buffer. Tenga en cuenta también que "consumidor" en este contexto no implica una vista de solo lectura del búfer; los consumidores pueden modificar el contenido del búfer, como hace WriteInt32ToBuffer, si se proporciona una vista de lectura y escritura del búfer.

El método WriteInt32ToBuffer tiene permiso para consumir el búfer desde el inicio de la llamada del método hasta el momento en que el método devuelve. De forma similar, DisplayBufferToConsole tiene una concesión sobre el búfer mientras se ejecuta, y se libera cuando se desenreda el método. (No hay ninguna API para la administración de concesiones; una "concesión" es una cuestión conceptual).

Memory<T> y el modelo de consumidor y propietario

Como se indica en la sección Propietarios, consumidores y administración de la duración, un búfer siempre tiene un propietario. .NET admite dos modelos de propiedad:

  • Modelo que admite la propiedad única. Un búfer tiene un único propietario durante toda su vida.
  • Modelo que admite la transferencia de propiedad. La propiedad de un búfer se puede transferir de su propietario original (su creador) a otro componente, que luego se convierte en responsable de la gestión del ciclo de vida del búfer. Ese propietario puede transferir a su vez la propiedad a otro componente, etc.

Usa la interfaz System.Buffers.IMemoryOwner<T> para administrar explícitamente la propiedad de un búfer. IMemoryOwner<T> admite ambos modelos de propiedad. El componente que hace referencia a IMemoryOwner<T> es el propietario del búfer. En el ejemplo siguiente se usa una instancia de IMemoryOwner<T> para reflejar la propiedad de un búfer de 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}'");
}

También puede escribir este ejemplo con la using instrucción :

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}'");
}

En este código:

  • El Main método contiene la referencia a la IMemoryOwner<T> instancia, por lo que el Main método es el propietario del búfer.
  • Los WriteInt32ToBuffer métodos y DisplayBufferToConsole aceptan Memory<T> como UNA API pública. Por lo tanto, son consumidores del búfer. Estos métodos consumen el búfer de uno en uno.

Aunque el WriteInt32ToBuffer método está pensado para escribir un valor en el búfer, el DisplayBufferToConsole método no está pensado para. Para reflejar esto, podría haber aceptado un argumento de tipo ReadOnlyMemory<T>. Para obtener más información sobre ReadOnlyMemory<T>, vea Regla 2: Usar ReadOnlySpan<T> o ReadOnlyMemory<T> si el búfer debe ser de solo lectura.

Instancias de Memory<T> "sin propietario"

Puede crear una Memory<T> instancia sin usar IMemoryOwner<T>. En este caso, la propiedad del búfer es implícita en lugar de explícita y solo se admite el modelo de propietario único. Para ello, puede hacer lo siguiente:

  • Llame a uno de los constructores Memory<T> directamente, pasando un búfer T[], como en el siguiente ejemplo.
  • Llamar al método de extensión String.AsMemory para generar una ReadOnlyMemory<char> instancia.
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}'");
}

El método que inicialmente crea la Memory<T> instancia es el propietario implícito del búfer. La propiedad no se puede transferir a ningún otro componente porque no hay ninguna IMemoryOwner<T> instancia para facilitar la transferencia. Como alternativa, puede también imaginarse que el recolector de elementos no utilizados del entorno de ejecución contiene el búfer, y todos los métodos simplemente consumen el búfer.

Directrices de uso

Dado que un bloque de memoria es propiedad de alguien, pero está pensado para pasarse a varios componentes, algunos de los cuales podrían operar sobre un bloque de memoria determinado simultáneamente, es importante establecer directrices para cómo usar tanto Memory<T> como Span<T>. Las directrices son necesarias porque es posible que un componente:

  • Conserve una referencia a un bloque de memoria después de que su propietario lo haya liberado.
  • Operar en un búfer al mismo tiempo que otro componente opera en él, lo que provoca que los datos del búfer se corrompan.

Aunque la naturaleza de asignación en la pila de Span<T> optimiza el rendimiento y hace que Span<T> sea el tipo preferido para operar en un bloque de memoria, Span<T> también está sujeto a algunas restricciones importantes. Es importante saber cuándo usar Span<T> y Memory<T>.

A continuación se muestran nuestras recomendaciones para usar Memory<T> correctamente y sus tipos relacionados. La guía que se aplica a Memory<T> y Span<T> también se aplica a ReadOnlyMemory<T> y ReadOnlySpan<T>, a menos que se indique lo contrario.

Regla 1: Para una API sincrónica, use Span<T> en lugar de Memory<T> como parámetro si es posible.

Span<T> es más versátil que Memory<T> y puede representar una variedad más amplia de búferes de memoria contiguos. Span<T> también ofrece un mejor rendimiento que Memory<T>. Por último, puede usar la Memory<T>.Span propiedad para convertir una Memory<T> instancia a Span<T>, aunque la conversión de Span<T> a Memory<T> no es posible. Por tanto, si los autores de llamada tienen una instancia de Memory<T>, pueden llamar a los métodos con parámetros Span<T>.

El uso de un parámetro de tipo Span<T> en lugar de tipo Memory<T> también le ayuda a escribir una implementación de método de consumo correcta. Obtendrá automáticamente comprobaciones en tiempo de compilación para asegurarse de que no está intentando acceder al buffer más allá de la duración asignada por el método (se explicará más adelante).

A veces, tendrás que usar un Memory<T> parámetro en lugar de un Span<T> parámetro, incluso si eres completamente sincrónico. Quizás una API de la que dependes acepte solo Memory<T> argumentos. Esto está bien, pero tenga en cuenta los inconvenientes implicados al usar Memory<T> sincrónicamente.

Regla 2: Use ReadOnlySpan<T> o ReadOnlyMemory<T> si el búfer debe ser de solo lectura

En los ejemplos anteriores, el DisplayBufferToConsole método solo lee del búfer; no modifica el contenido del búfer. La firma del método se debe cambiar a la siguiente.

void DisplayBufferToConsole(ReadOnlyMemory<char> buffer);

De hecho, si combina esta regla y regla n.º 1, podemos hacer aún mejor y volver a escribir la firma del método de la siguiente manera:

void DisplayBufferToConsole(ReadOnlySpan<char> buffer);

El DisplayBufferToConsole método ahora funciona con prácticamente todos los tipos de búfer imaginables: T[], almacenamiento asignado con stackalloc, etc. Incluso puede pasar una clase String directamente. Para obtener más información, vea Problema de GitHub dotnet/docs #25551.

Regla 3: Si su método acepta Memory<T> y devuelve void, no debe usar la instancia de Memory<T> después de que su método devuelva.

Esto se relaciona con el concepto de "concesión" mencionado anteriormente. La concesión de un método que no devuelve valores sobre la instancia de Memory<T> comienza cuando se especifica el método y termina cuando finaliza. Observe el ejemplo siguiente, que llama a Log en un bucle basado en lo especificado desde la consola.

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

Si Log es un método totalmente sincrónico, este código se comportará según lo esperado porque solo hay un consumidor activo de la instancia de memoria en un momento dado. Pero imagine que Log tiene esta implementación.

// !!! 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);
    });
}

En esta implementación, Log infringe su concesión porque sigue intentando usar la Memory<T> instancia en segundo plano después de que se haya devuelto el método original. El Main método podría mutar el búfer mientras Log intenta leer de él, lo que podría provocar daños en los datos.

Hay varias formas de solucionar esto:

  • El método Log puede devolver un Task en lugar de void, como hace la siguiente implementación del método 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 en su lugar, se puede implementar de la siguiente manera:

    // 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();
        });
    }
    

Regla 4: si el método acepta Memory<T> y devuelve una clase Task, no debe usar la instancia de Memory<T> después de las transiciones de Task a un estado terminal.

Esta es solo la variante asincrónica de la regla n.º 3. El Log método del ejemplo anterior se puede escribir de la siguiente manera para cumplir esta regla:

// 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();
    });
}

Aquí, "estado de terminal" significa que la tarea pasa a un estado completado, defectuoso o cancelado. Es decir, "estado terminal" significa "todo lo que provocaría esperar a que se inicie o continuar la ejecución".

Esta guía se aplica a los métodos que devuelven Task, Task<TResult>, ValueTask<TResult>o cualquier tipo similar.

Regla 5: Si el constructor acepta Memory<T> como parámetro, se supone que los métodos de instancia del objeto construido son consumidores de la instancia de Memory<T> .

Considere el ejemplo siguiente:

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);
    }
}

Aquí, el OddValueExtractor constructor acepta un ReadOnlyMemory<int> como parámetro de constructor, por lo tanto, el constructor en sí consume la instancia ReadOnlyMemory<int>, y todos los métodos de instancia del valor devuelto también consumen la instancia original ReadOnlyMemory<int>. Esto significa que TryReadNextOddValue consume la ReadOnlyMemory<int> instancia, aunque la instancia no se pase directamente al TryReadNextOddValue método .

Regla #6: Si tiene una propiedad de tipo Memory<T> que se puede establecer (o un método de instancia equivalente) en su tipo, se supone que los métodos de instancia de ese objeto son consumidores de la instancia Memory<T>.

Esto es realmente solo una variante de la regla n.º 5. Esta regla existe porque se supone que los establecedores de propiedades o los métodos equivalentes capturan y conservan sus entradas, por lo que los métodos de instancia del mismo objeto pueden utilizar el estado capturado.

En el ejemplo siguiente se desencadena esta regla:

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

Regla #7: Si tiene una referencia de IMemoryOwner<T>, debe en algún momento deshacerse de ella o transferir su propiedad (pero no ambas)

Puesto que una instancia de Memory<T> puede estar respaldada por la memoria administrada o no administrada, el propietario debe llamar a Dispose en IMemoryOwner<T> cuando se completa el trabajo realizado en la instancia de Memory<T>. Como alternativa, el propietario puede transferir la propiedad de la instancia de IMemoryOwner<T> a un componente diferente, momento en que el componente de adquisición pasa a ser responsable de llamar a Dispose en el momento adecuado (se ofrece más información sobre esto más adelante).

Si no se llama al Dispose método en una IMemoryOwner<T> instancia, es posible que se produzcan pérdidas de memoria no administradas u otra degradación del rendimiento.

Esta regla también se aplica al código que llama a métodos de fábrica como MemoryPool<T>.Rent. El autor de llamada pasa a ser el propietario de la instancia de IMemoryOwner<T> devuelta y es responsable de la eliminación de dicha instancia cuando se termina.

Regla 8: Si tiene un parámetro T< IMemoryOwner>en la superficie de API, acepta la propiedad de esa instancia.

Aceptar una instancia de este tipo indica que el componente pretende tomar posesión de esta instancia. Tu componente se hace responsable de la eliminación adecuada según la Regla número 7.

Cualquier componente que transfiera la propiedad de la IMemoryOwner<T> instancia a otro componente ya no debe usar esa instancia una vez completada la llamada al método.

Importante

Si el constructor acepta IMemoryOwner<T> como parámetro, su tipo debe implementar IDisposable y el método Dispose debe llamar a Dispose en el objeto IMemoryOwner<T>.

Regla 9: Si va a encapsular un método p/invoke sincrónico, la API debe aceptar Span<T> como parámetro

Según la regla 1, Span<T> generalmente es el tipo correcto que se va a usar para las API sincrónicas. Puede anclar instancias de Span<T> a través de la palabra clave fixed, como en el ejemplo siguiente.

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

En el ejemplo anterior, pbData puede ser NULL si, por ejemplo, el intervalo de entrada está vacío. Si el método exportado requiere absolutamente que pbData no sea NULL, aunque cbData sea 0, el método se puede implementar de la siguiente manera:

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

Regla 10: si va a encapsular un método p/invoke asincrónico, la API debe aceptar Memory<T> como parámetro.

Puesto que no puede usar la fixed palabra clave en operaciones asincrónicas, use el Memory<T>.Pin método para anclar Memory<T> instancias, independientemente del tipo de memoria contigua que representa la instancia. En el ejemplo siguiente se muestra cómo usar esta API para realizar una llamada asincrónica 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;
}

Consulte también