martes, 7 de abril de 2015

Gestión de memoria en sistemas .NET

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++.

2 comentarios:

  1. Me ha gustado mucho la ayuda que ofreces porque son cosas que queremos mejorar en nuestra empresa, gracias José Luis

    ResponderEliminar