[DotNetBook] Span: Nuevo tipo de datos .NET

Con este artículo, continúo publicando una serie de artículos, cuyo resultado será un libro sobre el trabajo de .NET CLR y .NET en general (aproximadamente 200 páginas del libro ya están listas, así que bienvenidos al final del artículo para enlaces).


Tanto el lenguaje como la plataforma han existido durante muchos años: y todo este tiempo ha habido muchas herramientas para trabajar con código no administrado. Entonces, ¿por qué ahora sale la próxima API para trabajar con código no administrado si de hecho ha existido durante muchos, muchos años? Para responder a esta pregunta, es suficiente entender lo que faltaba antes.


Los desarrolladores de la plataforma han tratado de ayudarnos a alegrar la vida cotidiana del desarrollo utilizando recursos no administrados: estos son envoltorios automáticos para métodos importados. Y la clasificación, que en la mayoría de los casos funciona automáticamente. Esta también es una instrucción stackallloc , que se trata en el capítulo sobre la pila de subprocesos. Sin embargo, en cuanto a mí, si los primeros desarrolladores que usan C # vinieron del mundo de C ++ (como lo hice yo), ahora provienen de lenguajes de nivel superior (por ejemplo, conozco un desarrollador que vino de JavaScript). ¿Qué significa esto? Esto significa que las personas sospechan cada vez más de los recursos no administrados y las construcciones que son similares en espíritu a C / C ++ y aún más a Assembler.


Nota


El capítulo publicado en Habré no está actualizado y, probablemente, está un poco desactualizado. Y, por lo tanto, consulte el original para obtener un texto más reciente:



Como resultado de tal actitud, cada vez hay menos contenido de código inseguro en los proyectos y cada vez más confianza en la API de la plataforma misma. Esto se verifica fácilmente observando el uso de la construcción stackalloc en los repositorios abiertos: es insignificante. Pero si toma algún código que lo use:


Interoperabilidad de clase.
/src/mscorlib/shared/Interop/Unix/System.Native/Interop.ReadDir.cs


 unsafe { // s_readBufferSize is zero when the native implementation does not support reading into a buffer. byte* buffer = stackalloc byte[s_readBufferSize]; InternalDirectoryEntry temp; int ret = ReadDirR(dir.DangerousGetHandle(), buffer, s_readBufferSize, out temp); // We copy data into DirectoryEntry to ensure there are no dangling references. outputEntry = ret == 0 ? new DirectoryEntry() { InodeName = GetDirectoryEntryName(temp), InodeType = temp.InodeType } : default(DirectoryEntry); return ret; } 

Queda claro el motivo de la impopularidad. Mire sin leer el código y responda una pregunta: ¿confía en él? Puedo suponer que la respuesta es no. Luego responde al otro: ¿por qué? La respuesta será obvia: además de ver la palabra Dangerous , que de alguna manera insinúa que algo podría salir mal, el segundo factor que afecta nuestra actitud es el byte* buffer = stackalloc byte[s_readBufferSize]; línea byte* buffer = stackalloc byte[s_readBufferSize]; , y más específicamente, byte* . Este registro es un disparador para cualquiera, de modo que el pensamiento aparece en mi cabeza: "¿qué, no se podría hacer de manera diferente o qué?". Entonces echemos un vistazo más al psicoanálisis: ¿por qué podría surgir tal pensamiento? Por un lado, usamos construcciones de lenguaje y la sintaxis propuesta aquí está lejos de, por ejemplo, C ++ / CLI, lo que le permite hacer cualquier cosa (incluidas las inserciones en Assembler puro), y por otro, parece inusual.


Entonces, ¿cuál es la pregunta? ¿Cómo devolver a los desarrolladores al seno del código no administrado? Es necesario darles una sensación de calma de que no pueden cometer un error por accidente, por ignorancia. Entonces, ¿por qué se introducen los Span<T> y Memory<T> ?


Span [T], ReadOnlySpan [T]


El tipo Span representa una parte de una determinada matriz de datos, un subrango de sus valores. Al mismo tiempo, permite, como en el caso de una matriz, trabajar con elementos de este rango tanto para escribir como para leer. Sin embargo, para el overclocking y la comprensión general, comparemos los tipos de datos para los que se realiza una implementación del tipo Span y veamos los posibles propósitos de su introducción.


El primer tipo de datos del que desea hablar es una matriz regular. Para las matrices, trabajar con Span se verá así:


  var array = new [] {1,2,3,4,5,6}; var span = new Span<int>(array, 1, 3); var position = span.BinarySearch(3); Console.WriteLine(span[position]); // -> 3 

Como vemos en este ejemplo, para empezar creamos una determinada matriz de datos. Después de eso, creamos un Span (o un subconjunto) que, refiriéndose a la matriz en sí, permite que su código use solo el rango de valores que se especificó durante la inicialización.


Aquí vemos la primera propiedad de este tipo de datos: crea algo de contexto. Desarrollemos nuestra idea con contextos:


 void Main() { var array = new [] {'1','2','3','4','5','6'}; var span = new Span<char>(array, 1, 3); if(TryParseInt32(span, out var res)) { Console.WriteLine(res); } else { Console.WriteLine("Failed to parse"); } } public bool TryParseInt32(Span<char> input, out int result) { result = 0; for (int i = 0; i < input.Length; i++) { if(input[i] < '0' || input[i] > '9') return false; result = result * 10 + ((int)input[i] - '0'); } return true; } ----- 234 

Como podemos ver, Span<T> introduce una abstracción de acceso a una determinada pieza de memoria, tanto para leer como para escribir. ¿Qué nos da esto? Si recordamos qué más se puede hacer con base en Span , entonces recordamos tanto los recursos no administrados como las líneas:


 // Managed array var array = new[] { '1', '2', '3', '4', '5', '6' }; var arrSpan = new Span<char>(array, 1, 3); if (TryParseInt32(arrSpan, out var res1)) { Console.WriteLine(res1); } // String var srcString = "123456"; var strSpan = srcString.AsSpan().Slice(1, 3); if (TryParseInt32(strSpan, out var res2)) { Console.WriteLine(res2); } // void * Span<char> buf = stackalloc char[6]; buf[0] = '1'; buf[1] = '2'; buf[2] = '3'; buf[3] = '4'; buf[4] = '5'; buf[5] = '6'; if (TryParseInt32(buf.Slice(1, 3), out var res3)) { Console.WriteLine(res3); } ----- 234 234 234 

Es decir, Span<T> es una herramienta de unificación para trabajar con memoria: administrada y no administrada, que garantiza la seguridad en el trabajo con este tipo de datos durante la recolección de basura: si las áreas de memoria con matrices administradas comienzan a moverse, entonces Será seguro para nosotros.


Sin embargo, ¿vale la pena alegrarse tanto? ¿Se podría haber logrado todo esto antes? Por ejemplo, si hablamos de matrices administradas, entonces no hay duda alguna: simplemente envuelva la matriz en otra clase, proporcionando una interfaz similar y listo. Además, se puede hacer una operación similar con cadenas: tienen los métodos necesarios. Nuevamente, simplemente envuelva la cadena exactamente del mismo tipo y proporcione métodos para trabajar con ella. Otra cosa es que para almacenar una cadena, un búfer o una matriz en un tipo, tendrá que jugar mucho almacenando enlaces a cada una de las opciones posibles en una sola instancia (por supuesto, solo una estará activa):


 public readonly ref struct OurSpan<T> { private T[] _array; private string _str; private T * _buffer; // ... } 

O, si comienza desde la arquitectura, haga tres tipos que hereden una sola interfaz. Resulta que para hacer de la herramienta una interfaz unificada entre estos tipos de datos managed , manteniendo el máximo rendimiento, no hay otra forma que Span<T> .


Además, para continuar la discusión, ¿qué es una ref struct en términos de Span ? Estas son precisamente las "estructuras, solo están en la pila", de las que tan a menudo escuchamos en las entrevistas. Y esto significa que este tipo de datos solo puede pasar por la pila y no tiene derecho a ir al montón. Y, por lo tanto, Span , al ser una estructura de referencia, es un tipo de datos de contexto que proporciona métodos, pero no objetos en la memoria. De esto, para su comprensión, debemos proceder.


Desde aquí podemos formular una definición del tipo Span y el tipo readonly ReadOnlySpan asociado con él:


Span es un tipo de datos que proporciona una interfaz única para trabajar con tipos heterogéneos de matrices de datos, así como la capacidad de transferir un subconjunto de esta matriz a otro método para que, independientemente de la profundidad de contexto, la velocidad de acceso a la matriz original sea constante y lo más alta posible.

Y realmente: si tenemos algo como este código:


 public void Method1(Span<byte> buffer) { buffer[0] = 0; Method2(buffer.Slice(1,2)); } Method2(Span<byte> buffer) { buffer[0] = 0; Method3(buffer.Slice(1,1)); } Method3(Span<byte> buffer) { buffer[0] = 0; } 

entonces la velocidad de acceso al búfer de origen será lo más alta posible: no está trabajando con un objeto administrado, sino con un puntero administrado. Es decir no con un tipo administrado .NET, sino con un tipo inseguro envuelto en un shell administrado.


Span [T] por ejemplos


Una persona está tan dispuesta que, a menudo, hasta que recibe una cierta experiencia, a menudo no llega una comprensión final de por qué se necesita una herramienta. Y por lo tanto, dado que necesitamos algo de experiencia, pasemos a los ejemplos.


ValueStringBuilder


Uno de los ejemplos más algorítmicamente interesantes es el tipo ValueStringBuilder , que está enterrado en algún lugar de las entrañas de mscorlib y, por alguna razón, como muchos otros tipos de datos interesantes, está marcado con el modificador internal , lo que significa que si no fuera por el estudio del código fuente de mscorlib, hablaremos de un método de optimización tan maravilloso Nunca lo sabría.


¿Cuál es el principal inconveniente del tipo de sistema StringBuilder? Esto, por supuesto, es su esencia: tanto él mismo como en qué se basa (y esto es una serie de caracteres char[] ) son tipos de referencia. Y eso significa al menos dos cosas: todavía (aunque un poco) cargamos un montón y el segundo: aumentamos la posibilidad de fallar en los cachés del procesador.


Otra pregunta que tuve para StringBuilder fue la formación de pequeñas cadenas. Es decir cuando la línea de resultado "dar diente" será corta: por ejemplo, menos de 100 caracteres. Cuando tenemos un formato bastante corto, surgen problemas de rendimiento:


  $"{x} is in range [{min};{max}]" 

¿Cuánto peor es este registro que la generación manual a través de StringBuilder? La respuesta está lejos de ser siempre obvia: todo depende del lugar de formación: con qué frecuencia se llamará a este método. Después de todo, primero string.Format asigna memoria para el StringBuilder interno, que creará una matriz de caracteres (SourceString.Length + args.Length * 8) y si durante la formación de la matriz resulta que no se adivinó la longitud, se creará otro StringBuilder para formar la continuación, formando así una lista simplemente conectada. Y como resultado, será necesario devolver la línea generada: y esta es otra copia. Derroche y despilfarro. Ahora, si nos libráramos de colocar la primera matriz de la cadena que se está formando en el montón, sería maravilloso: definitivamente nos libraríamos de un problema.


Eche un vistazo al tipo de las entrañas de mscorlib :


Clase ValueStringBuilder
/ src / mscorlib / shared / System / Text / ValueStringBuilder


  internal ref struct ValueStringBuilder { //           private char[] _arrayToReturnToPool; //     private Span<char> _chars; private int _pos; //    ,       public ValueStringBuilder(Span<char> initialBuffer) { _arrayToReturnToPool = null; _chars = initialBuffer; _pos = 0; } public int Length { get => _pos; set { int delta = value - _pos; if (delta > 0) { Append('\0', delta); } else { _pos = value; } } } //   -       public override string ToString() { var s = new string(_chars.Slice(0, _pos)); Clear(); return s; } //       //     :   public void Insert(int index, char value, int count) { if (_pos > _chars.Length - count) { Grow(count); } int remaining = _pos - index; _chars.Slice(index, remaining).CopyTo(_chars.Slice(index + count)); _chars.Slice(index, count).Fill(value); _pos += count; } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Append(char c) { int pos = _pos; if (pos < _chars.Length) { _chars[pos] = c; _pos = pos + 1; } else { GrowAndAppend(c); } } [MethodImpl(MethodImplOptions.NoInlining)] private void GrowAndAppend(char c) { Grow(1); Append(c); } //   ,     //         //            //           [MethodImpl(MethodImplOptions.NoInlining)] private void Grow(int requiredAdditionalCapacity) { Debug.Assert(requiredAdditionalCapacity > _chars.Length - _pos); char[] poolArray = ArrayPool<char>.Shared.Rent(Math.Max(_pos + requiredAdditionalCapacity, _chars.Length * 2)); _chars.CopyTo(poolArray); char[] toReturn = _arrayToReturnToPool; _chars = _arrayToReturnToPool = poolArray; if (toReturn != null) { ArrayPool<char>.Shared.Return(toReturn); } } [MethodImpl(MethodImplOptions.AggressiveInlining)] private void Clear() { char[] toReturn = _arrayToReturnToPool; this = default; // for safety, to avoid using pooled array if this instance is erroneously appended to again if (toReturn != null) { ArrayPool<char>.Shared.Return(toReturn); } } //  :       private void AppendSlow(string s); public bool TryCopyTo(Span<char> destination, out int charsWritten); public void Append(string s); public void Append(char c, int count); public unsafe void Append(char* value, int length); public Span<char> AppendSpan(int length); } 

Esta clase es similar en funcionalidad a su hermano mayor StringBuilder , pero tiene una característica interesante y muy importante: es un tipo significativo. Es decir almacenado y transmitido completamente por valor. Y el último modificador de tipo de ref , que se asigna a la firma de la declaración de tipo, nos dice que este tipo tiene una restricción adicional: tiene derecho a estar solo en la pila. Es decir La salida de sus instancias a los campos de clase dará como resultado un error. ¿Por qué todas estas sentadillas? Para responder a esta pregunta, solo mira la clase StringBuilder , cuya esencia acabamos de describir:


Class StringBuilder /src/mscorlib/src/System/Text/StringBuilder.cs


 public sealed class StringBuilder : ISerializable { // A StringBuilder is internally represented as a linked list of blocks each of which holds // a chunk of the string. It turns out string as a whole can also be represented as just a chunk, // so that is what we do. internal char[] m_ChunkChars; // The characters in this block internal StringBuilder m_ChunkPrevious; // Link to the block logically before this block internal int m_ChunkLength; // The index in m_ChunkChars that represent the end of the block internal int m_ChunkOffset; // The logical offset (sum of all characters in previous blocks) internal int m_MaxCapacity = 0; // ... internal const int DefaultCapacity = 16; 

StringBuilder es una clase dentro de la cual hay un enlace a una matriz de caracteres. Es decir cuando lo crea, de hecho, se crean al menos dos objetos: el propio StringBuilder y una matriz de caracteres de al menos 16 caracteres (por cierto, es por eso que es tan importante especificar la longitud estimada de la cadena: su construcción pasará por la generación de una lista de matrices de 16 caracteres conectadas individualmente). ) ¿Qué significa esto en el contexto de nuestra conversación sobre el tipo ValueStringBuilder: la capacidad está ausente de forma predeterminada, porque toma memoria del exterior, además es un tipo significativo y obliga al usuario a asignar un búfer para los caracteres en la pila. Como resultado, toda la instancia de tipo se inserta en la pila junto con su contenido, y el problema de optimización aquí se resuelve. ¿No hay asignación de memoria en el montón? No hay problema con la caída del rendimiento en el montón. Pero usted me dice: ¿por qué no usar ValueStringBuilder (o su versión autoescrita: es interna y no accesible para nosotros) siempre? La respuesta es: debe mirar el problema que está resolviendo. ¿La cadena resultante será de tamaño conocido? ¿Tendrá un cierto máximo conocido de longitud? Si la respuesta es sí y si el tamaño de la cadena no supera algunos límites razonables, puede usar una versión significativa de StringBuilder. De lo contrario, si esperamos largas colas, cambiamos a usar la versión normal.


ValueListBuilder


El segundo tipo de datos que quiero destacar especialmente es el tipo ValueListBuilder . Fue creado para situaciones en las que es necesario crear una colección de elementos por un corto tiempo e inmediatamente darle algún algoritmo para su procesamiento.


De acuerdo: la tarea es muy similar a la tarea ValueStringBuilder . Sí, y se resolvió de manera muy similar:


Archivo ValueListBuilder.cs


Para decirlo sin rodeos, tales situaciones son bastante comunes. Sin embargo, antes de resolver esta pregunta de otra manera: creamos una List , la poblamos con datos y perdimos el enlace. Si se llama al método con la frecuencia suficiente, surge una situación triste: muchas instancias de la clase List cuelgan en el montón, y con ellas las matrices asociadas con ellas se cuelgan en el montón. Ahora este problema se ha resuelto: no se crearán objetos adicionales. Sin embargo, como en el caso de ValueStringBuilder , se resolvió solo para programadores de Microsoft: la clase tiene un modificador internal .


Términos y condiciones de uso


Para finalmente comprender la esencia del nuevo tipo de datos, debe "jugar" con él escribiendo un par de cosas, o mejor, más métodos para usarlo. Sin embargo, las reglas básicas se pueden aprender ahora:


  • Si su método procesará algunos conjuntos de datos entrantes sin cambiar su tamaño, puede intentar detenerse en el tipo Span . Si no hay modificación de este búfer, entonces en el tipo ReadOnlySpan ;
  • Si su método funcionará con cadenas, calculando algunas estadísticas o analizando una cadena, entonces su método debe aceptar ReadOnlySpan<char> . Está obligado: esta es una nueva regla. Después de todo, si acepta una cadena, obliga a alguien a hacer una subcadena por usted
  • Si necesita hacer una matriz bastante corta con datos (por ejemplo, 10 KB como máximo) como parte del trabajo del método, puede organizar fácilmente dicha matriz usando Span<TType> buf = stackalloc TType[size] . Sin embargo, por supuesto, TType solo debe ser un tipo significativo, ya que stackalloc solo funciona con tipos significativos.

En otros casos, vale la pena echar un vistazo más de cerca a la Memory o al uso de tipos de datos clásicos.


Cómo funciona el palmo


Además, me gustaría hablar sobre cómo funciona Span y qué es tan notable al respecto. Y hay algo de qué hablar: el tipo de datos en sí se divide en dos versiones: para .NET Core 2.0+ y para todos los demás.


Span.Fast.cs, archivo .NET Core 2.0


 public readonly ref partial struct Span<T> { ///    .NET    internal readonly ByReference<T> _pointer; ///      private readonly int _length; // ... } 

Archivo ??? [descompilado]


 public ref readonly struct Span<T> { private readonly System.Pinnable<T> _pinnable; private readonly IntPtr _byteOffset; private readonly int _length; // ... } 

La cuestión es que .NET Framework y .NET Core 1. * no tienen un recolector de basura especialmente modificado (en contraste con la versión de .NET Core 2.0+) y, por lo tanto, se ven obligados a arrastrar un puntero adicional: al comienzo del búfer con el que trabajo Es decir, resulta que Span trabaja internamente con objetos administrados de la plataforma .NET como no administrados. Eche un vistazo al interior de la segunda versión de la estructura: hay tres campos. El primer campo es una referencia al objeto gestionado. El segundo es el desplazamiento desde el comienzo de este objeto en bytes para obtener el comienzo del búfer de datos (en líneas es un búfer con caracteres char , en las matrices es un búfer con datos de la matriz). Y finalmente, el tercer campo es el número de elementos de este búfer apilados uno tras otro.


Por ejemplo, tome el trabajo Span para cadenas:


Archivo coreclr :: src / System.Private.CoreLib / shared / System / MemoryExtensions.Fast.cs


 public static ReadOnlySpan<char> AsSpan(this string text) { if (text == null) return default; return new ReadOnlySpan<char>(ref text.GetRawStringData(), text.Length); } 

Donde string.GetRawStringData() siguiente:


Archivo de definición de campo coreclr :: src / System.Private.CoreLib / src / System / String.CoreCLR.cs


Archivo de definición GetRawStringData coreclr :: src / System.Private.CoreLib / shared / System / String.cs


 public sealed partial class String : IComparable, IEnumerable, IConvertible, IEnumerable<char>, IComparable<string>, IEquatable<string>, ICloneable { // // These fields map directly onto the fields in an EE StringObject. See object.h for the layout. // [NonSerialized] private int _stringLength; // For empty strings, this will be '\0' since // strings are both null-terminated and length prefixed [NonSerialized] private char _firstChar; internal ref char GetRawStringData() => ref _firstChar; } 

Es decir Resulta que el método va directamente dentro de la línea, y la especificación de ref char permite rastrear el enlace no administrado del GC dentro de la línea, moviéndolo junto con la línea durante la operación del GC.


La misma historia sucede con las matrices: cuando se crea Span , algún código dentro del JIT calcula el desplazamiento del comienzo de los datos de la matriz e inicializa el Span este desplazamiento. Y cómo calcular las compensaciones para cadenas y matrices, aprendimos en el capítulo sobre la estructura de los objetos en la memoria.


Span [T] como valor de retorno


A pesar de todo el idilio asociado Span, existen restricciones lógicas pero inesperadas en su devolución del método. Si nos fijamos en el siguiente código:


 unsafe void Main() { var x = GetSpan(); } public Span<byte> GetSpan() { Span<byte> reff = new byte[100]; return reff; } 

entonces todo parece extremadamente lógico y bueno. Sin embargo, vale la pena reemplazar una instrucción con otra:


 unsafe void Main() { var x = GetSpan(); } public Span<byte> GetSpan() { Span<byte> reff = stackalloc byte[100]; return reff; } 

cómo el compilador prohibirá una instrucción de este tipo. Pero antes de escribir por qué, le pido que adivine por sí mismo qué problemas implicará tal diseño.


Entonces, espero que hayas pensado, construido conjeturas y suposiciones, y tal vez incluso hayas entendido la razón. Si es así, no pinté el capítulo sobre la pila de hilos en los dientes en vano. Después de todo, después de haber dado un enlace a los parámetros locales de un método que ha terminado el trabajo, puede llamar a otro método, esperar a que termine y leer sus variables locales a través de x [0.99].


, , , , : CS8352 Cannot use local 'reff' in this context because it may expose referenced variables outside of their declaration scope : , , , .



Span<T> , . , use cases .


Enlace a todo el libro



Source: https://habr.com/ru/post/es418911/


All Articles