Compartir a través de



Enero de 2018

Volumen 33, número 1

C#: todo sobre Span. Exploración de un nuevo pilar de .NET

Por Stephen Toub | Enero de 2018

Imagine que expone una rutina de ordenación especializada para operar in situ sobre los datos en la memoria. Es probable que exponga un método que tome una matriz y proporcione una implementación que opere en ese tipo T[]. Eso es genial si el autor de llamada del método tiene una matriz y quiere ordenarla completamente, pero ¿qué sucede si el autor de llamada solo quiere ordenarla parcialmente? En ese caso, es probable que también exponga una sobrecarga que tomó un desfase y un recuento. No obstante, ¿qué sucede si quiere admitir datos en memoria que no están en una matriz, sino que proceden de código nativo, por ejemplo, o residen en la pila y solo tiene un puntero y una longitud? Cómo podría escribir el método de ordenación que opera en esa región arbitraria de la memoria y que, aun así, funciona igual de bien con matrices completas o subconjuntos de matrices, así como con matrices administradas y punteros no administrados?

O bien, veamos otro ejemplo. Está implementando una operación sobre System.String, como un método de análisis especializado. Es probable que exponga un método que tome una cadena y proporcione una implementación que opere en cadenas. No obstante, ¿qué sucede si quiere admitir el funcionamiento sobre un subconjunto de esa cadena? String.Substring se puede usar para esculpir solo la pieza que le interesa, pero es una operación relativamente cara, que implica la asignación de una cadena y la copia de memoria. Como hemos mencionado en el ejemplo de la matriz, puede tomar un desfase y un recuento, pero ¿qué sucede si el autor de llamada no tiene una cadena, sino un tipo char[]? O bien, ¿qué sucede si el autor de llamada tiene un tipo char*, como el creado con stackalloc para usar algo de espacio en la pila, o como resultado de una llamada a código nativo? ¿Cómo puede escribir su método de análisis de manera que no obligue al autor de llamada a realizar asignaciones ni copias y que funcione igualmente con entradas de tipo cadena, char[] y char*?

En ambas situaciones, es posible que pueda usar código y punteros no seguros, y exponer así una implementación que ha aceptado un puntero y una longitud. Sin embargo, ello elimina las garantías de seguridad esenciales para .NET y le expone a problemas, como saturaciones del búfer e infracciones de acceso, que para la mayoría de los desarrolladores de .NET son cosa del pasado. También provoca disminuciones de rendimiento adicionales, como la necesidad de anclar objetos administrados durante la operación para que el puntero recuperado siga siendo válido. En función del tipo de datos implicados, obtener un puntero podría no resultar práctico en absoluto.

Existe una respuesta a este enigma, cuyo nombre es Span<T>.

¿Qué es Span<T>?

System.Span<T> es un nuevo tipo de valor clave de .NET. Permite la representación de regiones contiguas de memoria arbitraria, independientemente de si dicha memoria está asociada a un objeto administrado, la proporciona código nativo a través de la interoperabilidad o se encuentra en la pila. Todo ello sin dejar de proporcionar un acceso seguro con características de rendimiento, como la de las matrices.

Por ejemplo, puede crear un tipo Span<T> a partir de una matriz:

var arr = new byte[10];
Span<byte> bytes = arr; // Implicit cast from T[] to Span<T>

A partir de ahí, puede crear un intervalo de manera sencilla y eficaz para representar o señalar solo un subconjunto de esta matriz, con una sobrecarga del método Slice del intervalo. A partir de este punto, puede indexar el intervalo resultante para la escritura y la lectura de datos en la parte pertinente de la matriz original:

Span<byte> slicedBytes = bytes.Slice(start: 5, length: 2);
slicedBytes[0] = 42;
slicedBytes[1] = 43;
Assert.Equal(42, slicedBytes[0]);
Assert.Equal(43, slicedBytes[1]);
Assert.Equal(arr[5], slicedBytes[0]);
Assert.Equal(arr[6], slicedBytes[1]);
slicedBytes[2] = 44; // Throws IndexOutOfRangeException
bytes[2] = 45; // OK
Assert.Equal(arr[2], bytes[2]);
Assert.Equal(45, arr[2]);

Como hemos mencionado, los intervalos son más que un simple método de acceso y matrices de subconjuntos. También se pueden usar para hacer referencia a datos en la pila. Por ejemplo:

Span<byte> bytes = stackalloc byte[2]; // Using C# 7.2 stackalloc support for spans
bytes[0] = 42;
bytes[1] = 43;
Assert.Equal(42, bytes[0]);
Assert.Equal(43, bytes[1]);
bytes[2] = 44; // throws IndexOutOfRangeException

De manera más general, se pueden usar para hacer referencia a longitudes y punteros arbitrarios, como la memoria asignada desde un montón nativo, de la siguiente manera:

IntPtr ptr = Marshal.AllocHGlobal(1);
try
{
  Span<byte> bytes;
  unsafe { bytes = new Span<byte>((byte*)ptr, 1); }
  bytes[0] = 42;
  Assert.Equal(42, bytes[0]);
  Assert.Equal(Marshal.ReadByte(ptr), bytes[0]);
  bytes[1] = 43; // Throws IndexOutOfRangeException
}
finally { Marshal.FreeHGlobal(ptr); }

El indexador Span<T> aprovecha una característica del lenguaje C# presentada en la versión C# 7.0, que se denomina "valores devueltos de tipo ref". El indexador se declara con un tipo de valor devuelto "ref T", que proporciona semántica, como la de indexación en matrices, que devuelve una referencia a la ubicación de almacenamiento real, en lugar de devolver una copia de lo que reside en dicha ubicación:

public ref T this[int index] { get { ... } }

El impacto de este indexador de valores devueltos de referencia es más obvio con un ejemplo, por ejemplo, si se compara con el indexador List<T>, que no proporciona valores devueltos de referencia. Aquí se muestra un ejemplo:

struct MutableStruct { public int Value; }
...
Span<MutableStruct> spanOfStructs = new MutableStruct[1];
spanOfStructs[0].Value = 42;
Assert.Equal(42, spanOfStructs[0].Value);
var listOfStructs = new List<MutableStruct> { new MutableStruct() };
listOfStructs[0].Value = 42; // Error CS1612: the return value is not a variable

Una segunda variante de Span<T>, denominada System.ReadOnlySpan<T>, permite el acceso de solo lectura. Este tipo es igual que Span<T>, excepto en que su indexador aprovecha una nueva característica de C# 7.2 para devolver "ref readonly T" en lugar de "ref T", lo que permite que funcione con tipos de datos inmutables, como System.String. ReadOnlySpan<T> hace que sea muy eficaz la segmentación de cadenas sin asignación ni copia, como se muestra a continuación:

string str = "hello, world";
string worldString = str.Substring(startIndex: 7, length: 5); // Allocates ReadOnlySpan<char> worldSpan =
  str.AsReadOnlySpan().Slice(start: 7, length: 5); // No allocation
Assert.Equal('w', worldSpan[0]);
worldSpan[0] = 'a'; // Error CS0200: indexer cannot be assigned to

Los intervalos proporcionan múltiples ventajas aparte de las mencionadas. Por ejemplo, los intervalos admiten la noción de reinterpretar conversiones, lo que significa que puede convertir un tipo Span<byte> en Span<int> (donde el índice 0 de Span<int> se asigna a los primeros cuatro bytes de Span<byte>). De este modo, si lee un búfer de bytes, lo puede pasar a métodos que actúen en bytes agrupados como enteros de manera segura y eficaz.

¿Cómo se implementa Span<T>?

Por lo general, no es necesario que los desarrolladores comprendan cómo se implementa una biblioteca que utilizan. Sin embargo, en el caso de Span<T>, merece la pena tener como mínimo un conocimiento básico de sus detalles, ya que insinúan algo sobre su rendimiento y sus restricciones de uso.

En primer lugar, Span<T> es un tipo de valor que contiene una referencia y una longitud, y se define más o menos de la siguiente manera:

public readonly ref struct Span<T>
{
  private readonly ref T _pointer;
  private readonly int _length;
  ...
}

El concepto de un campo ref T puede resultar extraño al principio. De hecho, no se puede declarar un campo ref T en C# ni tampoco en MSIL. No obstante, Span<T> está escrito realmente para usar un tipo interno especial en el runtime que se trate como un objeto intrínseco Just-In-Time (JIT), donde JIT genere para este el equivalente de un campo ref T. Considere la posibilidad de usar una referencia que le resulte mucho más familiar:

public static void AddOne(ref int value) => value += 1;
...
var values = new int[] { 42, 84, 126 };
AddOne(ref values[2]);
Assert.Equal(127, values[2]);

Este código pasa una ranura en la matriz por referencia, de modo que (optimizaciones aparte) tenga un valor ref T en la pila. El valor ref T de Span<T> es la misma idea, simplemente encapsulada dentro de una estructura. Los tipos que contienen estas referencias directa o indirectamente se denominan tipos ref, y el compilador de C# 7.2 permite la declaración de estos tipos mediante la estructura ref de la signatura.

Con esta breve descripción, deben quedar claras dos cosas:

  1. Span<T> está definido de manera que las operaciones sean tan eficaces como en las matrices: la indexación en un intervalo no requiere computación para determinar el inicio de un puntero y su desplazamiento inicial, ya que el propio campo ref incluye ambos valores. (En cambio, ArraySegment<T> tiene un campo de desplazamiento diferente, lo que aumenta su costo tanto para indexarlo como para pasarlo).
  2. La naturaleza de Span<T> como tipo ref conlleva algunas restricciones a causa del campo ref T.

Este segundo elemento tiene algunas ramificaciones interesantes que hacen que .NET contenga un segundo conjunto de tipos relacionado, dirigido por Memory<T>.

¿Qué es Memory<T> y por qué se necesita?

Span<T> es un tipo ref, ya que contiene un campo ref. Los campos ref pueden hacer referencia no solo al principio de objetos, tales como matrices, sino también al centro de estos:

var arr = new byte[100];
Span<byte> interiorRef1 = arr.AsSpan().Slice(start: 20);
Span<byte> interiorRef2 = new Span<byte>(arr, 20, arr.Length – 20);
Span<byte> interiorRef3 =
  Span<byte>.DangerousCreate(arr, ref arr[20], arr.Length – 20);

Estas referencias se conocen como punteros interiores y su seguimiento es una operación relativamente costosa para el recolector de elementos no utilizados del runtime de .NET. En consecuencia, el runtime restringe estas referencias para que solo residan en la pila, ya que proporciona un límite bajo implícito en el número de punteros interiores que podrían existir.

Además, Span<T> como se muestra anteriormente es mayor que el tamaño de palabra de la máquina, lo que significa que la lectura y escritura de un intervalo no es una operación atómica. Si varios subprocesos leen y escriben los campos de un intervalo en el montón al mismo tiempo, existe riesgo de "despedazamiento". Imagine un intervalo ya inicializado que contenga una referencia válida y un valor _length correspondiente de 50. Un subproceso comienza a escribir un nuevo intervalo sobre este y llega tan lejos como la escritura del nuevo valor _pointer. A continuación, para que pueda establecer el valor _length correspondiente en 20, un segundo subproceso lee el intervalo, incluido el nuevo valor _pointer pero con el valor _length antiguo (y más largo).

Como resultado, las instancias de Span<T> solo pueden residir en la pila y no en el montón. Esto significa que no puede realizar la conversión boxing en intervalos (y, por tanto, no puede usar Span<T> con las API de invocación de reflexión existentes, por ejemplo, ya que requieren la conversión boxing). Por tanto, no puede tener campos Span<T> en clases ni tampoco en estructuras que no sean de tipo ref. Esto significa que no puede usar intervalos en ubicaciones donde puedan convertirse implícitamente en campos en las clases, por ejemplo, mediante su captura en lambdas o como variables locales en métodos asincrónicos o iteradores (ya que esas "variables locales" pueden terminar siendo campos en las máquinas de estados generados por el compilador). También significa que no puede usar Span<T> como un argumento genérico, ya que las instancias de ese argumento de tipo pueden terminar con la conversión boxing aplicada o almacenados de otro modo en el montón (y actualmente no existe ninguna restricción "where T : ref struct" disponible).

Estas limitaciones son inmateriales para muchos escenarios, en especial para las funciones de procesamiento sincrónico y asociadas al cálculo. Sin embargo, la funcionalidad asincrónica es otra historia. Muchos de los problemas citados al principio de este artículo en torno a las matrices, los sectores de matrices, la memoria nativa, etc. existen tanto al realizar operaciones sincrónicas como asincrónicas. Pero si Span<T> no se puede almacenar en el montón y, por tanto, no se puede mantener en las operaciones asincrónicas, ¿cuál es la respuesta? Memory<T>.

Memory<T> looks very much like an ArraySegment<T>:
public readonly struct Memory<T>
{
  private readonly object _object;
  private readonly int _index;
  private readonly int _length;
  ...
}

Puede crear un tipo Memory<T> a partir de una matriz y segmentarlo como lo haría con un intervalo, pero es un tipo struct (no de tipo referencia) y puede residir en la pila. A continuación, cuando quiera realizar el procesamiento sincrónico, puede obtener un tipo Span<T> a partir de este, como, por ejemplo:

static async Task<int> ChecksumReadAsync(Memory<byte> buffer, Stream stream)
{
  int bytesRead = await stream.ReadAsync(buffer);
  return Checksum(buffer.Span.Slice(0, bytesRead));
  // Or buffer.Slice(0, bytesRead).Span
}
static int Checksum(Span<byte> buffer) { ... }

Igual que Span<T> y ReadOnlySpan<T>, Memory<T> tiene un equivalente de solo lectura, que es ReadOnlyMemory<T>. Como puede esperar, su propiedad Span devuelve un tipo ReadOnlySpan<T>. Consulte la Figura 1 para obtener un resumen rápido de los mecanismos integrados para la conversión entre estos tipos.

Figura 1 Conversiones sin asignación/sin copia entre tipos relacionados con Span

Desde A Mecanismo
ArraySegment<T> Memory<T> Conversión implícita, método AsMemory
ArraySegment<T> ReadOnlyMemory<T> Conversión implícita, método AsReadOnlyMemory
ArraySegment<T> ReadOnlySpan<T> Conversión implícita, método AsReadOnlySpan
ArraySegment<T> Span<T> Conversión implícita, método AsSpan
ArraySegment<T> T[] Propiedad Array
Memory<T> ArraySegment<T> Método TryGetArray
Memory<T> ReadOnlyMemory<T> Conversión implícita, método AsReadOnlyMemory
Memory<T> Span<T> Propiedad Span
ReadOnlyMemory<T> ArraySegment<T> Método DangerousTryGetArray
ReadOnlyMemory<T> ReadOnlySpan<T> Propiedad Span
ReadOnlySpan<T> ref readonly T Descriptor de acceso get del indexador, métodos de serialización
Span<T> ReadOnlySpan<T> Conversión implícita, método AsReadOnlySpan
Span<T> ref T Descriptor de acceso get del indexador, métodos de serialización
Cadena ReadOnlyMemory<char> Método AsReadOnlyMemory
Cadena ReadOnlySpan<char> Conversión implícita, método AsReadOnlySpan
T[] ArraySegment<T> Ctor, conversión implícita
T[] Memory<T> Ctor, conversión implícita, método AsMemory
T[] ReadOnlyMemory<T> Ctor, conversión implícita, método AsReadOnlyMemory
T[] ReadOnlySpan<T> Ctor, conversión implícita, método AsReadOnlySpan
T[] Span<T> Ctor, conversión implícita, método AsSpan
void* ReadOnlySpan<T> Ctor
void* Span<T> Ctor

Observará que el campo _object de Memory<T> no está fuertemente tipado como T[], sino que está almacenado como un objeto. Esto resalta que Memory<T> puede encapsular elementos distintos de las matrices, como System.Buffers.OwnedMemory<T>. OwnedMemory<T> es una clase abstracta que se puede usar para encapsular datos que requieren tener el ciclo de vida bien administrado, como la memoria recuperada de un grupo. Ese es un tema más avanzado que queda fuera del ámbito de este artículo, pero es la manera en que Memory<T> se puede usar, por ejemplo, para encapsular punteros en la memoria nativa. ReadOnlyMemory<char> también se puede usar con cadenas, del mismo modo que ReadOnlySpan<char>.

¿Cómo se integran Span<T> y Memory<T> con las bibliotecas de .NET?

En el fragmento de código Memory<T> anterior, observará una llamada a Stream.ReadAsync que se pasa en un valor Memory<byte>. No obstante, Stream.ReadAsync en .NET está definido actualmente para aceptar un tipo byte[]. ¿Cómo funciona?

En apoyo de Span<T> y tipos relacionados, se están agregando cientos de miembros y tipos nuevos en .NET. Muchos de estos son sobrecargas de métodos basados en matrices y en cadenas existentes, mientras que otros son tipos completamente nuevos centrados en áreas de procesamiento específicas. Por ejemplo, todos los tipos primitivos, como Int32, tienen ahora sobrecargas Parse que aceptan un tipo ReadOnlySpan<char>, además de las sobrecargas existentes que toman cadenas. Imagine una situación en que espera una cadena que contiene dos números separados por una coma (como "123,456") y que quiere analizar esos dos números. Hoy, podría escribir código como el siguiente:

string input = ...;
int commaPos = input.IndexOf(',');
int first = int.Parse(input.Substring(0, commaPos));
int second = int.Parse(input.Substring(commaPos + 1));

No obstante, esto provoca dos asignaciones de cadenas. Si escribe código que depende del rendimiento, dos asignaciones de cadenas podrían ser demasiado. En su lugar, ahora puede escribir lo siguiente:

string input = ...;
ReadOnlySpan<char> inputSpan = input.AsReadOnlySpan();
int commaPos = input.IndexOf(',');
int first = int.Parse(inputSpan.Slice(0, commaPos));
int second = int.Parse(inputSpan.Slice(commaPos + 1));

Al usar las nuevas sobrecargas Parse basadas en Span, toda esta operación está libre de asignaciones. Existen métodos de análisis y formato similares para primitivas como Int32 y hasta los tipos principales, como DateTime, TimeSpan y Guid, e incluso tipos de nivel superior, como BigInteger e IPAddress.

De hecho, muchos de estos métodos se han agregado a través del marco. Desde System.Random hasta System.Text.StringBuilder y System.Net.Sockets, se han agregado sobrecargas para que trabajar con {ReadOnly}Span<T> y {ReadOnly}Memory<T> resulte fácil y eficaz. Algunas de estas también proporcionan ventajas adicionales. Por ejemplo, Stream tiene ahora este método:

public virtual ValueTask<int> ReadAsync(
  Memory<byte> destination,
  CancellationToken cancellationToken = default) { ... }

Observará que, a diferencia del método ReadAsync existente que acepta un valor byte[] y devuelve un valor Task<int>, esta sobrecarga no solo acepta Memory<byte> en lugar de byte[], sino que devuelve ValueTask<int> en lugar de Task<int>. ValueTask<T> es una estructura que ayuda a evitar asignaciones en los casos en que se suele esperar que el método asincrónico se devuelva de manera sincrónica, y en que es poco probable que se pueda almacenar en caché una tarea completada para todos los valores devueltos comunes. Por ejemplo, el runtime puede almacenar en caché una tarea Task<bool> completada para un resultado true y otra para un resultado false, pero no puede almacenar en caché cuatro mil millones de objetos de tareas para todos los valores devueltos posibles de Task<int>.

Dado que para las implementaciones de Stream es bastante común el almacenamiento en búfer para que las llamadas de ReadAsync se completen de forma sincrónica, esta nueva sobrecarga ReadAsync devuelve ValueTask<int>. Esto significa que las operaciones de lectura de Stream asincrónicas que se completan de forma sincrónica pueden estar libres de asignaciones. ValueTask<T> también se usa en otras sobrecargas nuevas, como en las de Socket.ReceiveAsync, Socket.SendAsync, WebSocket.ReceiveAsync y TextReader.ReadAsync.

Además, existen ubicaciones en que Span<T> permite que el marco incluya métodos que antiguamente generaban preocupaciones en cuanto a la seguridad de la memoria. Considere una situación en la que quiere crear una cadena que contiene un valor generado de manera aleatoria, por ejemplo, para un id. de algún tipo. Actualmente, podría escribir código que exija la asignación de una matriz de caracteres, como el siguiente:

int length = ...;
Random rand = ...;
var chars = new char[length];
for (int i = 0; i < chars.Length; i++)
{
  chars[i] = (char)(rand.Next(0, 10) + '0');
}
string id = new string(chars);

En su lugar, podría usar la asignación de la pila, e incluso aprovechar Span<char>, para evitar tener que usar código no seguro. Este enfoque también aprovecha el nuevo constructor de cadena que acepta un valor ReadOnlySpan<char>, como se muestra a continuación:

int length = ...;
Random rand = ...;
Span<char> chars = stackalloc char[length];
for (int i = 0; i < chars.Length; i++)
{
  chars[i] = (char)(rand.Next(0, 10) + '0');
}
string id = new string(chars);

Esto es mejor, en tanto que ha evitado la asignación del montón, pero sigue teniendo que copiar en la cadena los datos generados en la pila. Este enfoque solo funciona cuando la cantidad de espacio necesaria es suficientemente pequeña para la pila. Si la longitud es corta, como de 32 bytes, está bien, pero si es de miles de bytes, podría provocar fácilmente una situación de desbordamiento de pila. ¿Qué sucedería si, en su lugar, pudiera escribir en la memoria de la cadena directamente? Span<T> permite hacerlo. Además del nuevo constructor de la cadena, ahora la cadena también tiene un método Create:

public static string Create<TState>(
  int length, TState state, SpanAction<char, TState> action);
...
public delegate void SpanAction<T, in TArg>(Span<T> span, TArg arg);

Este método se implementa para asignar la cadena y, a continuación, distribuir un intervalo grabable que pueda escribir para poder rellenar el contenido de la cadena mientras se construye. Observe que la naturaleza de solo pila de Span<T> es beneficiosa en este caso, ya que garantiza que el intervalo (que hace referencia al almacenamiento interno de la cadena) deje de existir antes de que se complete el constructor de la cadena e impide usar el intervalo para mutar la cadena una vez completada la construcción:

int length = ...;
Random rand = ...;
string id = string.Create(length, rand, (Span<char> chars, Random r) =>
{
  for (int i = 0; chars.Length; i++)
  {
    chars[i] = (char)(r.Next(0, 10) + '0');
  }
});

Ahora, no solo ha evitado la asignación, sino que está escribiendo directamente en la memoria de la cadena en el montón, lo que significa que también evita la copia y no está restringido por las limitaciones de tamaño de la pila.

Más allá de los tipos de marco principales que obtienen nuevos miembros, se están desarrollando muchos tipos nuevos de .NET que trabajarán con los intervalos para el procesamiento eficiente en escenarios específicos. Por ejemplo, los desarrolladores que buscan escribir aplicaciones de microservicios de alto rendimiento y sitios web con un procesamiento de texto intensivo pueden conseguir un aumento de rendimiento considerable si no tienen que codificar y descodificar desde cadenas al trabajar en UTF-8. Para permitirlo, se están agregando nuevos tipos, como System.Buffers.Text.Base64, System.Buffers.Text.Utf8Parser y System.Buffers.Text.Utf8Formatter. Estos tipos actúan en intervalos de bytes, lo que no solo evita la codificación y descodificación de Unicode, sino que les permite trabajar con búferes nativos que son comunes en los niveles más bajos de las distintas pilas de red:

ReadOnlySpan<byte> utf8Text = ...;
if (!Utf8Parser.TryParse(utf8Text, out Guid value,
  out int bytesConsumed, standardFormat = 'P'))
  throw new InvalidDataException();

Todas estas funciones no están destinadas solamente al uso público; en su lugar, el propio marco es capaz de utilizar estos nuevos métodos basados en Span<T> y en Memory<T> para conseguir un rendimiento mayor. Los sitios de llamada en .NET Core han pasado a usar las nuevas sobrecargas ReadAsync para evitar asignaciones innecesarias. El análisis realizado asignando subcadenas ahora aprovecha el análisis sin asignaciones. Incluso los tipos de nicho como Rfc2898DeriveBytes han entrado en acción. Estos aprovechan el nuevo método TryComputeHash basado en Span<byte> en System.Security.Cryptography.Hash­Algorithm para lograr un ahorro enorme en asignación (una matriz de bytes por iteración del algoritmo, que podría iterarse miles de veces), así como una mejora de rendimiento.

Esto no se detiene en el nivel de las bibliotecas de .NET principales, sino que sigue hasta la pila. ASP.NET Core ahora tiene una fuerte dependencia de los intervalos, por ejemplo, con el analizador HTTP del servidor Kestrel escrito encima de estos. En el futuro, es probable que los intervalos se expongan fuera de las API públicas en los niveles inferiores de ASP.NET Core, como en su canalización de middleware.

¿Qué hay del runtime de .NET?

Una de las maneras de proporcionar seguridad del runtime de .NET es garantizar que la indexación en una matriz no permita ir más allá de la longitud de la matriz, una práctica que se conoce como comprobación de límites. Por ejemplo, considere este método:

[MethodImpl(MethodImplOptions.NoInlining)]
static int Return4th(int[] data) => data[3];

En la máquina x64 en la que escribo este artículo, el ensamblado generado para este método tiene el aspecto siguiente:

sub      rsp, 40
       cmp      dword ptr [rcx+8], 3
       jbe      SHORT G_M22714_IG04
       mov      eax, dword ptr [rcx+28]
       add      rsp, 40
       ret
G_M22714_IG04:
       call     CORINFO_HELP_RNGCHKFAIL
       int3

Esa instrucción cmp compara la longitud de la matriz de datos con el índice 3, y la instrucción jbe posterior salta a la rutina de error de comprobación de intervalo si 3 se encuentra fuera de rango (para que se genere una excepción). El compilador JIT debe generar código que garantice que estos accesos no superan los límites de la matriz, lo que no significa que cada acceso a la matriz individual requiera una comprobación de límites. Considere este método Sum:

static int Sum(int[] data)
{
  int sum = 0;
  for (int i = 0; i < data.Length; i++) sum += data[i];
  return sum;
}

El compilador JIT debe generar código aquí que garantice que los accesos a data[i] no superan los límites de la matriz, pero, dado que el compilador JIT puede usar la estructura del bucle para indicar que siempre estaré en el rango (el bucle se itera en cada elemento de principio a fin), también puede realizar la optimización fuera de las comprobaciones de límites en la matriz. Así, el código de ensamblado generado para el bucle tiene el aspecto siguiente:

G_M33811_IG03:
       movsxd   r9, edx
       add      eax, dword ptr [rcx+4*r9+16]
       inc      edx
       cmp      r8d, edx
       jg       SHORT G_M33811_IG03

Una instrucción cmp sigue estando en el bucle, pero simplemente para comparar el valor de i (según esté almacenado en el registro edx) con la longitud de la matriz (según esté almacenada en el registro r8d); no se realizan comprobaciones de límites adicionales.

El runtime aplica optimizaciones similares al intervalo (tanto Span<T> como ReadOnlySpan<T>). Compare el ejemplo anterior con el código siguiente, donde el único cambio es el tipo de parámetro:

static int Sum(Span<int> data)
{
  int sum = 0;
  for (int i = 0; i < data.Length; i++) sum += data[i];
  return sum;
}

El ensamblado generado para el código es casi idéntico:

G_M33812_IG03:
       movsxd   r9, r8d
       add      ecx, dword ptr [rax+4*r9]
       inc      r8d
       cmp      r8d, edx
       jl       SHORT G_M33812_IG03

La gran similitud del código de ensamblado se debe en parte a la eliminación de las comprobaciones de límites. También es importante el reconocimiento de JIT del indexador de intervalos como intrínseco, lo que significa que el compilador JIT genera código especial para el indexador, en lugar de trasladar su código IL real al ensamblado.

Todo esto es para ilustrar que el runtime puede aplicar a los intervalos los mismos tipos de optimizaciones que a las matrices, para convertir los intervalos en un mecanismo eficiente para acceder a datos. Existen más detalles disponibles en la entrada de blog en bit.ly/2zywvyI.

¿Qué hay del lenguaje y el compilador de C#?

Ya he aludido a las características agregadas al lenguaje C# y al compilador para convertir Span<T> en un componente de primera de .NET. Varias características de C# 7.2 están relacionadas con los intervalos (de hecho, el compilador de C# 7.2 será necesario para usar Span<T>). Veamos tres de estas características.

Estructuras ref. Como mencionamos anteriormente, Span<T> es un tipo similar a una referencia, que se expone en C# a partir de la versión 7.2 como una estructura ref. Al colocar la palabra clave ref delante de struct, indica al compilador de C# que le permita usar otros tipos de estructura ref, tales como Span<T> como campos. Al hacerlo, también se suscribe a las restricciones asociadas que se van a asignar a su tipo. Por ejemplo, si quiere escribir un objeto struct Enumerator para un tipo Span<T>, dicho objeto Enumerator deberá almacenar el tipo Span<T> y, por tanto, deberá ser una estructura ref, como la siguiente:

public ref struct Enumerator
{
  private readonly Span<char> _span;
  private int _index;
  ...
}

Inicialización de intervalos de stackalloc. En versiones anteriores de C#, el resultado de stackalloc solo podía almacenarse en la variable local de un puntero. A partir de C# 7.2, stackalloc ya se puede usar como parte de una expresión y puede dirigirse a un intervalo, lo que se puede hacer sin usar la palabra clave unsafe. Así, en lugar de escribir:

Span<byte> bytes;
unsafe
{
  byte* tmp = stackalloc byte[length];
  bytes = new Span<byte>(tmp, length);
}

Puede escribir simplemente:

Span<byte> bytes = stackalloc byte[length];

Esto también resulta extremadamente útil en situaciones en las que necesita algo de espacio de desecho para realizar una operación, pero quiere evitar asignar memoria de montón para tamaños relativamente pequeños. Anteriormente, tenía dos opciones:

  • Escribir dos rutas de código completamente diferentes, y realizar asignaciones y actuar en la memoria basada en la pila y la memoria basada en el montón.
  • Anclar la memoria asociada a la asignación administrada y, a continuación, delegarla en una implementación que también se use para la memoria basada en la pila y que esté escrita con manipulación del puntero en código no seguro.

Ahora, se puede hacer lo mismo con la duplicación de código, con el código seguro y con el mínimo ritual:

Span<byte> bytes = length <= 128 ? stackalloc byte[length] : new byte[length];
... // Code that operates on the Span<byte>

Validación del uso de intervalos. Dado que los intervalos pueden hacer referencia a datos que podrían estar asociados a un marco de pila determinado, puede ser peligroso pasar intervalos de manera que se pueda habilitar la referencia a memoria que ya no es válida. Por ejemplo, imagine un método que ha intentado hacer lo siguiente:

static Span<char> FormatGuid(Guid guid)
{
  Span<char> chars = stackalloc char[100];
  bool formatted = guid.TryFormat(chars, out int charsWritten, "d");
  Debug.Assert(formatted);
  return chars.Slice(0, charsWritten); // Uh oh
}

Aquí, se asigna espacio de la pila y, a continuación, se intenta devolver una referencia a ese espacio, pero en el momento en que se devuelve, ese espacio ya no será válido para usarse. Afortunadamente, el compilador de C# detecta este uso no válido con estructuras ref y no puede realizar la compilación, que genera el error:

Error CS8352: No se puede usar un elemento "chars" local en este contexto porque puede exponer variables a las que se hace referencia fuera de su ámbito de declaración.

Siguientes pasos

Los tipos, los métodos, las optimizaciones de runtime y otros elementos que se tratan aquí están en proceso de incluirse en .NET Core 2.1. Después de eso, espero abrirles el camino a .NET Framework. Los tipos principales como Span<T> y los nuevos tipos como Utf8Parser también están en proceso de incluirse en un paquete System.Memory.dll que sea compatible con .NET Standard 1.1. De este modo, la funcionalidad estará disponible para las versiones existentes de .NET Framework y .NET Core, aunque sin algunas de estas optimizaciones implementadas al integrarse en la plataforma. Existe una versión preliminar de este paquete disponible para que la pruebe hoy mismo. Solo tiene que agregar una referencia al paquete System.Memory.dll desde NuGet.

Por supuesto, tenga en cuenta que puede haber y habrá cambios de última hora entre la versión preliminar actual y lo que se entrega realmente en una versión estable. Estos cambios se deberán en gran parte a los comentarios de desarrolladores como usted a medida que experimente con el conjunto de características. Dele una oportunidad y eche un vistazo a los repositorios github.com/dotnet/coreclr y github.com/dotnet/corefx para conocer el trabajo continuo. También puede encontrar documentación en aka.ms/ref72.

Por último, el éxito de este conjunto de características depende de los desarrolladores que lo prueban, proporcionan comentarios y compilan sus propias bibliotecas usando estos tipos, todo ello con el objetivo de proporcionar un acceso seguro y eficiente a la memoria en los programas de .NET modernos. Esperamos recibir comentarios sobre sus experiencias o, aún mejor, trabajar con usted en GitHub para mejorar .NET aún más.


Stephen Toub trabaja en .NET en Microsoft. Puede encontrarlo en GitHub en github.com/stephentoub.

Gracias a los siguientes expertos técnicos por revisar este artículo: Krzysztof Cwalina, Eric Erhardt, Ahson Khan, Jan Kotas, Jared Parsons, Marek Safar, Vladimir Sadov, Joseph Tremoulet, Bill Wagner, Jan Vorlicek y Karel Zikmund


Discuta sobre este artículo en el foro de MSDN Magazine