Depuis que je suis arrivé chez GPDis, je me rends compte que je ressors de temps à autres ce que j'ai fait dans les autres boites. Notamment des petits bouts de code qui font ce qui est pour moi évident. Comme cela faisait trop longtemps que je n'avais pas écrit de billet techniques très fouillés, je reprends pour cette série ces petits snipets de code que je reproduis souvent pour en expliquer la stratégie, et accessoirement augmenter mon klout sur les réseaux sociaux en tant que dev, parce qu'écrire des billets sur la balise <img> ou la série des Dirty Hacky m'ont pris parfois plus de trois mois. Et si vous croyez que j'ai que ça à faire, imaginez que je ponds aussi mon émission radio hebdomadaire CPU, qui demande son quota sacrificiel en nuits blanches.
Occasion de commencer une petite série, qui est doublé d'un projet git.
Le principe (de la série)
Écrire des petites lignes de code, pour explorer des petites astuces autour d'un langage, des petits bouts de code qu'on peut étendre à loisir pour son usage, en utilisant le langage dans sa version la plus pure/simple/indépendante/vanilla possible et faisant appel aux standards normaux.
L'énoncé du problème
Supposons que tu aies un script bash sur un serveur, qui est lancé toutes les minutes via crontab (supposons !), sauf qu'il ne doit pas être lancé concurrentiellement (une et une seule fois à la fois), et que parfois, il dure plus d'une minute.
Problèmes :
- Bash n'est pas super lisible,
- mais il est l'intepréteur de commande le plus présent sur un serveur unix alors que zsh doit être explicitement installé,
- il existe très peu de libs qui propose ce genre de snipets
- et on veut un petit bout de code qu'on puisse facilement étendre à sa propre convenance (d'où la licence GPL)
TL;DR Le corrigé
Soit vous clonez le repo git, soit vous copiez-collez sans comprendre les lignes suivantes (et bonne chance) :
LOCK_DIR=~/locks LOCK_FILE=${LOCK_DIR}/cron.lock function lock_script() { mkdir -p ${LOCK_DIR} if [ -f "${LOCK_FILE}" ]; then LOCKING_PID=`cat ${LOCK_FILE}` >&2 echo "ERROR : Script ${CALLED_FUNCTION} already locked on PID ${LOCKING_PID}. Cannot run PID $$ . Still running ?" exit 1 fi echo $$ > ${LOCK_FILE} } function unlock_script() { rm ${LOCK_FILE} }
Usage
Commencez votre script par lock_script
et concluez avant de quitter par unlock_script
.
Préliminaires
Pour des facilités de refactoring, je définis toujours mes variables en début de fichier. Je me suis mis dans le répertoire utilisateur, mais en cas de redistribution, il vaut mieux créer son arborescence dans /usr/var/lock
si on suit le Filesystem Hierarchy Standard.
En bash, les définition de fonctions peuvent être écrites soit :
function lock_script {
ou sous cette forme :
lock_script() {
Pour aider mes petits camarades à la lecture, je mixe (sûrement très improprement) les notations de déclarations de fonctions bash, ce qui ressemble plus à la convention C/JS/PHP/etc… On notera que la parenthèse n'est pas interprétée
lock_script() ligne à ligne
Je crée inconditionnellement le sous-répertoire qui va accueillir mes fichiers locks
mkdir -p ${LOCK_DIR}
Est-ce qu'un fichier lock est déjà présent ?
if [ -f "${LOCK_FILE}" ]; then
Je lis son contenu, lequel contient le PID du script l'ayant lancé. La notation `command`
est plus compacte que $(command)
et surtout plus lisible car déjà imbriquée.
LOCKING_PID=`cat ${LOCK_FILE}`
J'envoie un message expliquant la situation vers STDERR, via la très cabalistique notation >&2
qui préfixe une commande dont la sortie normale est redirigée vers le canal d'erreur. La variable $$
donne le PID du script courant.
>&2 echo "ERROR : Script ${CALLED_FUNCTION} already locked on PID ${LOCKING_PID}. Cannot run PID $$ . Still running ?"
Évolutions possibles :
- En profiter pour vérifier si ledit script tourne toujours.
- Internationaliser le message
Sortir avec un code d'erreur
exit 1
Fin de la conditionnelle.
fi
Sinon implicite : Enregistrer dans le LOCK_FILE le PID du script courant.
J'aurais pu utiliser un touch
qui crée un fichier vide. Mais stocker le PID dans le lock d'un script permet, par exemple, de vérifier qui le script est toujours en cours d'exécution ou a brusquement planté. Si vous avez des outils de monitoring, cela vous laisse de quoi vous amuser.
echo $$ > ${LOCK_FILE}
unlock_script() ligne à… ligne
On supprime le lock.
rm ${LOCK_FILE}
Et voilà.
Alors certes…
Y'avait mieux et plus propre. Pour celà, je vous invite à râler dans ⇓ les commentaires ⇓ ou sinon à commiter sur mon github.
6 réactions
1 De HarmO - 26/09/2017, 09:54
Avec de "nouveaux" outils plutôt récents, ça tient en une ligne.
Avec flock:
flock -xn /path/to/lockfile.lck -c /my/great/script.sh
Avec Solo (HAAAN !):
http://timkay.com/solo/
2 De Dam_ned - 26/09/2017, 11:55
En fait comme c'est pas atomique entre le teste de présence du fichier et ça création pour le lock du coup c'est pas safe.
Il faut utiliser une commande atomique pour ce faire.
Et mkdir en est une.
du coup :
```
mkdir -p $LOCK_DIR
if mkdir "${LOCK_FILE}"; then
echo 'je suis tout seul'
rmdir "${LOCK_FILE}"
else
echo "oups"
fi
```
3 De Da Scritch - 26/09/2017, 11:58
Dam_ned : C'est pas faux, mais le cas d'une race condition est extrèmement rare dans un script lancé en cron ^^ et en plus tu perds l'information du PID qui a créé le lock
4 De Dam_ned - 26/09/2017, 12:15
Variante avec le PID
```
mkdir -p $LOCK_DIR
if mkdir "${LOCK_FILE}"; then
echo $$ > ${LOCK_FILE}/pid
echo "je suis tout seul et mon pid est dans ${LOCK_FILE}/pid"
rm -rf "${LOCK_FILE}"
else
echo "oups"
fi
```
5 De Gasp - 27/09/2017, 07:15
En cas de plantage du script il ne faudrait il pas placer un trap qui efface le fichier?
6 De Da Scritch - 21/10/2020, 11:19
J'ai un peu amélioré le code, notamment attendre qu'un autre script soit terminé.
Ces deux fonctions vous seront utiles
function _wait_pid() {
# https://stackoverflow.com/questions...
# Purin que ça a l'air crade
tail --pid="${1}" --follow /dev/null
}
function _wait_previous_same_script_locking() {
# Wait until the same script locking
previous_locking_pid=""
if [ -f "${LOCK_FILE}" ]; then
previous_locking_pid="$(cat ${LOCK_FILE})"
echo "=== Waiting for previous same script still locking at PID ${previous_locking_pid} at ${LOCK_FILE}"
_wait_pid ${previous_locking_pid}
echo "=== Other script ended"
fi
}