Publi

Cómo permitir solo la ejecución de una instancia de nuestros scripts

Aunque me gusta que los programas sean flexibles y nos permitan una ejecución libre y sin restricciones. Además, quiero que permitan editar varios archivos a la vez y realizar múltiples conexiones. Hay software o scripts en los que debemos asegurar que sólo se haga una ejecución simultánea ante diferentes condiciones. Por ejemplo, si es un script para realizar copias de seguridad de nuestro sistema, tal vez nos interese que sólo se pueda lanzar una vez, porque sería un problema que se realicen dos copias de seguridad a la vez. Si recopilamos información de salud del sistema, podemos empeorar dicha salud si estamos recopilando varias veces esos datos, podríamos empeorar el rendimiento del sistema. O si estamos realizando una captura de pantalla a través de un script nuestro, puede ser contraproducente lanzar el mismo programa de forma simultánea varias veces.

Así que, en principio, tenemos que tener claro a qué nivel queremos restringir la ejecución de las instancias de nuestro programa. Es decir, ¿sólo permitimos una ejecución en todo el sistema? ¿una por usuario? ¿una por usuario y directorio de trabajo? Nosotros debemos definir la restricción de acuerdo a nuestras necesidades.

Una ejecución a nivel de sistema

Como siempre, hay muchas formas de complicarnos la vida con estos scripts. Ya depende de lo grande que sea nuestro script y la precisión u opciones que necesitemos. Empezaremos con algo muy sencillo, utilizando el comando pidof para buscar el número de instancias con el mismo nombre que el programa actual. Si ejecutamos, por ejemplo:

pidof -x firefox
6249 6178 6068 5979

Este comando nos devolverá los PIDs de los procesos que se llaman firefox. Y, por supuesto, podemos contar el número de comandos que hay (con wc -w). Del mismo modo podemos construir un script en bash que busque entre todos los programas en ejecución los que se llaman como él y decida detener la ejecución si hay más de uno:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/bin/bash

MYPID=$$
MYNAME=$0
PROCS=$(pidof -o "$MYPID" -x "$(basename "$MYNAME")")
echo "Inicio---"
echo $PROCS | wc -w

if [ "$(echo $PROCS | wc -w)" -gt 0 ]; then
     echo "Ya hay una instancia en ejecución. Saliendo"
     exit;
fi

echo "Hago una espera para simular que el script hace cosas, y que tarda un poco."
echo "Nos dará algo de tiempo para iniciar más ejecuciones del mismo script"
sleep 10
echo "El script ha terminado"
echo " ---------------- "

En este caso nuestra ejecución de pidof es más compleja, podíamos hacer la misma petición de antes (sin -o “$MYPID”), pero me gusta cómo queda el código así. En este caso, le estamos diciendo a pidof que excluya de la lista de PIDs que nos devuelve la PID actual. Luego, en la sentencia condicional if miramos si este número es mayor que 0 (-gt 0). Si lo es, ya existe un proceso que se llama como el proceso actual (sacamos el nombre con $(basename $0), así cogemos el nombre del archivo que se ha ejecutado, lo podemos poner a mano, pero así es más genérico). En caso de no incluir -o “$MYPID” en la línea de pidof, deberíamos comparar con (-gt 1), ya que pidof en este caso siempre va a devolver al menos una PID, la del proceso actual.

En este ejemplo vemos que se ha colocado primero el resultado de pidof en una variable. Podríamos hacer la sentencia de golpe (pidof … | wc …), pero algunos intérpretes como Bash, internamente cuando encuentran el pipe (|), hacen un fork del proceso actual, es como si lo duplicaran, de forma que justo en el momento en el que estamos mirando los procesos, hay un proceso más con el mismo nombre. Podríamos, por ejemplo, añadir un número más en la comparación (-gt 1 en lugar de -gt 0; o -gt 2 en lugar de -gt 1), pero al ser un tema de comportamiento interno del intérprete, puede que no funcione bien en futuras versiones o en otros intérpretes.

El principal problema que encontramos en este enfoque es que buscamos procesos por su nombre. Y cualquiera podría crear un proceso con el mismo nombre, tenerlo en ejecución siempre y evitaría que pudiéramos ejecutar el programa (intencionadamente o sin querer). Por otro lado, esto sería a nivel de sistema, es decir, no podríamos establecer restricciones tipo una instancia por usuario. También es lento, bueno, aunque el script se ejecuta muy rápido, no es eficiente. Para obtener las PID de los procesos que se llaman igual, pidof, internamente mira todas las PID de todos los procesos y hace una comparación con una cadena de caracteres, lo que pueden ser varios millones de operaciones. Además, esto puede traernos problemas, ya que la ejecución de pidof tarda un tiempo y si el programa se ejecuta dos veces muy seguidas una de otra, pueden pasar tres cosas (y no podemos controlar el resultado):

  • Que todo vaya bien y funcione perfectamente. Es el caso ideal, pero un fallo es probable.
  • Que los dos procesos detecten al otro proceso y ninguno de los dos se inicie.
  • Que ningún proceso detecte otro proceso y se inicie dos veces.

De todas formas, este es un buen método, y muy rápido de programar, sin complicarnos la vida.

Directorio o fichero de bloqueo

Otra opción es crear un directorio o fichero de bloqueo. Por un lado, es más rápido crear directorios, aunque en los ficheros podemos escribir dentro (vale, dentro de los directorios también podemos escribir, pero tendremos que crear archivos y para leer un valor debemos primero comprobar que un fichero existe y nos eternizaríamos demasiado). Esta primera aproximación la haremos utilizando comandos básicos, y la usaremos cuando no podamos utilizar flock (la siguiente opción), ya sea por tratarse de un sistema empotrado con versiones reducidas del sistema, o tengamos alguna incompatibilidad.
Esta aproximación nos permitirá:

  • Establecer varios tipos de bloqueo: a nivel de sistema, usuario o con el criterio que queramos, cambiando el nombre del directorio.
  • La probabilidad de tener problemas de condición de carrera como antes es menor, pero existe.
  • El bloqueo depende del fichero o directorio y no del nombre de proceso. Por lo que varios procesos pueden utilizar el mismo bloqueo.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/bin/bash

LOCKDIR="/tmp/myprocess_"$(whoami)

if [ -d "$LOCKDIR" ]; then
     echo "Ya hay una instancia en ejecución. Saliendo"
     exit;
fi
trap "rmdir $LOCKDIR" EXIT
mkdir "$LOCKDIR"

echo "Creando un proceso que hace muchas operaciones durante un tiempo"
sleep 10
echo "El script ha terminado"
echo " ---------------- "

En este ejemplo, lo primero que hacemos es crear un nombre de directorio de bloqueo ($LOCKDIR). Si éste existe, no dejamos pasar y cerramos el programa. Si no existe, creamos el directorio y hacemos que a la salida se borre (con trap … EXIT capturamos la salida del programa y ejecutamos un comando). Lo malo es que si matamos el proceso (nosotros o el sistema operativo debido a un fallo), nos vemos obligados a borrar manualmente el directorio /tmp/myprocess_USUARIO para poder volver a ejecutar el programa.

File locks

Llegamos hasta un comando con mucha potencia a nivel de sistema operativo. Con este programa podemos gestionar la ejecución de órdenes a partir de un bloqueo hecho sobre un fichero o directorio que gestionará si podemos o no podemos ejecutar el programa. (Atómico?)

Lo más sencillo es probarlo en dos terminales. Aquí comprobaremos los diversos modos de trabajo:

flock /tmp/bloqueo -c “echo INICIO; sleep 10; echo FIN”

Con esto, estableceremos un bloqueo sobre /tmp/bloqueo (un nombre de archivo que acabamos de inventarnos) y hacemos que cuando esté desbloqueado, escribamos en pantalla “INICIO”, luego esperemos 10 segundos y finalmente escribamos “FIN” en pantalla. Algo muy sencillo, pero en el mundo real, en lugar de una espera de 10 segundos, podremos ejecutar una tarea que sea peligrosa, y no pueda ser hecha por varios procesos a la vez. Pensemos en la lectura/escritura de ficheros o dispositivos, acceso por red a un determinado recurso, etc; o, como es nuestro caso, a la ejecución de un script que no puede ser ejecutado dos veces simultáneas.

El experimento que quiero llevar a cabo es el siguiente. Mientras transcurren los 10 segundos de espera del script anterior, debemos ejecutar, entro terminal lo siguiente:

flock /tmp/bloqueo -c “echo \”HOLA MUNDO\””

De forma que, cuando el recurso /tmp/bloqueo no esté bloqueado, se escriba en pantalla HOLA MUNDO. De esta forma veremos que HOLA MUNDO no se escribirá inmediatamente, sino cuando concluya la espera de 10 segundos del otro terminal. Hemos establecido, una forma de comunicación entre dos procesos diferentes, en forma de bloqueo. Aunque, como vemos ahora, el segundo proceso esperará de forma indefinida a que el primero termine. Y, el primero puede no terminar nunca. ¿Por qué? Bien porque se haya quedado el proceso colgado, o porque hayamos matado de mala manera el proceso, o simplemente el proceso está tardando mucho y debemos cancelar la espera y ponernos a otra cosa. Cosas que pasan en el mundo real. Esto lo podemos hacer de la siguiente manera:

flock -w 3 /tmp/bloqueo -c “echo \”HOLA MUNDO\””

De esta forma, flock esperará durante 3 segundos (también podemos poner fracciones de segundo) antes de darse por vencido y no ejecutar nada. Es decir, si antes de 3 segundos, el recurso se libera, escribiremos HOLA MUNDO; si no, no haremos nada, simplemente saldremos. Además, devolveremos un código de salida, por defecto, el código será 0 si se ha podido coger el recurso y ejecutar la orden (el hola mundo), de lo contrario, devolverá 1. Aunque este valor lo podemos personalizar con -E valor. Para ver el código de salida podemos hacer:

flock -w 3 /tmp/bloqueo -c “echo \”HOLA MUNDO\””
echo $?
1

Ahora, integremos flock en nuestro script. De manera que podamos ejecutar un proceso por usuario del sistema:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/bin/bash

LOCKFILE="/tmp/myprocess_"$(whoami)
MYPID=$$

function exit_error() {
    echo "Ya hay una instancia en ejecución. Saliendo"
    exit 1
}

(
    flock -n 3 || exit_error
    echo "Creando un proceso que hace muchas operaciones durante un tiempo"
    sleep 10
    echo "El script ha terminado"
    echo " ---------------- "
) 3> "$LOCKFILE"

Con el uso de flock no tenemos que preocuparnos por los posibles problemas de desbloqueo ni condiciones de carrera. Es decir, el sistema operativo se encargará de gestionar los recursos bloqueados, en este caso, nuestro fichero de bloqueo (/tmp/myprocess_USUARIO). Si por algún casual el proceso muere, el núcleo del sistema operativo se encargará de eliminar el bloqueo para que otro proceso ocupe su lugar, lo mismo pasa cuando el proceso termina. Además, si dos procesos llaman a flock a la vez, sólo uno obtendrá el bloqueo exclusivo.

En este caso, en lugar de vincular el bloqueo a un archivo directamente, lo hemos vinculado a un descriptor (un descriptor no es más que un número con el que nos referimos al recurso que queremos utilizar, porque para un ordenador es mucho más fácil manejar números). Y dicho descriptor (el 3, porque yo lo valgo, pero que puede ser un número mayor a 2 porque 0, 1 y 2 están pillados; aunque podemos poner el 100 por ejemplo) lo relacionamos con el archivo en cuestión.

Aunque esta sintaxis puede ser un poco liosa y confusa. A mí me gusta más llamar a una función de bloqueo (y quitarme del medio los paréntesis, y el número 3 escrito en varias partes del programa, porque me gustaría más ponerlo en forma de variable pero en la última línea no podremos ponerlo). Así que, vamos a modificar un poco el código:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/bin/bash

LOCKFILE="/tmp/myprocess_"$(whoami)
LOCKFD=150
MYPID=$$

function lock() {
    echo {LOCKFD}>$LOCKFILE
    flock -n $LOCKFD
}

function exit_error() {
    echo "Ya hay una instancia en ejecución. Saliendo"
    exit 1
}

lock || exit_error

echo "Creando un proceso que hace muchas operaciones durante un tiempo"
sleep 10
echo "El script ha terminado"
echo " ---------------- "

La clave aquí está en que la función lock crea el archivo vinculado al descriptor $LOCKFD (¡lo he sacado a una variable aparte!), aunque la forma de hacerlo aquí con echo {LOCKFD}>$LOCKFILE requiere Bash 4.1. Si lo queremos hacer con versiones anteriores debemos recurrir a eval (que personalmente, prefiero evitar). Luego hacemos el flock en el que nos aseguramos de devolver 0 si todo va bien y 1 si algo va mal. De esta forma, la función lock se basta y se sobra para establecer el bloqueo y en el resto del código no nos tenemos que preocupar de nada más.

Detección de scripts sobas

Algo que puede pasar mientras ejecutamos los scripts es que uno tarde demasiado en finalizar. Por ejemplo, lanzamos un script de copia de seguridad, que lo mismo puede estar 4 o 5 horas en ejecución si tenemos muchos datos, o si estamos comprimiendo o codificando nuestras backups. Así que, vamos a incluir en nuestro script un pequeño aviso del tiempo que lleva el otro proceso ejecutándose.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#!/bin/bash

readonly LOCKFILE="/tmp/myprocess_"$(whoami)
LOCKFD=150
readonly MYPID="$$"
readonly MYNAME="$(basename "$0")"

function lock() {
    local PID="$(cat $LOCKFILE)"
    echo {LOCKFD}<>$LOCKFILE

    flock -n $LOCKFD
    local STATUS=$?

    if [ $STATUS = 0 ]; then
        # Escribimos nuestra PID
        echo $MYPID >&${LOCKFD}
        return 0
    else
        local PROCNAME="$(cat /proc/$PID/comm 2>/dev/null)"
        if [ "$PROCNAME" != "$MYNAME" ]; then
            echo "Error, el proceso ejecutado no coincide con el que debe"
            exit 1
        fi
        local FROMTIME=$(awk -v ticks="$(getconf CLK_TCK)" -v epoch="$(date  %s)" '
            NR==1 { now=$1; next }
            END { printf "%9.0f", epoch - (now-($20/ticks)) }'
/proc/uptime RS=')' /proc/$PID/stat | xargs -i date  "%d/%m/%Y %H:%M:%S" -d @{})
        echo "El proceso $PID ($PROCNAME) lleva abierto desde $FROMTIME"
        return 1
    fi
}

function exit_error() {
    echo "Ya hay una instancia en ejecución. Saliendo"
    exit 1
}

lock || exit_error

echo "Creando un proceso que hace muchas operaciones durante un tiempo"
sleep 10
echo "El script ha terminado"
echo " ---------------- "

En este caso, escribimos la PID del proceso que bloquea el recurso en el mismo fichero de recurso (podríamos utilizar otro, pero bueno). Entonces, cuando falla el establecimiento del bloqueo, buscamos el nombre del proceso con la PID que hay escrita y a partir de ahí averiguamos cuándo se inició el mismo (tenemos que mirar en stat y analizar el resultado, por eso el script de awk). Luego, presentamos en pantalla cuándo se inició dicho proceso.

Conclusión

Dependiendo de nuestro script podremos utilizar una u otra solución o, incluso otra totalmente diferente. Aunque es importante asegurarse que cuando nuestro script esté utilizando recursos o elementos que no deban ser utilizados simultáneamente, hagamos un bloqueo antes de causar un desastre. Imaginad por ejemplo que inicio dos veces una copia de seguridad diaria de base de datos que escribe sobre el mismo fichero o que empiezo el envío de un mailing dos veces, antes de que se marque como enviado. Esto puede dar lugar a errores en copias de seguridad o envíos de correo no deseado, o correos duplicados que, pueden causar que muchos usuarios se den de baja de nuestra lista de distribución.

¿Qué método prefieres para bloquear tu script?

Foto principal: unsplash-logoMILKOVÍ

También podría interesarte....

There are 2 comments left Ir a comentario

  1. Eduardo C. /
    Usando Mozilla Firefox Mozilla Firefox 60.0 en Ubuntu Linux Ubuntu Linux

    Muchas gracias.

    Me ha gustado mucho tu explicación y lo implementaré en mis scripts.
    Sólo he mencionar que al menos en Ubuntu 16.04, flock corta el nombre del script que escribe en el archivo “/proc/$PID/comm”. En mi caso el script se llama “internet_watcher.sh” y el contenido del archivo comm solo es “internet_watche”, dándome siempre como resultado “Error, el proceso ejecutado no coincide con el que debe”.

    1. Gaspar Fernández / Post Author
      Usando Mozilla Firefox Mozilla Firefox 59.0 en Ubuntu Linux Ubuntu Linux

      Hola Eduardo!
      Llevas razón. Es un pequeño problema. En realidad a culpa no es de flock, sino del mismo /proc/$PID/comm que no almacena el nombre por completo. Ahora mismo se me ocurre que podemos utilizar
      basename “$(realpath /proc/$PID/exe)” ya que exe contiene un enlace al archivo ejecutable y con realpath extraemos la ruta de ese archivo (deshacemos el enlacec). es cuestión de probar.

      Gracias por tu comentario!

Leave a Reply