Traitements parallèles dans un script shell

Publié par cpb
Avr 21 2012

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).

4 Réponses

  1. lecteur assidu dit :

    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.

    • cpb dit :

      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,

      #! /bin/sh
      
      # Seuil en pourcentage au-dessous duquel on relance un nouveau job
      SEUIL_CHARGE=50
      
      # Lire la liste de tous les jobs depuis l'entree standard
      nb_jobs=0
      while read job
      do
              nb_jobs=$((nb_jobs + 1 ))
              liste_jobs[$nb_jobs]="$job"
      done
      
      # Parcourir la liste des jobs a executer
      j=1
      while [ $j -le $nb_jobs ]
      do
              # Attendre que la charge système soit sous le seuil
              while true
              do
                      charge=$(awk '{print $1 * 100}' /proc/loadavg)
                      if [ $charge -le $SEUIL_CHARGE ]; then break; fi
                      sleep 5
              done
      
              # Lancer le j-ieme job en arriere-plan
              eval "${liste_jobs[$j]} &"
              j=$((j+1))
      
              # Attendre que la charge systeme evolue un peu
              sleep 5
      done
  2. steph dit :

    > 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

    • yves dit :

      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:

      #! /bin/sh
      [...]
      NUMERO_RESULTAT = 1
      
      perform_statistiques() {
          # Traiter l'heure ecoulee
          statistiques "$1" > 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
      }
      [...]
      
          perform_statistiques ${FICHIER} &
      
      [...]
      

      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..

URL de trackback pour cette page