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:
$LOCK_FILE
.Veamos cómo mejorarlo, paso a paso.
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.
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.
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.
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
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
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
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
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
$$
es una variable especial de la shell que contiene el PID del script que se está ejecutando. Si guardamos este PID en $LOCK_FILE
, podemos agregar un paso a nuestra verificación: si $LOCK_FILE
existe, probamos que el PID guardado en él corresponde a un proceso activo:1 echo $$ >$LOCK_FILE
2 if kill -0 $(cat $LOCK_FILE); then
3 # el proceso anterior sigue activo
4 fi
kill -0 PID
sirve para verificar que PID corresponde a un proceso activo. Es más confiable que usar grep
en la salida de ps
.$LOCK_FILE
$LOCK_FILE
y un proceso con el PID que guardamos en $LOCK_FILE
, y concluye erróneamente que ya hay otra instancia del script corriendops
, pidof -x
, o pgrep
. No es tan confiable, pero puede funcionar bien en muchos casos. Es importante, si usamos este método, filtrar entradas que correspondan a procesos en estado zombie o defunct.sleep
dentro, podemos “encolar” diferentes instancias del script, para que cada una se active cuando finalice la anterior (el orden en el que corren no está garantizado si solamente usamos while
+ sleep
)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?
[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