Blog ElCodiguero
11 Apr 2015 Linux & UNIX

Evitar múltiples ejecuciones de un script

Supongamos que queremos asegurarnos de que solamente una copia de un script se ejecuta en un momento dado. Por ejemplo, tenemos un script programado en cron para correr cada 5 minutos, que realiza una tarea que a veces puede llevar más tiempo que eso. Este es un problema que la mayoría de los lenguajes de programación resuelven con memoria compartida o diferentes clases de mutexes, pero desde la shell de UNIX no tenemos una herramienta similar. Lo único de lo que podemos disponer, en general, es el sistema de archivos [1].

Una solución simple al problema es generar un archivo temporal, que el propio script gestione. Esto es, un archivo que sea creado al arrancar el script, y que sea borrado al finalizar, agregando además que si al iniciar el script el archivo existe, se notifica al usuario y el script termina sin hacer nada. Una solución como esta se implementa fácilmente:

 1  #!/bin/bash
 2  LOCK_FILE=/tmp/script.fulanito.lock
 3 
 4  if [[ ! -e $LOCK_FILE ]]; then
 5      touch $LOCK_FILE
 6 
 7      #contenido del script
 8      rm -f $LOCK_FILE
 9  else
10      echo "Una copia anterior de este script sigue corriendo"
11  fi

Este código funciona bien en situaciones normales, pero tiene varios problemas potenciales:

Veamos cómo mejorarlo, paso a paso.

Capturar señales

Para evitar que el archivo $LOCK_FILE siga existiendo luego de que el script es interrumpido (o termina por una condición de error), lo que debemos hacer es capturar las señales que el sistema operativo puede enviarle, esto es SIGINT (Ctrl-C), SIGQUIT, y SIGTERM [2]. En un script, esto se hace con el comando trap:

trap [código a ejecutar] [señales]

trap indica a la shell que debe ejecutar el código a ejecutar si recibe alguna de las señales. código a ejecutar puede ser una cadena o el nombre de una función. Por ejemplo, en nuestro script podemos usar

trap 'rm $LOCK_FILE; exit $?' SIGTERM SIGINT SIGQUIT

o la pseudo-señal EXIT, que quiere decir "cuando la shell termina", sea por recibir una señal o por terminar de forma normal.

trap 'rm $LOCK_FILE; exit $?' EXIT

El código exit $? en las líneas anteriores hace que el script termine con un código de error si no puede borrar el archivo $LOCK_FILE, nunca está de más comunicar al proceso padre el resultado de nuestros scripts.

Evitar problemas de concurrencia

Si dos copias del script son iniciadas al mismo tiempo, es posible (aunque difícil, estas operaciones duran pocos milisegundos) que las dos copias lleguen al mismo tiempo al código que comprueba que el archivo $LOCK_FILE exista. Si esto sucede, ambas copias del script pueden determinar que el archivo no existe, y continuar su ejecución.

Esta clase de problemas en donde dos programas compiten por un recurso, sin que podamos determinar cuál de ellos lo obtiene, se conocen como race conditions ("condiciones de carrera"). En muchos casos, cuando es posible, los evitamos usando operaciones atómicas [3].

Para este problema en particular, veremos dos variantes.

  1. mkdir

    Con mkdir tenemos una solución al problema, siempre que estemos dispuestos a usar un directorio y no un archivo como $LOCK_FILE. La orden

    mkdir $LOCK_DIR

    tiene dos resultados posibles: si el directorio no existe, será creado, y mkdir saldrá con un código de éxito. Si el directorio existe, mkdir fallará y no habrá cambios en el sistema.

  2. noclobber

    BASH tiene una opción llamada noclobber, que hace que si intentamos redirigir salida (vía >) a un archivo que ya existe, la redirección falle. Basta entonces con escribir algo como:

    set -o noclobber # se puede abreviar como set -C
    : > $LOCK_FILE
    if [[ $? != 0 ]];
        # la redirección falló, el archivo existía previamente
    fi

    Para desactivar noclobber (como hacemos con las opciones de bash que modifican su comportamiento y usamos para una parte específica de nuestro script), debemos añadir una línea conteniendo set +C. Otra opción es utilizar una subshell, de tal manera que la shell que corre nuestro script no se vea afectada por el cambio, y por tanto no sea necesario desactivar nada:

    (set -C; : > $LOCK_FILE) 2> /dev/null
    if [[ $? != 0 ]]; then
        # la redirección falló, el archivo existía
    fi

Con estas mejoras, así es como nuestro script se ve ahora:

 1  #!/bin/bash
 2  LOCK_FILE=/tmp/script.fulanito.lock
 3  trap 'rm $LOCK_FILE; exit $?' EXIT
 4 
 5  ( set -C; : > $LOCK_FILE ) 2>/dev/null
 6  if [[ $? == 0 ]]; then
 7      touch $LOCK_FILE
 8      #contenido del script
 9  else
10      echo "Una copia anterior de este script sigue corriendo"
11  fi

Mejorando aún más

Nuestro script sigue teniendo problemas potenciales, aún tenemos condiciones en las que dejaremos $LOCK_FILE sin borrar, por ejemplo. Pero antes de hacerlo más complejo, quizás sea mejor utilizar una herramienta hecha justamente con este propósito, como solo, flock, o lockfile. Complicar un script es sencillo, y no es difícil que la complejidad se nos vaya de las manos si intentamos cubrir cada una de las posibilidades.

solo

solo funciona abriendo un puerto en la máquina donde se ejecuta, de forma que si una ejecución no termina, el segundo inicio fallará, porque un puerto no puede ser abierto dos veces. Ejemplo de uso:

solo -port=PUERTO COMANDO

flock

flock es parte del paquete util-linux, por lo que es muy probable que esté incluido con el sistema operativo. Puede bloquear un archivo y esperar en caso de que lo encuentre bloqueado, también permite crear diferentes tipos de bloqueo. La limitación más importante de flock es que no funciona con NFS. Ejemplo de uso:

exec 200> $LOCK_FILE
flock -s 200
# código del script

lockfile

lockfile es parte de procmail, lo cual quiere decir que no estará disponible en todas las distribuciones, al menos de forma predefinida. Su uso es más sencillo que el de flock, y también funciona sobre NFS. Uso:

lockfile $LOCK_FILE
# código del script
rm $LOCK_FILE

Ideas, soluciones alternativas y mejoras potenciales

1  echo $$ >$LOCK_FILE
2  if kill -0 $(cat $LOCK_FILE); then
3      # el proceso anterior sigue activo
4  fi

Lectura recomendada

Evidentemente existe mucha información en internet sobre este tema, desde blogs como éste que presentan una solución sencilla, a discusiones donde se plantean soluciones complejas y bastante ingeniosas. Para escribir este artículo combiné mi propio conocimiento con lo que pude encontrar en varias páginas:

Una más, sobre el uso de : como aparece en los ejemplos de arriba: StackOverflow - What Is the Purpose of the colon GNU Bash Builtin?

Notas

[1]Para este propósito nos sirve cualquier clase de sistema que permita tener un estado compartido entre diferentes procesos: podríamos inventar un sistema que dependa de un registro en una base de datos, una clave en un servidor memcache, o algo similar. Si bien estas soluciones pueden tener sus ventajas, son en general vulnerables a problemas de concurrencia, debido a que es requisito indispensable para evitar esta clase de problemas, que la actualización de los valores requeridos sea una operación atómica.
[2]Hay señales que no podemos capturar, por ejemplo SIGKILL (kill -9). Si el script recibe esta señal, termina inmediatamente sin tener oportunidad de eliminar el archivo, aunque usemos el comando trap.
[3]Las operaciones atómicas se llaman así porque son indivisibles, y funcionan de tal manera que, o completan lo que deben hacer, o no hacen nada. mkdir se puede considerar una operación atómica, porque al terminar tenemos que el directorio que debía crear existe, o tenemos un error, pero no es posible tener un estado intermedio "roto" en caso de que ocurran problemas.

Activa Javascript para para cargar los comentarios, basados en DISQUS

El Blog de ElCodiguero funciona sobre Pelican

Inicio | Blog | Acerca de