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í