Il est rare de devoir utiliser des traitements en tâche de fond dans un script shell. À moins, bien entendu, qu’il s’agisse d’un script de démarrage servant justement à lancer plusieurs traitements en parallèle.
Il peut néanmoins être parfois nécessaire de gérer des lancements en arrière-plan, comme cela m’est arrivé une fois.
Situation
Un système aéroportuaire de traitement de plots radar m’envoyait des informations en continu sur un port réseau UDP/IP. Je devais les enregistrer dans des fichiers horodatés. Chaque fichier contenait une heure d’enregistrement environ. Je devais également, à l’issue de chaque enregistrement, effectuer une analyse des données qui durait environ cinq minutes.
Logiciels existants
Deux logiciels, écrits en C, étaient disponibles.
Le premier, appelons-le enregistreur
prenait plusieurs arguments sur sa ligne de commande
- L’adresse IPv4 du groupe multicast dans lequel s’inscrire. Ceci n’a pas d’intérêt pour ce qui nous concerne ici, prenons par exemple 224.10.10.10.
- Le numéro de port UDP pour recevoir les données. La valeur ne nous concerne pas plus que la précédente. Fixons-la arbitrairement à 2012.
- La durée en secondes de l’enregistrement à réaliser. Pour une durée d’une heure, nous passerons
3600
. - Le nom du fichier où stocker l’enregistrement. Pour que les fichiers soient aisément manipulables par la suite, il est nécessaire que leurs noms contiennent un horodatage compréhensible.
Le second logiciel, que nous appellerons statistiques
était un outil étudiant les données reçues (qu’il lisait depuis un fichier), et sortant des éléments d’information sur sa sortie standard. Ce programme prenait uniquement en argument le nom du fichier d’enregistrement à traiter.
Ces deux logiciels fonctionnaient très bien, et je devais les enchaîner dans un script shell afin qu’ils tournent 24H/24H.
Problème
Tout d’abord il fallait générer le nom du fichier en incluant un horodatage lisible. Pour cela le plus simple est souvent de s’appuyer sur la commande date
dont les options permettent facilement de construire une chaîne de caractères en insérant des éléments de date et d’heure.
NOM_FICHIER=$(date +"enregistrement-LFPG-%Y-%m-%d-%H-%M.dat")
La variable NOM_FICHIER
contiendra une chaîne de caractères comme enregistrement-LFPG-2012-04-21-09-00.dat
.
Le second problème était plus gênant. Si l’on pouvait tolérer la perte d’une seconde de messages entre deux enregistrements, il n’était pas envisageable d’avoir un « trou » de cinq minutes (durée du programme statistiques
). Je ne pouvais donc pas utiliser le shéma évident suivant.
#! /bin/sh ADRESSE_IP=224.10.10.10 PORT_UDP=2012 while true do # Generer le nom du fichier d'enregistrement FICHIER=$(date +"enregistrement-LFPG-%Y-%m-%d-%H-%M.dat") # Declencher l'enregistrement enregistreur ${ADRESSE_IP} ${PORT_UDP} 3600 "${FICHIER}" # Traiter l'heure ecoulee statistiques "${FICHIER}" > fichier-resultat.txt done
Il fallait donc paralléliser le traitement statistique et l’enregistrement des données.
Première idée
On peut tout d’abord imaginer un schéma où le traitement statistique fonctionne en arrière-plan, la boucle principale étant cadencée par l’enregistreur. En voici un exemple.
#! /bin/sh [...] while true do # Generer le nom du fichier d'enregistrement FICHIER=$(date +"enregistrement-LFPG-%Y-%m-%d-%H-%M.dat") # Declencher l'enregistrement enregistreur ${ADRESSE_IP} ${PORT_UDP} 3600 "${FICHIER}" # Traiter l'heure ecoulee statistiques "${FICHIER}" > fichier-resultat.txt & done
On remarquera, à la fin de la ligne de la commande statistiques
, le &
qui provoque le passage à l’arrière-plan. Ceci permet au shell de reprendre immédiatement son exécution en début de boucle et donc de démarrer un nouvel enregistrement.
Cette méthode fonctionnait, mais elle présentait néanmoins un petit inconvénient. A l’issue du traitement statistique, le fichier de résultats devait être renommé en utilisant un numéro d’ordre séquentiel et copié dans un répertoire indépendant ; ceci seulement dans le cas où le fichier n’était pas vide. Il devenait compliqué de réaliser ces opérations dans une exécution en arrière-plan.
L’idée fut donc d’inverser le principe précédent, en effectuant l’enregistrement en arrière-plan et le traitement statistique dans la boucle principale.
Seconde idée
Voici un premier aperçu de la nouvelle solution.
#! /bin/sh [...] NUMERO_RESULTAT=1 while true do # Generer le nom du fichier d'enregistrement FICHIER=$(date +"enregistrement-LFPG-%Y-%m-%d-%H-%M.dat") # Declencher l'enregistrement enregistreur ${ADRESSE_IP} ${PORT_UDP} 3600 "${FICHIER}" & # Traiter l'heure ecoulee statistiques "${FICHIER}" > fichier-resultat.txt # si le fichier n'est pas vide if [ -s fichier-resultat.txt ] # Le renommer et le déplacer mv fichier-resultat.txt "resultat-${NUMERO_RESULTAT}.txt" mv "resultat-${NUMERO_RESULTAT}.txt" ~/resultats-statistiques/ # Incrémenter le numéro d'ordre NUMERO_RESULTAT=$(( NUMERO_RESULTAT + 1 )) fi done
Si cette idée semble séduisante au premier abord, elle ne fonctionne pas du tout en réalité. Lorsqu’on lance l’enregistreur en arrière-plan, le traitement statistique s’exécute en quelques minutes et relance un nouvel enregistreur alors que le précédent n’est pas terminé, et ainsi de suite.
Pour que cette méthode soit utilisable, il faudrait s’assurer avant de démarrer un enregistrement que le précédent soit fini. Pour cela, nous allons noter après le lancement en arrière-plan de l’enregistreur son numéro de processus (PID), et avant le lancement nous attendrons que le processus enregistreur précédent soit terminé.
Le shell nous fournit dans la variable $!
le PID du dernier processus lancé en arrière-plan. Ajoutons donc la ligne suivante dans notre code.
[...] enregistreur ${ADRESSE_IP} ${PORT_UDP} 3600 "${FICHIER}" & PID_ENREGISTREUR=$! # Traiter l'heure ecoulee [...]
Pour attendre la fin d’un processus lancé par le shell, celui-ci nous propose la commande wait
que l’on peut faire suivre du numéro de PID. Ajoutons cette attente avant le lancement.
[...] wait ${PID_ENREGISTREUR} enregistreur ${ADRESSE_IP} ${PORT_UDP} 3600 "${FICHIER}" & [...]
Nous devons bien sûr prendre en considération le démarrage de notre script, où aucun enregistreur ne tourne encore. Pour cela, il suffit d’initialiser la variable PID_ENREGISTREUR
à un numéro impossible de processus (les PID sont toujours supérieurs à zéro) et de la tester avant d’appeler wait
.
Troisième essai
Notre code devient :
#! /bin/sh ADRESSE_IP=224.10.10.10 PORT_UDP=2012 REPERTOIRE_STATISTIQUES="~/resultats-statistiques/" PID_ENREGISTREUR=0 while true do # Generer le nom du fichier d'enregistrement FICHIER=$(date +"enregistrement-LFPG-%Y-%m-%d-%H-%M.dat") # Attendre la fin de l'enregistreur precedent if [ $PID_ENREGISTREUR -gt 0 ] then wait $PID_ENREGISTREUR fi # Declencher l'enregistrement enregistreur ${ADRESSE_IP} ${PORT_UDP} 3600 "${FICHIER}" & PID_ENREGISTREUR=$! # Traiter l'heure ecoulee statistiques "${FICHIER}" > fichier-resultat.txt # si le fichier n'est pas vide if [ -s fichier-resultat.txt ] # Le renommer et le deplacer mv "fichier-resultat.txt" "resultat-${NUMERO_RESULTAT}.txt" mv "resultat-${NUMERO_RESULTAT}.txt" "${REPERTOIRE_STATISTIQUES}" # Incrementer le numero d'ordre NUMERO_RESULTAT=$(( NUMERO_RESULTAT + 1 )) fi done
Pourtant, en observant le script, on peut s’apercevoir d’un gros défaut : le traitement statistique démarre sur le même fichier que l’enregistreur en cours ! Il faut bien sûr qu’il travaille sur l’enregistrement précédent. Modifions encore notre script.
Solution
#! /bin/sh ADRESSE_IP=224.10.10.10 PORT_UDP=2012 REPERTOIRE_STATISTIQUES="~/resultats-statistiques/" PID_ENREGISTREUR=0 FICHIER_COURANT="" FICHIER_PRECEDENT="" while true do # Attendre la fin de l'enregistreur precedent if [ ${PID_ENREGISTREUR} -gt 0 ] then wait ${PID_ENREGISTREUR} FICHIER_PRECEDENT="${FICHIER_COURANT}" fi # Generer le nom du nouvel enregistrement FICHIER_COURANT=$(date +"enregistrement-LFPG-%Y-%m-%d-%H-%M.dat") # Lancer l'enregistreur en arriere-plan enregistreur ${ADRESSE_IP} ${PORT_UDP} 3600 "${FICHIER_COURANT}" & PID_ENREGISTREUR=$! # Traiter l'enregistrement de l'heure ecoulee statistiques "${FICHIER_PRECEDENT}" > fichier-resultat.txt # si le resultat n'est pas vide if [ -s fichier-resultat.txt ] # Le renommer et le deplacer mv "fichier-resultat.txt" "resultat-${NUMERO_RESULTAT}.txt" mv "resultat-${NUMERO_RESULTAT}.txt" "${REPERTOIRE_STATISTIQUES}" # Incrementer le numero d'ordre NUMERO_RESULTAT=$(( NUMERO_RESULTAT + 1 )) fi done
Cette fois le script est correct et fonctionne.
Conclusion
Nous voyons que l’encadrement par un script shell de programmes existants n’est pas toujours évident surtout lorsqu’il existe une dépendance temporelle entre eux (synchronisation). Ici, certains points du script ont été ignorés (gestion des erreurs d’exécution comme la saturation du disque par exemple), mais il nous a quand même fallu quatre versions pour obtenir enfin un programme correct. On retiendra égtalement que le soin apporté à la lisibilité et à la clarté du script est un gage de facilité de maintenance ultérieure, surtout lorsque l’algorithme global sort quelque peu de l’ordinaire (parallélisme par exemple).
Bonjour Christophe,
J’en profite pour te remercier pour ton bouquin sur le shell en plus d’être bien écrit il est très pédagogique. J’ai un peu compris les script shell et c’était pas gagné 😉
Bon ma réflexion suite à ton article. Te semblerait-il possible via un script shell de bourrer au « maximum » un proc à x coeur ?
Du genre, je lance x’ job et je regarde la charge procS qui dés qu’elle descend en dessous de y% motive l’envoie d’un nouveau job etc…
C’est totalement déconnant ou c’est faisable sans monter une usine à gaz ?
Merci pour ton retour.
Cordialement.
Bonjour,
Voici un exemple de petit script (non testé) qui attend une liste de commandes sur son entrée standard (un job par ligne). Il les exécute en relançant un job chaque fois que la charge système (lue depuis /proc/loadavg sous Linux) descend en dessous d’un seuil fixé au début du script.
On peut sûrement l’améliorer sensiblement, mais ça te donnera une base de départ.
Cordialement,
> A l’issue du traitement statistique, ….
Dans la solution 1 qui me semble la bonne, pourquoi ne pas encapsuler l’exécutable « statistiques » dans un script qui s’occupera de faire les opérations annexes ?
while true
do
FICHIER=$(date + »enregistrement-LFPG-%Y-%m-%d-%H-%M.dat »)
enregistreur ${ADRESSE_IP} ${PORT_UDP} 3600 « ${FICHIER} »
wrapper_statistiques.sh « ${FICHIER} » &
done
J’ai pensé à la même chose, par contre dans ce cas la variable globale NUMERO_RESULTAT doit être gérée différemment afin d’être partagée entre chaque instance d’exécution du script wrapper_statistiques.sh . Dans un fichier par exemple?
Sinon il me semble qu’on peut appliquer cette idée à une fonction shell, directement:
Par contre je suis une brêle en script shell, et je n’ai pas testé. Comme je ne sais pas ce que je fais, faites attention si vous copiez ce bout de code..