Romper los fundamentos fundamentales de C #: asignar memoria para un tipo de referencia en la pila

En este artículo, se proporcionarán los conceptos básicos del dispositivo de tipo interno, así como un ejemplo en el que la memoria para el tipo de referencia se asignará completamente en la pila (esto es porque soy un programador de pila completa).



Descargo de responsabilidad


Este artículo no contiene material que deba usarse en proyectos reales. Es simplemente una extensión de los límites en los que se percibe un lenguaje de programación.

Antes de comenzar la historia, le recomiendo que lea la primera publicación sobre StructLayout , porque Allí se analiza un ejemplo que se utilizará en este artículo (Sin embargo, como siempre).

Antecedentes


Comenzando a escribir el código para este artículo, quería hacer algo interesante usando el lenguaje ensamblador. Quería romper de alguna manera el modelo de ejecución estándar y obtener un resultado realmente inusual. Y recordando la frecuencia con la que la gente dice que el tipo de referencia difiere del significativo en que el primero se encuentra en el montón y el segundo en la pila, decidí usar el ensamblador para mostrar que el tipo de referencia puede vivir en la pila. Sin embargo, comencé a encontrar todo tipo de problemas, por ejemplo, devolver la dirección deseada y representarla como un enlace administrado (todavía estoy trabajando en ello). Entonces comencé a engañar y hacer lo que no funciona en ensamblador, en C #. Y al final, el ensamblador no se quedó en absoluto.
Además, una recomendación para leer: si está familiarizado con el dispositivo de los tipos de referencia, le recomiendo omitir la teoría sobre ellos (solo se darán los conceptos básicos, nada interesante).

Un poco sobre la estructura interna de los tipos.


Me gustaría recordarle que la separación de la memoria en la pila y el montón se produce en el nivel .NET, y esta división es puramente lógica, físicamente no hay diferencia entre las áreas de memoria debajo del montón y debajo de la pila. La diferencia en productividad ya se proporciona específicamente al trabajar con estas áreas.

¿Cómo asignar memoria en la pila? Para empezar, veamos cómo está estructurado este misterioso tipo de referencia y qué contiene, lo que no es significativo.

Entonces, considere el ejemplo más simple con la clase Employee.

Código de empleado
public class Employee { private int _id; private string _name; public virtual void Work() { Console.WriteLine(“Zzzz...”); } public void TakeVacation(int days) { Console.WriteLine(“Zzzz...”); } public static void SetCompanyPolicy(CompanyPolicy policy) { Console.WriteLine("Zzzz..."); } } 


Y eche un vistazo a cómo se presenta en la memoria.
UPD: esta clase se considera en el ejemplo de un sistema de 32 bits.



Por lo tanto, además de la memoria para los campos, tenemos dos campos ocultos más: el índice del bloque de sincronización (la palabra del título del objeto en la imagen) y la dirección de la tabla de métodos.

El primer campo, es el índice del bloque de sincronización, no estamos particularmente interesados. Al colocar el tipo, decidí omitirlo. Hice esto por dos razones:

  1. Soy muy vago (no dije que las razones sean razonables)
  2. Este campo es opcional para el funcionamiento básico del objeto.

Pero como ya hemos hablado, creo que es correcto decir algunas palabras sobre este campo. Se utiliza para diferentes propósitos (código hash, sincronización). Más bien, el campo en sí mismo es simplemente el índice de uno de los bloques de sincronización asociados con este objeto. Los bloques en sí están ubicados en la tabla de bloques de sincronización (a la matriz global). Crear un bloque de este tipo es una operación bastante grande, por lo que no se crea si no es necesario. Además, cuando se usan bloqueos delgados, el identificador del hilo que recibió el bloqueo (en lugar del índice) se escribirá allí.

El segundo campo es mucho más importante para nosotros. Gracias a la tabla de métodos de tipo, es posible una herramienta tan poderosa como el polimorfismo (que, por cierto, no es poseída por la estructura, los reyes de la pila). Suponga que la clase Employee implementa adicionalmente tres interfaces: IComparable, IDisposable e ICloneable.

Entonces la tabla de métodos se verá así



La imagen es muy genial, allí, en principio, todo está pintado y es comprensible. Si le faltan dedos, entonces el método virtual no se llama directamente a la dirección, sino por el desplazamiento en la tabla de métodos. En la jerarquía, los mismos métodos virtuales se ubicarán en el mismo desplazamiento en la tabla de métodos. Es decir, llamamos al método en la clase base en el desplazamiento, sin saber qué tipo de tabla de métodos se utilizará, pero sabiendo que en este desplazamiento habrá el método más relevante para el tipo de tiempo de ejecución.

También vale la pena recordar que la referencia al objeto apunta a la tabla de métodos.

El ejemplo tan esperado


Comencemos con clases que nos ayudarán en nuestro objetivo. Usando StructLayout (realmente lo intenté sin él, pero no funcionó) escribí los mapeadores de puntero más simples para los tipos administrados y viceversa. Es bastante fácil obtener un puntero de un enlace administrado, pero la transformación inversa me causó dificultades y, sin pensarlo dos veces, apliqué mi atributo favorito. Para mantener el código en una clave, lo hice en 2 direcciones de una manera.

Codigo aqui
 //     public class PointerCasterFacade { public virtual unsafe T GetManagedReferenceByPointer<T>(int* pointer) => default(T); public virtual unsafe int* GetPointerByManagedReference<T>(T managedReference) => (int*)0; } //     public class PointerCasterUnderground { public virtual T GetManagedReferenceByPointer<T>(T reference) => reference; public virtual unsafe int* GetPointerByManagedReference<T>(int* pointer) => pointer; } [StructLayout(LayoutKind.Explicit)] public class PointerCaster { public PointerCaster() { pointerCaster= new PointerCasterUnderground(); } [FieldOffset(0)] private PointerCasterUnderground pointerCaster; [FieldOffset(0)] public PointerCasterFacade Caster; } 


Primero, escriba un método que tome un puntero a alguna memoria (no necesariamente en la pila, por cierto) y configure el tipo.

Para facilitar la búsqueda de la dirección de la tabla de métodos, creo un tipo en el montón. Estoy seguro de que la tabla de métodos se puede encontrar de otras maneras, pero no me propuse el objetivo de optimizar este código, fue más interesante para mí hacerlo comprensible. Luego, usando los convertidores descritos anteriormente, obtenemos un puntero al tipo creado.

Este puntero apunta exactamente a la tabla de métodos. Por lo tanto, es suficiente simplemente obtener el contenido de la memoria a la que apunta. Esta será la dirección de la tabla de métodos.
Y dado que el puntero que se nos pasa es una especie de referencia al objeto, debemos escribir la dirección de la tabla de métodos exactamente donde apunta.

Eso es todo, en realidad. Inesperadamente, ¿verdad? Ahora nuestro tipo está listo. Pinocho, quien nos asignó la memoria, se encargará de la inicialización de los campos.

Solo queda usar el grandcaster para convertir el puntero en un enlace administrado.

 public class StackInitializer { public static unsafe T InitializeOnStack<T>(int* pointer) where T : new() { T r = new T(); var caster = new PointerCaster().Caster; int* ptr = caster.GetPointerByManagedReference(r); pointer[0] = ptr[0]; T reference = caster.GetManagedReferenceByPointer<T>(pointer); return reference; } } 

Ahora tenemos un enlace en la pila que apunta a la misma pila, donde según todas las leyes de los tipos de referencia (bueno, casi) yace un objeto construido a partir de tierra negra y palos. El polimorfismo está disponible.

Debe entenderse que si pasa este enlace fuera del método, luego de regresar de él, obtendremos algo poco claro. No se puede hablar de llamadas a métodos virtuales; vuelemos por excepción. Los métodos regulares se llaman directamente, en el código simplemente habrá direcciones para métodos reales, por lo que funcionarán. Y en el lugar de los campos habrá ... pero nadie sabe qué habrá allí.

Dado que es imposible usar un método separado para la inicialización en la pila (dado que el marco de la pila se borrará después de regresar del método), la memoria debe asignarse por el método que desea usar el tipo en la pila. Estrictamente hablando, no hay una sola manera de hacer esto. Pero lo más adecuado para nosotros es stackalloc. La palabra clave perfecta para nuestros propósitos. Desafortunadamente, fue lo que introdujo la incontrolabilidad en el código. Antes de eso, había una idea de usar Span para estos fines y prescindir de un código inseguro. No hay nada de malo en el código inseguro, pero como en cualquier otro lugar, no es una bala de plata y tiene sus propias áreas de aplicación.

Luego, después de recibir un puntero a la memoria en la pila actual, pasamos este puntero a un método que compone el tipo en partes. Eso fue todo lo que escuchó, bien hecho.

 unsafe class Program { public static void Main() { int* pointer = stackalloc int[2]; var a = StackInitializer.InitializeOnStack<StackReferenceType>(pointer); a.StubMethod(); Console.WriteLine(a.Field); Console.WriteLine(a); Console.Read(); } } 

¡No debe usar esto en proyectos reales, el método que asigna memoria en la pila usa el nuevo T (), que a su vez usa la reflexión para crear el tipo en el montón! Entonces, este método será más lento que la creación habitual del tipo una vez, bueno, 40-50.

Aquí puedes ver todo el proyecto.

Fuente: en una digresión teórica, se usaron ejemplos del libro Sasha Goldstein - Pro .NET Performace

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


All Articles