Universidad Carlos III de Madrid

Grado en Ing. Telemática/Sist. Audiovisuales/Sist. de Comunicaciones

Arquitectura de Sistemas

Septiembre 2012 - Enero 2013

10. Anomalías en la gestión de memoria en C

La gestión de memoria en C se describe como explícita porque tanto las operaciones de reserva como las de liberación han de aparecer explícitamente en el código. En otros lenguajes de programación, como por ejemplo Java, el entorno de ejecución se encarga de recuperar aquellas porciones de memoria que ya no se utilizan, liberando al programador de escribir este código. De la gestión explícita de memoria se derivan varias posibles anomalías cuando fragmentos de memoria no se pueden liberar porque se ha perdido cualquier referencia a ellos. Es lo que se conoce como fuga de memoria. Esa porción de memoria permanece reservada pero inaccesible para el resto de la ejecución de un programa.

10.1. La trastienda de la gestión de memoria

Al comenzar la ejecución de un programa, su memoria se divide en tres zonas: la pila, memoria global y el heap. El heap se utiliza para la reserva y liberación de porciones de memoria durante la ejecución del programa. Pero ¿cómo se gestiona esta memoria?

El sistema operativo mantiene una tabla interna en la que apunta qué fragmentos del heap están ocupados y qué punteros se han devuelto como resultado de la petición de reserva. Cuando un programa ejecuta la función malloc para pedir un nuevo fragmento, el sistema busca una porción del tamaño solicitado, si existe devuelve su dirección de comienzo, y se apunta ese bloque como ocupado. De manera análoga, cuando se llama a la función free para liberar un fragmento, el sistema busca en la tabla ese fragmento (que debe estar apuntado previamente como reservado) y libera el espacio para usos futuros. En la siguiente figura se ilustra este funcionamiento.

A la petición de reserva de una porción de 2048 bytes, el gestor de memoria responde con la dirección de un bloque que previamente marca como ocupado. La llamada a free es análoga, pero se recibe una dirección de memoria de un bloque previamente reservado, se busca en la tabla, y si existe, se marca de nuevo como disponible.

Este esquema de gestión de memoria hace que los programas deban ceñirse a unas pautas muy concretas para garantizar el correcto uso de la memoria y sacar el mayor rendimiento a un programa. Por ejemplo, si un programa utiliza una cantidad muy alta de datos dinámicos (esto es, que se almacenan en la memoria solicitada al gestor mediante malloc) y no libera esa memora en cuanto puede, corre el riesgo de agotar la memoria y no terminar la ejecución.

10.2. La fuga de memoria

Una de las anomalías más comunes cuando se gestiona la memoria de forma explícita es lo que se conoce como fuga de memoria. Esta situación ocurre cuando un programa obtiene memoria dinámica, y el valor del puntero que devuelve el sistema, por error, se pierde. En tal caso, ya no es posible invocar a la función free con ese puntero, y la porción de memoria se queda reservada por lo que resta de ejecución. Como ejemplo de fuga de memoria analicemos el siguiente fragmento de código.

char *string;
  string = (char *)malloc(100);
  string = NULL;

La primera línea declara un puntero a carácter. En la segunda se reserva un espacio de 100 bytes. El gestor de memoria devuelve un puntero al comienzo de ese bloque y se almacena en la variable string. En ese momento, la dirección de ese bloque no está almacenada en ningún otro sitio. La línea siguiente asigna el valor NULL al mismo puntero. ¿Qué ha sucedido con la dirección de memoria de la porción que se acaba de reservar? Se ha perdido y no hay forma alguna de recuperarla, porque string era la única copia de ese valor. Como consecuencia, la porción de memoria reservada seguirá marcada como ocupada por el resto de ejecución del programa. La memoria se ha fugado.

La principal consecuencia de una fuga de memoria, por tanto, es que esa porción no se puede utilizar, se ha perdido. Esto es equivalente a que la memoria disponible para la ejecución del programa se haya reducido. Los efectos de una fuga de memoria dependen del lugar en el código en el que se produzca. Si en un programa se fuga una única porción de unos cuantos bytes, es posible que su efecto pase desapercibido. Sin embargo, si la pérdida de memoria se produce en un lugar que se ejecuta un número muy elevado de veces, el efecto puede ser mucho más notorio. Fíjate en el siguiente fragmento de programa:

#define MILLION 1000000

char *table[MILLION];
for (i = 0; i < MILLION; i++) {
  table[i] = (char *)malloc(100);
  table[i] = NULL;
}

La fuga de memoria se produce en un lugar que forma parte de un bucle que se ejecuta un millón de veces. En este bucle se fugan casi 100 Megabytes de memoria.

Las fugas de memoria no se producen en situaciones tan obvias como las descritas anteriormente, sino que aparecen en lugares del código inesperados debidos a despistes en la manipulación de punteros. El problema de las fugas de memoria en C es tan complicado de solventar que han aparecido herramientas especializadas, tanto comerciales como de código libre, especialmente concebidas para analizar un programa y detectar fugas.

Una situación típica de fuga de memoria es cuando se manipulan estructuras de datos encadenadas. En una estructura se almacenan punteros obtenidos mediante llamadas a malloc y en ellos a su vez se almacenan más punteros obtenidos de esta manera. La liberación de la memoria que ocupan estas estructuras de datos ha de programarse con sumo cuidado. El siguiente fragmento de código ilustra este problema.

struct contact_information 
{
  char *name, *lastname;
  int age;
};

struct contact_information *agenda;
int i;

agenda = (struct contact_information *)calloc(100, sizeof(struct contact_information));
for (i = 0; i < 100; i++) {
  agenda[i].name = (char *)malloc(10);
  agenda[i].lastname = (char *)malloc(30);
  agenda.age = 0;
}
free(agenda);

La variable agenda se reserva con espacio suficiente para almacenar 100 estructuras del tipo struct contact_information. En el bucle, los dos primeros campos de cada una de las estructuras se inicializa con dos punteros que se obtienen mediante malloc. Al terminar el bucle, la llamada free(agenda) libera el espacio reservado para la tabla, pero no el que se ha reservado para las cadenas de texto de cada uno de sus elementos. La forma correcta de liberar la estructura es igualmente con un bucle que atraviese la tabla y libere cada campo por separado con una llamada a free.

Las dos reglas a respetar en cualquier programa en C en lo referente a la gestión dinámica de memoria son:

  1. Toda porción reservada de forma dinámica (con malloc, calloc o realloc) debe ser liberada mediante una llamada a free.

  2. Si un programa llega a su última instrucción y tiene bloques de memoria dinámica sin liberar, se considera que el programa es erróneo.

Desafortunadamente, no hay una técnica concreta para evitar las fugas de memoria, pero sí hay herramientas que dado un programa lo analizan para ofrecerte un informe sobre qué memoria se ha fugado (si ha habido alguna). Para darte una idea de la dificultad de este problema, cuando las primeras herramientas de detección de fugas aparecieron, se utilizaron para analizar aplicaciones que se consideraban sólidas y maduras, y para sorpresa de sus diseñadores, se detectaron fugas que hasta el momento ningún programador había detectado.

10.3. Memoria sin inicializar

Otra característica de la gestión dinámica de memoria en C es que la inicialización de la memoria se realiza sólo si así se solicita mediante la llamada a la función calloc. En otras palabras, cuando se reserva una porción de memoria mediante una llamada a malloc, esa porción es visible al programa con su contenido intacto. Es decir, que no se inicializa a ningún valor en particular. Lo más probable es que contenga restos de la información que se ha almacenado previamente.

Este comportamiento está pensado para poder obtener el mayor rendimiento de un programa. A menudo hay porciones de memoria que se solicitan, pero que a continuación se inicializan desde el propio programa a unos valores concretos. En este caso, si malloc inicializase la memoria, se haría esta tarea dos veces, con la consiguiente pérdida de tiempo. Por este motivo, sólo la función calloc realiza esta tarea. Como ejemplo, en la siguiente porción de código se intenta mostrar por pantalla como cadena de texto la basura que haya quedado almacenada en esa zona de memoria.

char *string;
string = (char *)malloc(100);
printf("%s\n", string);

10.4. Sobre-escritura de memoria dinámica

El manejo de arrays en C se hace sin comprobación alguna de que el índice utilizado para acceder a un elemento esté en los límites correctos. De este comportamiento se deriva que los punteros y los arrays son, a efectos del compilador, lo mismo, una dirección de memoria sobre la que se puede utilizar entre corchetes un índice para acceder a un elemento. Este comportamiento se mantiene para el caso de la memoria dinámica, es decir, si se reserva espacio en memoria dinámica para un puntero o un array y en su acceso se rebasa el tamaño de su porción de memoria, la ejecución continua sin ningún tipo de comprobación. El siguiente fragmento de código ilustra esta situación.

struct point_info 
{
  int x;
  int y;
}; 

struct point_info *points;

points = (struct point_info *)malloc(100 * sizeof(struct point_info));
points[356].x = 10;
points[356].y = 20;

Como el índice que se utiliza para el acceso de las dos últimas líneas está fuera de los límites, se está accediendo a una porción del heap que contiene otros datos que pueden estar reservados o no. El efecto es imprevisible, pero el programa no realiza ninguna comprobación.

10.5. Acceso a memoria con un puntero corrupto

Cuando la memoria dinámica se reserva, el sistema marca esa porción como ocupada y por tanto sus datos se mantienen. Sin embargo, cuando la memoria se libera, su contenido ya no está garantizado, y depende del uso interno que de ella haga el sistema operativo.

Esta observación es importante porque la función free que recibe como parámetro un puntero, libera su contenido pero no evita que se pueda volver a acceder a él, en lo que se conoce como un problema de acceso a un puntero corrupto. La siguiente porción de código muestra un ejemplo de este problema.

struct list_element 
{
  int;
  struct list_element *next;
}; 

void destroy(struct list_element *l) 
{
    while (l != NULL) 
    {
      free(l);
      l = l->next;
    }
    return;
}

La línea que avanza por la cadena de punteros l = l ->next accede a una porción de memoria apuntada por l que ha sido liberada previamente, por lo tanto, su contenido no está garantizado y puede que el campo next ya no contenga el dato esperado. Una forma de resolver este problema es copiar ese puntero en un lugar en el que no pase esto, por ejemplo, una variable local.