La siguiente entrada es un extracto de un texto perteneciente a una actividad calificable
del master “Investigación en Ingeniera del Software” que me encuentro actualmente
cursando, exactamente para la asignatura “Desarrollo de software Seguro”.
En este extracto hablo de manera breve de la gestión de memoria de .NET, así como
de su recolector de basura, comparándolos a C, y mostrando diferencias y semejanzas
entre ambos lenguajes de programación.
Gestión de memoria en sistema .NET
Tradicionalmente C/C++, tiene ciertas características con respecto al uso y gestión
de la memoria, que si no son tenidas en cuenta podrían generar diversos problemas
en nuestra aplicación. La mayoría de estos problemas tienen que ver con los punteros,
y resumiéndolos podrían quedar en la siguiente lista:
- El programador es responsable de obtener los recursos necesarios y libéralos. con lo que se puede "olvidar" de liberarlos apropiadamente.
- Lo array no validan "fronteras". podemos sáltanos libremente dichas fronteras (tamaño máximo o mínimo), y el sistema posiblemente no generar ningún fallo, hasta que se genere un error desconocido e inesperado en un punto del sistema que nos será difícil diagnosticar y puede exponer una vulnerabilidad.
- Los objetos pueden hacerse cast libremente entre ellos, sin comprobar que el tipo destino sea adecuado con respecto al origen, lo cual pueden generar liersos resultado inesperados.
- El tamaño de la variables numéricas podría varias con respecto a las arquitectura (x64 y x86), lo que podría revelar vulnerabilidades con respecto a la interconexión entre varios sistema, o con la compilación en una nueva arquitectura.
- Muchas funciones devuelve un código de error numérico para validar el éxito de la función pero debido a que no interrumpen la ejecución del programa, si ese valor no es comprobado siempre, el sistema continuara ejecutándose con un valor inconsistente.
- Todas la validaciones que tiene que usar el programador, colección de clases seguras que sustituyen a otras inseguras (que siguen existiendo y existiendo en las universidades), así como versiones especial de las funciones para trabajar con encodings de texto diferente. hace que C o C++ sea particularmente difíciles de entender para un programador novato.
Tanto Java, como posteriormente C#, hicieron especial énfasis, en solucionar todos
los problemas o dificultades relativos al uso de memoria (entre otras temas relacionados
con C++, como simplicidad de los mecanismo de herencia, y la sintaxis).
Se podría decir que en C#, casi todo es un puntero, salvo que no se llama como tal
siendo el nombre más apropiado que se le da "referencia". Las variables se separan
en tipos por "valor", que son principalmente las estructuras y números básicos (como
enteros), y se guardan en la pila de memoria y tipos por referencia (el resto de
los objetos), que se guarda en el montículo de la memoria.
Las variables se pasan, por defecto, por valor a las funciones (esto quiere decir
que se pasa una copia de la variable), esto genera confusión al pensar que cuando
pasamos un objeto entre funciones se copiara el objeto. Este no es el comportamiento
real, lo que se pasa es una copia a la referencia al objeto, con lo que si cambiamos
algo del mismo objeto estaremos cambiando realmente el objeto original, pero si
le asignamos a la variable otro valor, realmente estamos asignando a esa variable
una referencia a un objeto completamente nuevo que se perderá una vez finalizada
la variable. Para conservar el valor, se puede agregar el prefijo ref (que
indica que se debe conservar el valor de la variable si se asigna a otro objeto),
o el prefijo out (parecido al anterior, pero forzosamente debe asignarse valor antes
de salir de la función o la aplicación no compilara). Los tipos por "valor", como
la estructura, se pasan como indica el nombre por valor, y cualquier modificación
que se haga dentro de unas funciones, se perderán completamente la salir de las
funciones. Generalmente se intenta que estos tipos sean inmutables (como los enteros
o las fechas), para hacerlos más fáciles de usar, y evitar este inconveniente.
Este es un ejemplo de este comportamiento:
private class EjemploPorReferencia { public string Nombre { get; set; } } private struct EjemploPorValor { public string Nombre { get; set; } } private static void Funcion(EjemploPorReferencia referencia1, EjemploPorValor valor1, ref EjemploPorReferencia referencia2, ref EjemploPorValor valor2) { //Modifico el contenido de la referencia, esto hace que conserve el cambio fuera de la funcion referencia1.Nombre = "Nombre refeerncia uno"; //Como se pasa el VALOR de una referencia, al asignarle un nuevo objeto se pierde el anterior referencia1 = new EjemploPorReferencia { Nombre = "Nombre referencia dos" }; //Este nuevo objeto recien creado se perderna al regregar la funcion, puesto que es una neuva funcion //Pero los cambios realizados ANTES de asignar el objeto seran conserbados (puesto que se hiceron en la referencia original. //Este cambio se perdera, puesto que las estructuras se pasan por valor siempre y no son referencias //como los objetos valor1.Nombre = "Nombre estructura una"; //En este objeto la misma referencia se pasa por referencia, con lo que se conservan los cambios //y ademas si se asigna a otra variable (otra estructura) esta se conservara. referencia2.Nombre = "Nombre referencia dos"; //se conserva el valor referencia2 = new EjemploPorReferencia() { Nombre = "Nombre referencia dos, creada desde dentro" }; //Debido a que se asigna un nuevo valor y se conserva el ultimo, y no el primero (por el atributo REF). //En caso la estructura se pasa por referencia, con lo que se conservan los cambios //y ademas si se asigna a otra variable (otra estructura) esta se conservara. valor2.Nombre = "Nombre estrura dos"; //se conserva el valor valor2 = new EjemploPorValor() { Nombre = "Nombre estructura dos, creada desde dentro" }; //Debido a que se asigna un nuevo valor y se conserva el ultimo, y no el primero (por el atributo REF). } private static void Main(string[] args) { //Inicializador de las variables EjemploPorReferencia referencia1 = new EjemploPorReferencia { Nombre = "referencia1" }; EjemploPorValor valor1 = new EjemploPorValor { Nombre = "valor1" }; EjemploPorReferencia referencia2 = new EjemploPorReferencia { Nombre = "referencia2" }; EjemploPorValor valor2 = new EjemploPorValor { Nombre = "valor2" }; //Se muestran los valores previos Console.WriteLine("VALORES PREVIOS"); Console.WriteLine(String.Format("Referencia1 :{0}", referencia1.Nombre)); Console.WriteLine(String.Format("Valor1 :{0}", valor1.Nombre)); Console.WriteLine(String.Format("Referencia2 :{0}", referencia2.Nombre)); Console.WriteLine(String.Format("Valor2 :{0}", valor2.Nombre)); Console.WriteLine(); //llamo a las funciones Funcion(referencia1, valor1, ref referencia2, ref valor2); //Se muestran los valores Actuales Console.WriteLine("VALORES ACTUALES"); Console.WriteLine(String.Format("Referencia1 :{0}", referencia1.Nombre)); Console.WriteLine(String.Format("Valor1 :{0}", valor1.Nombre)); Console.WriteLine(String.Format("Referencia2 :{0}", referencia2.Nombre)); Console.WriteLine(String.Format("Valor2 :{0}", valor2.Nombre)); Console.WriteLine(); Console.ReadLine(); }
El resultado esperado de la ejecución de esta función el siguiente:
Liberación de memoria en .NET
.NET, a diferencia de C/C++, tiene un mecanismo de liberación de memoria automático
llamado Recolector de Basura, con la que se desentiende de esta tarea al
programador, y se elimina la posibilidad de "olvidos" en cuanto a esta tarea, o
liberaciones incorrectas (como dobles liberaciones, cuando la memoria ya está siendo
usado para otros objetos, etc.).
Los recolectores de basura, no son novedad de .NET o Java, por ejemplo ya existían
en Visual Basic 6.0 o Perl u otros más antiguos, en el que la liberación de la memoria,
se realizan en cuanto un objeto dejaba de ser usado. Estos mecanismos se llamaban
Recolectores de Bausera basando en conteos. Lo que hace es contar las referencias
que existen para un objeto y cuanto estas llegan a cero, se libera la memoria relacionada
inmediatamente.
Este mecanismo tiene dos inconvenientes, la primera, es que es muy lento, al tener
que comprobar el conteo de las variables, cada vez que se liberan. La segunda es
que si tenemos una referencia cruzada, digamos entre un objeto A o B, si no se destruye
esa relación, los objetos nunca se liberan aunque no existan referencias externas
a A o B, puesto que siempre existirá una referencia, la de A a B y viceversa.
El mecanismo de recolección de basura de .NET es diferente, no se basa en conteo,
sino en localizar los segmentos de memoria que son accesibles, en un determinado
momento. El principio es el siguiente, la liberación de memoria es costosa, y más
si es muy repetida, con lo que .NET no libera memoria, a no ser que sea necesario
(con lo que siempre da la impresión que las aplicaciones de .NET ocupan mucha memoria,
aunque esto es un comportamiento normal). Cuando se produce la necesidad de la liberación,
al principio se marca toda la memoria como "sucia" o en "desuso", y se comienza
revisando los objetos que todavía están referenciados desde el punto de entrada
de la aplicación, dichos objetos se marcan "en uso", y se comienza revisando los
objetos referenciaos por los "objetos" en uso, y a su vez se marcan "en uso", y
se repite la operación. Al final los objetos que están marcados "en uso", son conservados
en memoria, y el resto que sigue marcado como memoria "sucia" es liberado. De esta
forma el proceso es más rápido y menos costoso, a la vez que se soluciona el problema
de objetos referenciados entre sí puesto que si no hay ningún otro objeto que los
referencia, se marcaran como "sucios" (puesto que no hay manera de llegar a ellos
desde un punto externo a sí mismos) y se liberan sin problemas.
El proceso es válido (y automático) para objetos de .NET, pero por ejemplo para
liberar componentes como ActiveX de manera segura, es necesario hacerlo explícitamente
con el comando Marshal.FinalReleaseComObject(COM), sino dicho memoria, seguirá
siendo consumida indefinidamente y posiblemente aumentando su uso.
Identidad y tamaño de los objetos
Los objetos en C#, tienen asociada su identidad y los array su tamaño, a diferencia
de C++ en el que no hay forma de conocer el tipo de un objeto o el tamaño de un
array teniendo exclusivamente dicho array u objeto.
En .NET es siempre posible recuperar la identidad del objeto con el método, GetType,
a la vez que se puede comprobar si un objeto es de un tipo determinado con el operador
is.
EjemploPorReferencia referencia1 = new EjemploPorReferencia(); //Obtengo el tipo Type tipo = referencia1.GetType(); //Cambien puedo comprobar el tipo, de esta forma if (tipo is EjemploPorReferencia) { Console.WriteLine("El tipo es referencia"); }
Igualmente el compilador nos impide realizar cast inválidos, advirtiéndonos
de esta circunstancia como un error.
Aunque se puede "engañar" al compilador dando un rodeo a la asignación que compilara
perfectamente, pero sin embargo dará un excepción que interrumpirá el flujo normal
del programa (a diferencia de C/C++ en el que la ejecución del programa continuara,
hasta que eventualmente generar un comportamiento inesperado).
Algo semejante ocurrida cuando intentamos acceder a una posición invalidad dentro
de un array (nuevamente C nos permitirá hacerlo y c#, generara una excepción).
Igualmente podemos comprobar el tamaño de los array con la propiedad Length.
Por otro lado .NET nos facilita el acceso a los array con el comando foreach, de
forma que no tenemos que trabajar con índices dentro de un array o una lista (además
este comando para más seguridad impide las modificaciones del objeto que se esté
recorriendo).
private static void Funcion4() { EjemploPorReferencia[] referencias = new EjemploPorReferencia[10]; foreach (EjemploPorReferencia referencia in referencias) { //Trabajo directamente con el objeto referencia en lugar de con un indice. } }
Homogeneidad de arquitecturas
En .NET, en cualquier sistema, e independientemente si la aplicación es de 64 o
32 bits, el tamaño de los enteros y demás valores, siempre tiene el mismo tamaño,
y las clases de tipo string son del mismo tipo (Unicode). El efecto es nos
podemos abstraer del tipo de dato y el tamaño de esto, y dejar esos aspectos de
bajo nivel al compilador, a diferencia de C, en el que los tenemos que tener en
cuenta, la mayoría de las veces, para evitar futuros problemas en nuestra aplicación
al momento de cambiar de arquitectura o interconectar distintos sistemas entre sí
Interrupción del flujo de ejecución en caso de errores.
Cuando tenemos una acceso de memoria incorrecto en C/C++, muchas veces no somos
notificados por ello (porque no hay mecanismos para detectar estas circunstancias),
sin embargo en .NET existen una seria de excepciones que controlan explícitamente
este tipo de errores e interrumpen el flujo ejecución del programa, para que esta
condición sea detectada, y corregida adecuadamente.
Algunas excepciones relacionadas con el control de acceso a la memoria son las siguientes:
- InvalidCastException: No se puede realizar un cast, entre un tipo de objeto y otro.
- IndexOutOfRangeException: Se está intentando a acceder a un índice inexistente dentro de un array.
- NullReferenceException: Se está intentado usar un objeto que no se ha inicializado (con el comando new).
- AccessViolationException: Se está intentado acceder a una posición de memoria invalida (ocurre cuando estamos conectando sistema de .NET con sistema en otras tecnologías).
Uso de punteros en C#
El uso de punteros tradicionales como en C/C++ es posible realizarlo también en
C#, aunque es difícil encontrar situaciones donde esto será realmente necesario,
para realizarlo se debe configurar los segmentos de código de la aplicación como
inseguros (unsafe) del siguiente modo:
class UnsafeTest { // Unsafe method: takes pointer to int: unsafe static void SquarePtrParam(int* p) { *p *= *p; } unsafe static void Main() { int i = 5; // Unsafe method: uses address-of operator (&): SquarePtrParam(&i); Console.WriteLine(i); } } // Output: 25
En estos contexto no se realizan las validaciones por defecto que se hacen en C#
"seguro", por eso no es tan común el uso de "punteros" tradicionales en C#, y en
principio debiera evitarse, su existencia parece deberse exclusivamente a interacción
de bajo nivel con componentes creados en C o C++.
Me ha gustado mucho la ayuda que ofreces porque son cosas que queremos mejorar en nuestra empresa, gracias José Luis
ResponderEliminarGracias a ustedes, ¡Mucho éxito en sus proyectos!
Eliminar