Instrucciones de uso de Memory<T> y Span<T>

.NET incluye una serie de tipos que representan una región contigua y arbitraria de memoria. Span<T> y ReadOnlySpan<T> son búferes de memoria ligera que ajustan las referencias a la memoria administrada o no administrada. Dado que estos tipos solo se pueden almacenar en la pila, no son aptos para escenarios como las llamadas de método asincrónico. 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 parte de los datos, o todos, se puedan pasar de forma eficaz a los componentes de la canalización, que puede procesarlos y, opcionalmente, modificar el búfer. Como varios componentes o subprocesos pueden acceder a Memory<T> y sus tipos relacionados, es importante seguir algunas directrices de uso estándar para crear código sólido.

Propietarios, consumidores y administración de la duración

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 principales:

  • Propiedad. El propietario de una instancia de búfer es responsable de la administración de la duración, por ejemplo, destruir el búfer cuando ya no se use. 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 ceder el control del búfer a Component-B, momento en que Component-A ya no puede usar más el búfer, y Component-B pasa a ser responsable de destruir el búfer cuando ya no se usa.

  • Consumo. El consumidor de una instancia de búfer puede usar la instancia de búfer leyéndolo y, posiblemente, escribir en él. Los búferes pueden tener un consumidor a la vez, a menos 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 ejemplo de pseudocódigo siguiente se muestran estos tres conceptos. Buffer en el pseudocódigo representa un búfer Memory<T> o Span<T> del tipo Char. El método Main crea una instancia del búfer, llama al método WriteInt32ToBuffer para escribir la representación de cadena de un entero en el búfer y, después, llama al método DisplayBufferToConsole 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 método Main crea el búfer y, por tanto, es su propietario. Por lo tanto, Main es responsable de destruir el búfer cuando ya no se usa. El pseudocódigo ilustra esto llamando a un método Destroy en el búfer. (Ni Memory<T> ni Span<T> tienen realmente un método Destroy. Podrá ver ejemplos de código reales más adelante en este artículo).

El búfer tiene dos consumidores, WriteInt32ToBuffer y DisplayBufferToConsole. Hay solo un consumidor a la vez (primero WriteInt32ToBuffer y, luego, DisplayBufferToConsole), y ninguno de los consumidores posee el búfer. 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 WriteInt32ToBuffer, si se proporciona una vista de lectura/escritura del búfer.

El método WriteInt32ToBuffer tiene una concesión (pueden consumir) sobre el búfer entre el inicio de la llamada al método y el momento en que se devuelve el método. 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 administrar concesiones; la "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:

  • Un modelo que admite la propiedad única. Un búfer tiene un propietario único para toda su duración.

  • Un modelo que admite la transferencia de la propiedad. La propiedad de un búfer se puede transferir desde el propietario original (su creador) a otro componente, que, luego, pasa a ser responsable de la administración de la duración del búfer. Ese propietario, a su vez, puede transferir la propiedad a otro componente, y así sucesivamente.

Usa la interfaz System.Buffers.IMemoryOwner<T> para administrar explícitamente la propiedad de un búfer. IMemoryOwner<T> es compatible con ambos modelos de propiedad. El componente que tiene una referencia de IMemoryOwner<T> posee el 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 podemos escribir este ejemplo con la instrucción 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}'");
}

En este código:

  • El método Main contiene la referencia a la instancia de IMemoryOwner<T>, por lo que el método Main es el propietario del búfer.

  • Los métodos WriteInt32ToBuffer y DisplayBufferToConsole aceptan Memory<T> como API pública. Por lo tanto, son consumidores del búfer. Estos métodos consumen el búfer de uno en uno.

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

Instancias de Memory<T> "sin propietario"

Puede crear una instancia de Memory<T> sin usar IMemoryOwner<T>. En este caso, la propiedad del búfer está implícita en lugar de explícita, y solo se admite el modelo de propietario único. Puede hacerlo de la siguiente forma:

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

El método que crea inicialmente la instancia de Memory<T> es el propietario implícito del búfer. No se puede transferir la propiedad a cualquier otro componente porque no hay ninguna instancia de IMemoryOwner<T> que facilite 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.

Instrucciones de uso

Dado que un bloque de memoria tiene un propietario, pero está diseñado para pasarse a varios componentes, algunos de los cuales pueden funcionar en un bloque de memoria específica al mismo tiempo, es importante establecer instrucciones para usar Memory<T> y 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.

  • Funcione en un búfer al mismo tiempo que otro componente está operando en él, con lo que se dañan los datos del búfer.

  • Si bien la naturaleza asignada a la pila de Span<T> optimiza el rendimiento y convierte a Span<T> en el tipo preferido para funcionar en un bloque de memoria, también somete a Span<T> a algunas restricciones principales. Es importante saber cuándo usar Span<T> y Memory<T>.

A continuación, se muestran recomendaciones para usar correctamente Memory<T> y sus tipos relacionados. Las instrucciones que se aplican a Memory<T> y a Span<T> son válidas también para 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 un parámetro si es posible.

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

Usar un parámetro de tipo Span<T> en lugar de tipo Memory<T> también 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á tratando de acceder al búfer más allá de la concesión del método (encontrará más información sobre esto más adelante).

En ocasiones, tendrá que usar un parámetro Memory<T> en lugar de Span<T>, aunque sea un método completamente sincrónico. Quizás, una API de la que depende solo acepta argumentos Memory<T>. Aunque es correcto, debe ser consciente de los inconvenientes que implica 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 método DisplayBufferToConsole solo lee del búfer, no modifica su contenido. La firma del método se debe cambiar a la siguiente.

void DisplayBufferToConsole(ReadOnlyMemory<char> buffer);

De hecho, si se combinan esta regla y la 1, podemos hacerlo incluso mejor y reescribir la firma del método como sigue:

void DisplayBufferToConsole(ReadOnlySpan<char> buffer);

El método DisplayBufferToConsole ahora funciona con prácticamente cualquier tipo de búfer imaginable: T[], almacenamiento asignable con stackalloc y así sucesivamente. Incluso puede pasar una clase String directamente. Para más información, consulte la incidencia de GitHub dotnet/docs n.º 25551.

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

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.

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

Si Log es un método completamente sincrónico, este código se comportará según lo esperado porque solo hay un consumidor activo de la instancia de la memoria en un momento dado. Pero, en lugar de esto, 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 tratando de utilizar la instancia de Memory<T> en segundo plano después de que se haya devuelto el método original. El método Main podría modificar el búfer mientras Log intenta leer desde él, lo que podría provocar daños en los datos.

Hay varias formas de solucionar esto:

  • El método Log puede devolver una clase 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 se puede implementar de la siguiente forma:

    // 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.

Se trata de la variante asincrónica de la regla 3. El método Log del ejemplo anterior puede escribirse como sigue para cumplir con 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();
    });
}

En este caso, "estado terminal" significa que Task cambia a un estado completado, con errores 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 un parámetro, se da por sentado que los métodos de instancia del objeto construido son los 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);
    }
}

En este caso, el constructor OddValueExtractor acepta ReadOnlyMemory<int> como un parámetro de constructor, por lo que el propio constructor es un consumidor de la instancia de ReadOnlyMemory<int> y todos los métodos de instancia del valor devuelto también son los consumidores de la instancia de ReadOnlyMemory<int> original. Esto significa que TryReadNextOddValue consume la instancia de ReadOnlyMemory<int>, aunque no se pasa directamente al método TryReadNextOddValue.

Regla 6: Si tiene una propiedad con el tipo Memory<T> configurable (o un método de instancia equivalente) en su tipo, se da por sentado que los métodos de instancia de ese objeto son los consumidores de la instancia de Memory<T>.

Se trata simplemente una variante de la regla 5. Esta regla existe porque se supone que los establecedores de propiedades o 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 infringe 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>, en algún punto, debe eliminarla o transferir su propiedad (pero no ambas cosas).

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 método Dispose correctamente en una instancia de IMemoryOwner<T>, se pueden producir fugas de memoria no administrada 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 IMemoryOwner<T> en la superficie de API, implica la aceptación de la propiedad de esa instancia.

Aceptar una instancia de este tipo indica que el componente intenta tomar posesión de esta instancia. El componente pasa a ser responsable de la correcta eliminación según la regla 7.

Cualquier componente que transfiere la propiedad de la instancia de IMemoryOwner<T> a un componente diferente ya no debe utilizar esa instancia una vez finalizada la llamada al método.

Importante

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

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

Según la regla 1, Span<T> es normalmente el tipo correcto que se 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 requiera absolutamente que pbData tenga un valor distinto de NULL, aunque cbData sea 0, el método puede implementarse como sigue:

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 está encapsulando un método p/invoke asincrónico, la API debe aceptar Memory<T> como parámetro.

Puesto que no se puede utilizar la palabra clave fixed en las operaciones asincrónicas, use el método Memory<T>.Pin para anclar instancias de Memory<T>, independientemente del tipo de memoria contigua que represente la instancia. En el ejemplo siguiente se muestra cómo utilizar esta API para realizar una llamada p/invoke asincrónica.

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

Vea también