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.
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.
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:
Toda porción reservada de forma dinámica (con
malloc
, calloc
o realloc
) debe
ser liberada mediante una llamada a free
.
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.
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);
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.
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.