inicio mail me! sindicaci;ón

Archive for C

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?).

¿Existe un PID?

¿Cómo saber si existe un proceso con un PID determinado? ¿existe algún proceso con el pid 16547?

Se me ocurrieron diferentes formas, algunas poco portables, en otras presentaba dudas, así que pregunté en varias listas de correo.

Y el resultado es este programita en C donde se presentan dos formas programadas (y que funcionan) y comento más o menos cómo funcionan, así como la gente que me ha dado ideas y qué ideas me han dado: existe_pid.c;

Por cierto, gracias a todos los que colaborásteis con este tema.

Mandatory locking

Me ha venido eso a la cabeza, el mandatory locking. Y como no tengo una pila entrañable de apuntes por estudiar, ejercicios y prácticas por hacer, y libros que leer, y además me sobra el tiempo, me he propuesto revisitar todo el panorama de bloqueos de ficheros. Por supuesto, esto no es la biblia de los bloqueos de ficheros, así que de lo que leas créete la mitad, de lo que no leas no te creas nada :P

1. ¿Por qué debe un programa bloquear un fichero? Esencialmente para poder acceder a él en entornos con concurrencia asegurándose que tras el acceso, especialmente si es de escritura el fichero guarda un estado coherente.

Os ilustraré con un ejemplo: supongamos que estamos haciendo un programa para una librería (de las que venden libros, no una .dll, ni un .so) que gestiona pedidos de libros. Tenemos una base de datos donde se almacena el inventario de libros tal que así:

Practical Common Lisp:Peter Seibel:500:Apress:1590592395:34
The Pragmatic Programmer, From Journeyman to Master:Andrew Hunt, David Thomas:352:Addison-Wesley Professional:020161622X:52

Como vemos cada entrada (o tupla que se diría en el modelo relacional si esto fuera una base de datos, que dista de serlo) tiene varios campos (atributos): el título del libro, los autores, el número de páginas, la editorial, el ISBN y por último el número de copias que tenemos en stock. En realidad una base de datos de una librería sería mucho más compleja, con más atributos (peso y dimensiones, para envíos por correo, tipo de tapas, etc.) y varias tablas interrelacionadas entre sí.

Hasta aquí todo bien, llega un cliente, compra una copia del Practical Common Lisp, y lo obvio: nuestro programa decrementa el número de copias que nos quedan del libro (el último campo) en uno, tras asegurar de que quedaban libros antes de cobrarle al cliente, e incluso avisando a los proveedores de que traigan más si ve que quedan pocos.

Paso a paso nuestro programa lo que hace es:

1.Leer el registro para Practical Common Lisp

2. ¿nº copias > 0? no=>dar mensaje de error y se acabó

3. ¿nº copias < lim_inferior_copias? si=> poner en la cola de avisos uno para el proveedor de ese libro, que nos traiga más, que se vende bien.

4. Decrementar en 1 el nº de copias (lo que viene a ser vender una copia)
5. Guardar datos en la base de datos

Ahora se repite la operación, pero esta vez con dos clientes que compran el mismo libro simultáneamente. Cada cliente manda ejecutar una instancia del programa, la instancia A y la instancia B, y cada instancia sigue uno de esos 5 pasos citados anteriormente:

A1. Leer el registro para Practical Common Lisp. Leerá “Practical Common Lisp:Peter Seibel:500:Apress:1590592395:33″, así que pensará que quedan 33 copias (la 34 se la llevó el del ejemplo anterior)
B1. Leer el registro para Practical Common Lisp. Esta operación se hace a la vez que la anterior y resultará en leer “Practical Common Lisp:Peter Seibel:500:Apress:1590592395:33″, por lo que para la instancia B del programa vendedor también quedan 33 libros, lo cual es correcto, ¿no?

Pasos A2, B2, A3 y B3: ambas instancias las pasan con éxito, no tienen relevancia para el ejemplo.

A4: Decrementar en uno el número de copias que quedan: ahora quedan 32

B4: Ídem que A4, lo único es que el número de copias que la instancia B tiene en mente es la que leyó en el paso B1, 33 por tanto. Así que decrementando nos quedamos también en 32, sin embargo hemos vendido dos libros a sendos clientes.

A5 y B5: Guardar los nuevos datos en la base de datos.

El resultado es que después de esta operación hemos vendido 2 libros y en la base de datos parece que sólo hemos vendido uno. Tras esta operación la base de datos no es consistente.

¿Cuál ha sido el problema? Ha sido que esas dos operaciones no se podían hacer simultáneamente, sino secuencialmente: primero deberíamos hacer una y luego la otra. Para ello sigamos este nuevo esquema:

0. ¿Base de datos desbloqueada? no => esperas un ratitín y vuelves a comprobar. Si después de haber comprobado 3 veces no hay tu tía, das un mensaje de error.
1. Leer registro de la base de datos Y bloquear la base de datos, que nadie más pueda acceder a ella, ni siquiera para leer.

2 y 3. Comprobar si quedan copias o si hay que reponer libros

4. Decrementar el número de unidades que quedan

5. Guardar nuevo registro y desbloquear la base de datos

Vamos, que la solución son los bloqueos: permitir bloquear y desbloquear la base de datos y comprobar si está bloqueada o no.

2. Varios sistemas para desarrollar bloqueos
2.1. Ficheros de bloqueo

Inicialmente los sistemas Unix no proveían un método para llevar a cabo los bloqueos, así que alguien se rascó su propia comezón y se le ocurrió que podría crear un fichero que indicara si otro fichero (la base de datos) estaba en uso o no por su mera presencia.

Dicho de otra forma, para comprobar si la base de datos está bloqueada yo comprobaría si puedo acceder al fichero de bloqueo, que llamaremos /var/lock/vendedor.lock, p.ej.:

int comprueba_bloqueo(const char *fn)
{
FILE *fd;

if( (fd=fopen(fn,”r”)) ==NULL )
return 0;

fclose(fd);
return 1;
}

Así que tratamos de acceder al fichero que nos pasan como parámetro para lectura, ¿que falla? pues eso es que o bien no existe (no hay bloqueo) o bien no tenemos permisos para acceder (vigilad este último detalle, que a lo mejor os da quebraderos de cabeza), y en ese caso nos devolvería 0. En caso de que si exista, y por tanto haya bloqueo, devolvería 1 (previo cierre del fichero).

En PHP uno se puede apañar con la función file_exists. En fin, cada lenguaje tendrá su método, aquí no quiero centrarme en un lenguaje ni en un sistema en concreto (aunque lo acabaré haciendo), sino que prefiero dar un vistazo general.

Hasta aquí hemos llegado a comprobar si está o no bloqueado. ¿Y qué hacer después? Vamos a mejorar un poco lo anterior para el supuesto caso de que el fichero esté bloqueado:

int
main(int argc, char **argv)
{
int bloqueado;
int n_prueba;

printf(“Fichero de bloqueo: %s\n”,LOCK_FILE);
n_prueba=0;
while( (bloqueado=comprueba_bloqueo(LOCK_FILE)) && n_prueba++
usleep(PAUSA);
/*comprobamos hasta INTENTOS veces si está bloqueado o no, esperando PAUSA microsegundos entre cada intento */
if(bloqueado) {
printf(“La base de datos permanece bloqueada (%d intentos, %.0f segundos)\n”,INTENTOS,INTENTOS*PAUSA/1000000.0);
exit(EXIT_FAILURE);
}
/*si sigue bloqueado nos largamos*/
printf(“Fichero bloqueado: %s\n”,bloqueado?”si”:”no”);

exit(EXIT_SUCCESS);
}

Realmente no queremos que nos imprima esas trazas para saber cómo van las cosas, sino que queremos que monte un bloqueo en caso de que esté desbloqueada, y posteriormente, una vez que acabe, que lo desmonte.

Para bloquear, simplemente tenemos que crear el fichero de bloqueo:

int bloquea(const char *fn)
{
FILE *fd;

if( (fd=fopen(fn,”w”)) ==NULL)
return -1;

fclose(fd);
return 1;
}

si se creó devolverá 1, en caso de error devuelve -1.

Y para desbloquear, simplemente borraremos el fichero:

int desbloquea(const char *fn)
{
if(unlink(fn))
return 0;
else
return 1;
}

Lo que devolverá 0 si hubo fallos, o 1 si todo fue bien.

Estos son los principios básicos de los ficheros de bloqueo. Sin embargo, existe aún un pequeño problema: qué ocurre si una vez que hemos bloqueado el fichero, y antes de desbloquearlo, nuestro programa falla, o es matado, o se va la luz. Simplemente que el fichero de bloqueo se quedará hasta que alguien manualmente lo borre, por lo que la base de datos seguirá bloqueada hasta entonces. Es la pega de este método.

Estas funciones presentan posbiles mejoras, p.ej. yo debería comprobar que existe un fichero de bloqueo antes de tratar quitarlo, y además en vez de ver por un lado si no existe fichero de bloqueo y luego crearlo, sería más práctico que la función bloquea() se encargara ella sola de hacer la comprobación, y por ejemplo, si se sobrepasa el número de intentos límite borrar el fichero de bloqueo (dando por supuesto que el proceso que lo creó, y responsable de borrarlo ha muerto), crear el fichero con O_EXCL, guardar el PID en el fichero, y leerlo, etc.

2.2 Advisory y mandatory lockings: el sistema operativo soporta bloqueos.
Precisamente para evitar el inconveniente de que se mantuviera el bloqueo una vez el proceso se ha estrellado, System V y BSD implementaron sendos métodos: System V mediante fcntl() y BSD mediante una llamada específica al sistema de nombre flock(). En estos casos es el sistema operativo el que se hace responsable de que el bloqueo sea consistente. La llamada lockf() en el fondo es más o menos un alias para fcntl().
Para ambas opciones existen dos tipos de bloqueos, los exclusivos que sólo permiten un proceso por fichero, y los compartidos, que permiten varios. En el caso concreto de fcntl() uno puede bloquear diferentes regiones del fichero, p.ej. del byte 1 al 512 por un proceso, y del 726 al 1024 por otro proceso.

El bloqueo que proveen estas dos llamadas es lo que se viene llamando advisory locking. Cuando un proceso trata de escribir se bloquea el fichero (o el área del fichero) para los demás procesos. ¿Y si simultáneamente se trata de escribir por parte de otro proceso? Pues el otro proceso tratará de bloquear el fichero, pero si no lo consigue nada nos asegura que no haga una llamada a write(), lo que nos dará problemas. El advisory locking (o unenforced locking) requiere cooperación por parte de ambos procesos.

Para resolver este problema se desarrolló el mandatory locking (o enforced locking) por parte de los diseñadores de System V, que es un atributo que tiene el archivo. Si un fichero tiene activado este atributo, automáticamente en cada llamada a write() o read() se ejecutará un fcntl() previo que bloqueará el área de fichero que se va a leer y aún no está bloqueada, y posteriormente otro fcntl() desbloqueante. Si antes de haber hecho las llamadas hubiéramos ejecutado un fcntl() sobre todo el fichero, entonces no se ejecutaría ningún fcntl() implícito.

Todo aquel que tiene permiso para abrir el fichero, tiene permiso para aplicarle un mandatory locking, así que hay que tener cuidado a ver a quién se le da permisos, por que si no le da la gana desbloquear nos estará ocasionando un ataque de denegación de servicio.

La forma de representar el atributo del mandatory locking es activando el bit de ejecución SGID y desactivando el permiso de ejecución para el grupo. Por ser una combinación sin significado se eligió para esta tarea. Esto es válido para SunOS, Solaris, HP-UX y Linux, aunque cada uno lo implementa a su manera y con sus detalles.

Los bloqueos obligatorios (mandatory) no son POSIX.

En Linux los bloqueos pueden hacerse de lectura o de escritura, entre otros tipos. La llamada mmap() también es alterada según el fichero a mapear esté bloqueado o no. Ved la página del manual de fcntl().
Por supuesto, cada caso es un problema aparte ya que no es lo mismo usar un sistema de ficheros que otro, y menos si uno de ellos es por red, como p.ej. NFS, donde no funcionan los flock(). También hay que tener en cuenta el sistema operativo y su versión, y quizá la arquitectura.
En Windows las cosas son ligeramente distintas, ya que todos los bloqueos son enforced, y además el modo de acceder a un fichero varía. Para más información, sobre todo de Unix, leed la bibliografía.

Bibliografía:

Haré algo de código en C para ver qué tal se me dan los bloqueos, si se me resisten o no. Lo postearé por aquí