Span y Memory en C#: alto rendimiento sin presionar al GC

Cada vez que haces string.Substring() o array.Skip(10).Take(20).ToArray(), .NET crea un objeto nuevo en el heap. El GC lo recogerá tarde o temprano, pero mientras tanto ocupa memoria y puede causar pausas. Span<T> y Memory<T> existen para evitar esas copias cuando el rendimiento importa de verdad.

Si todavía no conoces bien el lenguaje, echa un vistazo primero a C# de alto rendimiento: la evolución del lenguaje. Aquí damos por sentado que sabes escribir clases, genéricos y algo de async/await.

El problema: copias innecesarias

Imagina que recibes un buffer de red con 4096 bytes y solo necesitas procesar los primeros 200. El enfoque tradicional crearía un array nuevo con esos 200 bytes. Con Span<T> puedes hacer un slice que apunte directamente a esa región sin mover un solo byte.

Lo mismo ocurre con strings. str.Substring(5, 10) aloca una cadena nueva. str.AsSpan(5, 10) devuelve un ReadOnlySpan<char> que apunta al mismo bloque de memoria, sin copiar nada.

string url = "https://programacion.net/articulo/ejemplo";
ReadOnlySpan<char> path = url.AsSpan(23); // "/articulo/ejemplo"
// path apunta al string original, sin nueva string en el heap

Span<T>: slice de memoria contigua

Span<T> es una estructura que guarda un puntero a memoria y una longitud. Puede apuntar a tres tipos de memoria: un array en el heap, memoria en el stack reservada con stackalloc, o memoria no administrada. Lo importante es que siempre es contigua.

int[] numeros = { 1, 2, 3, 4, 5, 6, 7, 8 };

// Slice del índice 2, longitud 4: [3, 4, 5, 6]
Span<int> mitad = numeros.AsSpan(2, 4);

// Se puede modificar: afecta al array original
mitad[0] = 99; // numeros[2] ahora es 99

Si no necesitas modificar los datos, usa ReadOnlySpan<T>. Es la misma idea pero el compilador no te deja escribir en ella, lo que abre la puerta a pasar strings directamente:

void ProcesarTexto(ReadOnlySpan<char> texto)
{
    // Trabaja con el texto sin copias
    if (texto.StartsWith("ERROR"))
        Console.WriteLine("Encontrado error");
}

// Llamada sin alocar nada nuevo:
ProcesarTexto("ERROR: algo salió mal".AsSpan());

La restricción clave: ref struct

Span<T> es un ref struct. Eso significa que el compilador garantiza que solo vive en el stack. Las consecuencias prácticas son tres: no puedes guardarlo como campo de una clase, no puedes usarlo en métodos async y no puedes capturarlo en una lambda. Si necesitas cualquiera de esas cosas, tienes que usar Memory<T>.

Memory<T>: cuando el Span no llega

Memory<T> representa la misma idea, un slice de memoria contigua, pero sin la restricción de ref struct. Puede vivir en el heap, guardarse en campos de clase y cruzar puntos de await.

public class LectorDeRed
{
    private Memory<byte> _buffer; // campo de clase, ok

    public async Task LeerAsync(Stream stream)
    {
        byte[] array = new byte[4096];
        _buffer = new Memory<byte>(array);

        int leidos = await stream.ReadAsync(_buffer);
        // _buffer.Span solo está disponible en contexto síncrono
        ProcesarDatos(_buffer.Slice(0, leidos));
    }

    private void ProcesarDatos(Memory<byte> datos)
    {
        Span<byte> span = datos.Span; // aquí ya es síncrono
        // ...
    }
}

La regla práctica: empieza intentando con Span<T>. Si el compilador se queja, cambia a Memory<T>.

ArrayPool<T>: reutilizar arrays sin GC

Alocar arrays grandes con frecuencia presiona al GC. ArrayPool<T> mantiene un conjunto de arrays ya creados que puedes pedir prestados y devolver cuando termines.

using System.Buffers;

var pool = ArrayPool<byte>.Shared;
byte[] buffer = pool.Rent(4096); // puede darte un array más grande

try
{
    int leidos = stream.Read(buffer, 0, buffer.Length);

    // Trabajar solo con los bytes reales, no con todo el buffer
    Memory<byte> datos = new Memory<byte>(buffer, 0, leidos);
    ProcesarDatos(datos);
}
finally
{
    pool.Return(buffer); // devuelvo el array al pool
}

Dos cosas a tener en cuenta: Rent() puede devolverte un array más grande de lo que pediste, así que usa siempre la longitud real de los datos. Y siempre usa try/finally para garantizar que el array vuelve al pool aunque haya una excepción.

MemoryPool<T> e IMemoryOwner<T>

Para gestionar el tiempo de vida de forma más controlada, existe MemoryPool<T>. Devuelve un IMemoryOwner<T> que implementa IDisposable, así que puedes usar using directamente:

using System.Buffers;

using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(4096);
Memory<byte> memoria = owner.Memory;

int leidos = await stream.ReadAsync(memoria);
ProcesarDatos(memoria.Slice(0, leidos));

// Al salir del using, la memoria vuelve al pool automáticamente

Cuando el using termina, la memoria se devuelve al pool aunque haya una excepción en medio. Mucho más limpio que el try/finally manual.

stackalloc: memoria en el stack

Para buffers pequeños de vida muy corta, stackalloc es la opción más rápida. No hay heap, no hay GC, la memoria desaparece cuando sale del scope:

void ParsearCabecera(ReadOnlySpan<byte> datos)
{
    // Buffer temporal de 256 bytes en el stack
    Span<byte> temporal = stackalloc byte[256];

    int longitud = DecodificarCabecera(datos, temporal);
    ProcesarCabecera(temporal.Slice(0, longitud));
    // temporal desaparece aquí, sin GC
}

El límite típico del stack en .NET es alrededor de 1 MB, así que reserva con stackalloc solo buffers pequeños (unos pocos kilobytes como mucho). Para buffers más grandes, usa ArrayPool<T>.

System.IO.Pipelines: I/O sin copias

Si trabajas con I/O de red o parseas protocolos, System.IO.Pipelines es la API más eficiente de .NET. Está construida encima de Span<T> y Memory<T> y evita las copias en cada etapa del proceso. Kestrel, el servidor HTTP de ASP.NET Core, la usa internamente.

using System.IO.Pipelines;

var pipe = new Pipe();
PipeWriter writer = pipe.Writer;
PipeReader reader = pipe.Reader;

// Productor: escribe en el pipe sin buffer propio
async Task EscribirAsync(Stream origen)
{
    while (true)
    {
        Memory<byte> buffer = writer.GetMemory(512);
        int leidos = await origen.ReadAsync(buffer);
        if (leidos == 0) break;
        writer.Advance(leidos);
        await writer.FlushAsync();
    }
    await writer.CompleteAsync();
}

// Consumidor: lee sin copiar
async Task LeerAsync()
{
    while (true)
    {
        ReadResult resultado = await reader.ReadAsync();
        ReadOnlySequence<byte> buffer = resultado.Buffer;

        foreach (ReadOnlyMemory<byte> segmento in buffer)
        {
            ProcesarSegmento(segmento.Span);
        }

        reader.AdvanceTo(buffer.End);
        if (resultado.IsCompleted) break;
    }
}

La ventaja frente a Stream clásico es que el productor y el consumidor comparten el mismo buffer sin copiarlo de uno a otro. Para parsear protocolos de red con alto volumen de conexiones, la diferencia en asignaciones de memoria es significativa.

Cuándo usarlo y cuándo no

Estas APIs son herramientas de optimización, no el estilo por defecto de C#. Tiene sentido usarlas cuando estás procesando strings sin querer crear substrings, implementando parsers o codecs, leyendo y escribiendo en sockets con mucho volumen, o cuando BenchmarkDotNet te muestra que el GC presiona de verdad.

En código de negocio normal, donde los datos se transforman una vez y la legibilidad importa más que los microsegundos, Span<T> complica el código sin beneficio real. Un string.Substring() en un método que se llama diez veces al día no es un problema.

La regla práctica: mide primero con BenchmarkDotNet. Si el GC no aparece como cuello de botella en el profiler, no cambies nada. Si aparece, ahí tienes las herramientas para eliminarlo.

Imagen: Pexels / Daniil Komov

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP