inicio mail me! sindicaci;ón

Reserva de memoria en Linux

Ya sabemos que en C las reservas de memoria se hacen mediante la llamada al sistema malloc() y similares, pero cómo opera realmente el kernel es algo que puede no estar del todo claro.

Mediante una prueba de concepto voy a ir mostrando cómo se comporta un kernel Linux estándar, sin configuraciones especiales. Me voy a basar en el concepto de Tamaño del Conjunto Residente (Resident Set Size, RSS).

El Conjunto Residente en el conjunto de páginas de nuestro proceso que se encuentran en memoria física, que será igual o inferior al conjunto de páginas de nuestro proceso (cada página o bien está en memoria física, o bien en el archivo de intercambio).
Hay que recordar que para que puede accederse a una información o ejecutarse código, debe estar en memoria física, y que los algoritmos de gestión de memoria del S.O. tienden, cuando necesitan localizar un nuevo hueco de memoria libre, a enviar las páginas menos usadas al espacio de intercambio.

Inicialmente tenemos un programa que arranca:

#include
#include
#include
#include

void mi_pause(const char *str);

int main(int arg, char **argv)
{
int pagesize=sysconf(_SC_PAGE_SIZE);
int num_pag=10000,i;
char *buffer;
time_t t0;
short d_t=30;
float d_p=0.1;

/*Sin reservar memoria*/
mi_pause("El RSS deberia ser minimo");

Hasta aquí, si vemos su RSS:

pablo@linux-rt:# ps ax -o rss,ucmd | grep mem
352 mem

Hasta este punto, este programa ha sido cargado en memoria por el cargador del sistema operativo, junto con las librerías requeridas si es que no estaban ya cargadas.

Así que el proceso tiene asignadas al menos una serie de páginas donde está el código del programa y otras páginas donde está la pila (el kernel Linux por defecto asigna una pila de 8K a los procesos, y en la arquitectura i386 las páginas son de 4K, luego dos páginas para la pila). Ahora mismo desconozco si tendrá más secciones, aparte de código y pila, pero todas estas páginas suman 352k, tal y como nos indica ps.

Se sigue ejecutando el código:

/*Tras reservar memoria*/
buffer=(char *)malloc(num_pag*pagesize);
if(!buffer) {
perror("Error en malloc()");
exit(EXIT_FAILURE);
}
mi_pause("El RSS deberia seguir siendo minimo");

Ahora reservamos memoria, 10000 páginas en el heap (malloc() reserva en el heap o montículo, que es una tercera sección de memoria). En i386 eso son 40000k. Aparentemente, cabe esperar que el RSS haya subido de 352k que teníamos antes a 40352k, aproximadamente:

pablo@linux-rt:# ps ax -o rss,ucmd | grep mem
388 mem

Pues no, tenemos tan sólo 36k más, 9 páginas. Y las otras 9991, ¿dónde andan?

El comportamiento por defecto del kernel Linux es proporcionar las páginas bajo demanda, es decir, que mientras no vayas a usar la página, ¿para qué molestarse en hacer que esté disponible? (esto se llama deferred page allocation, y pareceser que AIX también disponía de un sistema como éste)

Si tú solicitas –malloc()– la memoria y no has superado el límite que tiene establecido tu usuario, etc., el sistema te dice que sí, que vale, que te da la memoria, pero en realidad aún no ha ido a buscarte una página libre.

Cuando por fin accedes a esa página que es legítimamente tuya, es cuando el S.O. se molesta en recorrer toda la lista de páginas que tiene, encontrar una libre –si la hay, y si no, ya se sabe: elegir una candidata y mandarla al espacio de intercambio para dejar un hueco libre–, marcarla como tuya, y devolver el control a tu proceso.

O resumidamente: que hasta que no accedes a las páginas no son tuyas del todo, y por tanto no pertenecen a tu conjunto residente.

Ventajas:

  • Ahorro de tiempo ante procesos que reservan mucho y usan poco.
  • Ahorro de tiempo en el arranque de un proceso (que es donde se suele reservar memoria), al contrario que durante su ejecución (donde se usa esa memoria y hay que localizarla, si es que llega a usarse).
  • Permite aprovechar al máximo el sistema, a expensas de tener una menor estabilidad. En sistemas de misión crítica esto puede no ser deseable, y de hecho creo que hay una opcion del kernel para desactivar este comportamiento.

Voy a tratar de explicar este último punto con el siguiente escenario:
Sea un sistema con 64M de RAM (y nada más de memoria), si el proceso A reserva 50M (aunque sólo usa 20M de esas 50M), y a continuación el proceso B reserva 30M y usa enteros esos 30M.

En total ambos procesos tratan de reservar 80M, de las cuales la máquina no dispone, así que en principio cabe pensar que no se pueden ejecutar a la vez ambos procesos.

En cambio, con el enfoque que hemos visto, el kernel le localiza 20M al proceso A (aunque le informe de que le ha reservado 50M) y 30M al proceso B, dando un total de 50M localizadas (abarcables por la máquina).

Si ahora el proceso A que sólo usaba 20M y que está conviviendo con B, poco a poco empieza a aumentar su consumo de memoria hasta usar por entero esos 50M, ¿qué ocurre? Pues sencillamente que no hay sitio para los dos procesos (situación Out-of-Memory) y al menos uno debe morir.

¿Quién se encarga de hacer justicia? El OOM Killer ¿Bajo qué política? Eso es más complicado, y en el enlace anterior se muestra un fragmento de código que explica un poco los fundamentos, aunque hay quien discrepa con este sistema.

¿Qué ocurre si mientras A engorda, a B le da tiempo a acabar? Pues todos felices y contentos y hemos podido ejecutar dos procesos que de otra manera no podríamos.

Sigamos ejecutando:

/*Tras acceder a la memoria*/
for(i=0;i
buffer[i*pagesize]=0;
}
mi_pause("Ahora deberia ser maximo");

El código ahora se dedica a poner el primer byte de cada página reservada a 0. Ahora sí que accedemos a las páginas que habíamos solicitado con malloc().

El resultado es que ahora nuestro RSS engorda estrepitosamente:

pablo@linux-rt:# ps ax -o rss,ucmd | grep mem
40384 mem

Ahora sí que hemos hecho la reserva efectiva.

Sigamos ejecutando:

/*Tras acceder sólo al 10% de las páginas durante 30s. mientras otra aplicación accede a mucha memoria*/
printf("Ahora se esta accediendo al %f%% de las paginas reservadas\n",d_p);
printf("– Comprueba el RSS mientras se accede (%hds.) –\n",d_t);
printf("– Correr paralelamente un devorador de memoria ayudara –\n");
t0=time(NULL);
while(time(NULL)
for(i=0;i
buffer[i*pagesize]=0;
}
}

Nos dedicamos, durante 30 segundos, a acceder al primer 10% de las páginas que reservamos, es decir, las primeras 1000 páginas.

¿Cuál es el objetivo de esto? Que si ahora otros procesos requieren grandes cantidades de memoria, podamos ver cómo el S.O. nos quita páginas del conjunto residente y las vaya enviando al espacio de intercambio.

El S.O. va a quitar primero las menos utilizadas, que son ese 90% que no estamos accediendo. Si los otros procesos no requieren demasiada memoria, lo que nos quite probablemente no llegue al 90%.
Si requieren muchísima, puede superarlo, dando lugar a que constantemente haya que estar metiendo y sacando páginas del espacio de intercambio, ya que parte de las que se han metido sí se usan con frecuencia (hiperpaginación o thrashing).

Para comer memoria he hecho devorador.c, que se reserva (y accede durante 20 segundos) 200M de memoria (estoy ejecutando todo esto en una máquina virtual con 256M de RAM):

#include
#include
#include
#include

int main(int argc, char **argv)
{
size_t size=200*1024*1024, offs;
char *buffer;
unsigned short pagesize=sysconf(_SC_PAGE_SIZE);
time_t t0, d_t=20;

buffer=(char *)malloc(size);
if(!buffer){
perror("Error en malloc()");
exit(EXIT_FAILURE);
}

offs=0;
t0=time(NULL);
while(time(NULL)
buffer[offs]=0;
offs=(offs+pagesize)%size;
}

free(buffer);

exit(EXIT_SUCCESS);
}

Si paralelamente a la ejecución del código anterior, ejecutamos el devorador en otra terminal, podemos ver como el RSS decae:

pablo@linux-rt:# ps ax -o rss,ucmd | grep mem
23268 mem
pablo@linux-rt:# ps ax -o rss,ucmd | grep mem
17484 mem

No llega hasta el 10% de los 40000k que (más o menos) teníamos, pero se ve cómo la mitad de las páginas se han ido al espacio de intercambio.

/*Tras liberar memoria*/
free(buffer);
mi_pause("Ahora deberia volver a ser minimo");

exit(EXIT_SUCCESS);
}

void mi_pause(const char *str)
{
if(str)
printf("%s\n",str);

printf("– Comprueba el RSS y dale a intro –\n");
getchar();
}

Y ya para acabar liberamos la memoria reservada, visualizamos cómo disminuye el RSS:

pablo@linux-rt:# ps ax -o rss,ucmd | grep mem
188 mem

y finalizamos el programa.

Espero que con esto se hayan refrescado o dado a conocer algunos conceptos sobre gestión de memoria de los sistemas operativos, y más particularmente sobre el deferred page allocation de Linux, y sus ventajas e inconvenientes.

Ficheros: mem.c y devorador.c

Averiguar el tamaño de página

Una entrada breve, pero que creo que puede ser útil, ya que hasta hace unos años estuve mucho tiempo sin saber cómo hacer esto.

A veces, en ciertas aplicaciones, es útil e incluso necesario saber el tamaño de página que usa la arquitectura sobre la que estamos trabajando. Funciones como mmap y mlock en ocasiones requieren conocer este valor.

Para ello existe la llamada al sistema sysconf (definida en unistd.h). Esta llamada recibe un argumento que indica qué se quiere saber acerca del sistema, por ejemplo, sysconf(_SC_PAGE_SIZE) nos devuelve el tamaño de página. Puede fallar con ciertos argumentos, en cuyo caso devolverá -1 y fijará errno a EINVAL.

La lista de atributos del sistema que se pueden conocer está resumida en la correspondiente página del manual para esta llamada al sistema, desglosada por los estándares a los que pertenecen. Por ejemplo, nos indica que _SC_PAGE_SIZE está soportada por la versión 2 de la Single Unix Specification, así que es posible que en sistemas que no sigan SUSv2 este atributo tenga otro nombre, o haya que leerlo de otra manera.

Como ejemplo plasmo aquí un cacho de código que obtiene este valor, y su ejecución en dos arquitecturas diferentes:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char **argv)
{

  /*Definido en SUSv2*/
  printf("sysconf(_SC_PAGE_SIZE)=%ld\n",
         sysconf(_SC_PAGE_SIZE));

  exit(EXIT_SUCCESS);
}

Compilando y ejecutándolo en una UltraSPARC-250:

-bash-3.00$ ./sysconf-pagesize
sysconf(_SC_PAGE_SIZE)=8192
-bash-3.00$ uname -a
SunOS <xxx> 5.10 Generic_120011-12 sun4u sparc SUNW,Ultra-250

vemos que las páginas son de 8K.

En cambio, en un i686 (realmente un i686 virtualizado sobre vmware, pero eso da igual porque la arquitectura por supuesto no cambia):

-bash-3.00$ ./sysconf-pagesize
sysconf(_SC_PAGE_SIZE)=4096
-bash-3.00$ uname -a
Linux <xxx> 2.6.26-1-686 #1 SMP Sat Jan 10 18:29:31 UTC 2009 i686 GNU/Linux

tenemos páginas de 4K (lo esperable, ¿no?).

Recuperando archivos recién borrados Y que están siendo usados

Teniendo que hacer un

mplayer -vo xv -ao alsa /descargas/Temp/014.part

resulta que hice un

rm /descargas/Temp/014.part

y como en la consola no hay papelera de reciclaje, pues… ¡cucú!

Así que me puse a buscar como loco y al final encontré este documento.

Hay que remarcar que tal fichero estaba abierto por el aMule mientras ocurría todo esto, y ha sido gracias a eso que lo he podido recuperar.

Abreviando, el método consiste en buscar qué PID tiene el programa que está tirando del fichero:

pablo@golgi:~$ ps aux | grep amule | grep -v grep

pablo 4165 1.6 3.6 114592 37976 ? Sl May25 214:10 amule

Y a continuación ir a su correspondiente entrada en /proc. Si el PID es el 4165, su entrada será /proc/4165/, y en /proc/4165/fd/ tendremos enlaces a los descriptores de fichero abiertos.
Con lsof +L1 podemos ver qué inodos carecen de referencias, y además qué descriptor de fichero tienen (si hay alguno). En este caso la columna correspondiente de la salida de lsof nos dice que tiene el descriptor 11, ¡¡así que en /proc/4165/fd/11 tenemos el fichero!! Ahora hacemos…

cp /proc/4165/fd/11 /descargas/Temp/014.part

Y todo como si nada (por cierto, el sistema de ficheros era ext3, pero eso debería de dar igual).

Ahora la explicación larga:

La información en el disco duro se almacena en sectores, y cada sector se identifica secuencialmente por un número.

Si queremos guardar un fichero, el sistema operativo busca una serie de sectores libres donde quepa el fichero y a continuación creará un inodo y copia el contenido del fichero a esos sectores libres. Ese inodo (nodo índice) es un índice de qué sectores ocupa el fichero, además de guardar qué permisos tiene el fichero, tamaño, tipo de fichero (enlace duro, enlace simbólico, fichero normal, directorio, etc.), propietarios, etc. Otro de los datos que guarda es un contador de referencias.

Un directorio es un fichero que guarda los nombres de los ficheros que hay dentro suyo y una referencia a sus respectivos inodos.

Cuando borramos un fichero lo primero que se hace es desvincular el nombre del fichero del inodo, es decir, borrar la entrada correspondiente del fichero de directorio. Así nadie podrá acceder a ese fichero, ya que cuando hacemos referencia al nombre del fichero, éste no aparece en el fichero de directorio. Además de borrar el nombre se disminuye en uno el contador de referencias del inodo.

Sin embargo, no sólo hay que desvincular el nombre, si no que también hay que borrar el inodo. Mientras el fichero está siendo usado por algún proceso el inodo permanecerá ahí, pero en el momento en que ese proceso cierre el fichero, el sistema operativo borrará el inodo por tener a cero el contador de referencias, y además todos los sectores usados por el contenido del fichero los marcará como libres.

En el caso anterior al haber hecho un rm teníamos el nombre desvinculado, pero como el proceso amule estaba tirando del fichero aún teníamos el inodo en disco y los sectores del fichero intactos.

Gracias a /proc y lsof pude localizar el descriptor de fichero que se refería a ese inodo, y al copiarlo lo que se hace es meterlo en sectores nuevos, indexados por un nuevo inodo, que tendrá su propia entrada en el fichero del directorio /descargas/Temp/ (i.e., un nombre).

Otra de las opciones que traté de hacer era, mediante debugfs, tratar de crear una entrada en el fichero de directorio hacia ese inodo, pero no conseguí que tragara con el número de inodo que le di (y que saqué mediante lsof)

Spinlocks

Un spinlock es un mecanismo para controlar bloqueos. Quien haya echado un vistazo al código fuente del kernel Linux lo habrá visto sin tener que profundizar mucho.

Básicamente es un procedimiento que, mediante espera ocupada, permanece inactivo hasta que se libera el bloqueo, en cuyo caso pasa a tomarlo.

Realmente es la forma más sencilla de acceder a un bloqueo, con el único inconveniente de que está consumiendo CPU a cambio de no hacer nada más que esperar:

mientras haya bloqueo { repetir } adquiere bloqueo

Lo extraño aquí es que se utilice espera ocupada, ya que siempre se trata de evitar este método. La idea es que los spinlocks van a ser muy breves, la espera ocupada va a ser tan breve y fugaz, que sería muy laborioso para el procesador y el sistema operativo andarse con procedimientos más avanzados. No merecería la pena por unos pocos nanosegundos de espera ocupada andar montando todo un circo con semáforos, o señales o algún otro mecanismo para sincronismo.

Desde luego, si van a ser más de unos nanosegundos no es nada recomendable que los hilos pierdan su cuota de tiempo de proceso iterando sin hacer nada productivo.

Otro problema potencial es que desde que se sale del bucle hasta que se apropia del bloqueo puede surgir una condición de carrera. Para evitar los problemas que ello acarrea se usarán instrucciones atómicas en ensamblador que comprueben el bloqueo y lo adquieran, pero obviamente, esto requiere soporte por parte de la arquitectura. Por supuesto, hay más soluciones, como el algoritmo de Peterson y el de Dekker.

Bibliografía:

  • Entrada en la Wikipedia EN
  • El fichero /Documentation/spinlocks.txt en el código fuente de Linux contiene información acerca de los spinlocks y su implementación en el kernel linux.