weidner/computer/software/shell/

Parallele Jobs in der Shell

Neulich kam ein Kollege zu mir und fragte, wie er am besten mehrere Backup-Jobs gleichzeitig starten kann. Einige sollten später laufen, wenn die anderen fertig sind.

Mehrere Jobs gleichzeitig in der Shell

Die Grafik veranschaulicht das Problem. Zum Zeitpunkt t0 starten die ersten Jobs und sind zum Zeitpunkt t1 fertig. Zum Zeitpunkt t2 sollen die nächsten Jobs starten. Eine dritte Staffel sollte frühestens zum Zeitpunkt t3 starten, wenn alle Jobs der zweiten fertig sind. Außerdem sollen t1 und t2 nicht allzuweit auseinander liegen. Schließlich war es für Backup-Jobs, die über Nacht laufen und am Morgen fertig sein sollten.

Das ganze möglichst schnell und ohne zusätzliche Programme, also in der Shell.

Die erste Idee, die Jobs mit & in den Hintergrund zu schicken, hat einige Probleme:

Für alle diese Probleme gibt es Abhilfe, auch mit den Mitteln der Shell selbst. Die meisten dieser Abhilfen, die mir einfielen, benötigen Interprozesskommunikation und zusätzlich temporäre Dateien. Außerdem blähen sie das Skript auf, bis ich am Ende die eigentlichen Jobs im Skript kaum noch finde.

Dabei gibt es eine Möglichkeit, Jobs in der Shell gleichzeitig zu starten, bei der die Shell automatisch wartet, bis der letzte dieser Jobs geendet hat: alle Prozesse, die über Pipes miteinander verbunden sind, werden gleichzeitig von der Shell gestartet.

Die Standardausgabe jedes dieser Jobs ist mit der Standardeingabe des nächsten Jobs verbunden, die Standardeingabe mit der Standardausgabe des vorigen Jobs. Auf diese Art bekomme ich die Ausgaben der einzelnen Jobs aber nur zum jeweils nächsten Job und die Shell sieht nur die Ausgabe des letzten. Die Jobs davor würden zudem blockieren, weil ihre Ausgabe nicht gelesen wird.

Ich könnte die Ausgabe des vorherigen Jobs mit cat in eine Datei leiten und diesen so entsperren.

( cat >> $logfile &; jobx )

Damit würden zwar alle Jobs problemlos laufen, aber die Ausgabe würde wieder durcheinander in der Logdatei landen.

Besser ist es, wenn jeder Job die Ausgabe des vorigen an den nächsten weiterleiten würde. Das heißt, jeder Job, mit Ausnahme des ersten, müsste etwa so aussehen:

( jobx; cat )

Mit den runden Klammern schicke ich die Jobs in eine Subshell, so dass das Semikolon nicht die Pipe auseinander reißt. Auf diese Weise erscheinen die Ausgaben allerdings in umgekehrter Reihenfolge:

$ echo 1|(echo 2; cat)|(echo 3; cat)
3
2
1

Außerdem kann Job2 seine Ausgabe erst vollständig schreiben, wenn Job3 beendet ist und cat die Ausgabe weiterleitet. Dito für Job1, so dass zwar alle Jobs zur gleichen Zeit starten, aber die ersten blockieren, bis die letzten fertig sind.

Ich könnte auf die Idee kommen und die Reihenfolge von cat und dem Job tauschen:

job1|(cat; job2)|(cat; job3)

Dann stimmt zwar die Reihenfolge, aber job2 startet erst, wenn job1 seine Ausgabe schließt und job3 startet erst, wenn job2 seine Standardausgabe schließt.

Hier helfe ich mir mit einem Trick: ich speichere die Ausgabe aller Jobs in Variablen zwischen und gebe sie erst am Ende aus. Abgesehen davon, dass alle Jobs unabhängig voneinander laufen können, habe ich nun auch die Möglichkeit, die Ausgabe in die richtige Reihenfolge zu bringen.

Damit es übersichtlicher wird, kapsele ich den Aufruf von cat und den Job in eine Shell-Funktion, der ich den Job als Argument übergebe.

 1 in_pipe() {
 2   CMD="$*"
 3   BEFORE=$(date)
 4   LOG=$($CMD)
 5   RESULT=$?
 6   AFTER=$(date)
 7   
 8   cat
 9   echo $BEFORE
10   echo "$CMD"
11   echo "-----"
12   echo "$LOG"
13   echo "-----"
14   echo "RESULT=$RESULT"
15   echo $AFTER
16   echo "====="
17   return $RESULT
18 }

Bei dieser Funktion kann ich gleich alle Register ziehen. In Zeile 2 speichere ich alle übergebenen Argumente in einer Variablen, die mir dann als Befehl für den Job dient. In den Zeilen 3 und 6 speichere ich die Zeit vor und nach dem Aufruf des Jobs in zwei weiteren Variablen. In Zeile 4 speichere ich die Ausgaben des Jobs in einer Shell-Variable während er läuft und in Zeile 5 hebe ich den Rückgabewert für später auf.

Zeile 8 enthält den Aufruf von cat, der die Ausgabe des vorherigen Jobs durchleitet, dadurch entspricht die Reihenfolge der Ausgaben der Reihenfolge der Jobs in der Pipe. Ab Zeile 9 kommt die Ausgabe aller gesammelten Werte dieses Jobs, gefolgt von einer Trennzeile. In Zeile 17 schließlich gibt die Funktion den Rückgabewert des Jobs an die aufrufende Shell zurück.

Diese Funktion rufe ich wie folgt auf:

echo "=====" \
| in_pipe sleep 3 \
| in_pipe echo Hallo \
| in_pipe sleep 2
date

Und bekomme diese Ausgabe:

=====
Mi 21. Jan 10:01:58 CET 2015
sleep 3
-----

-----
RESULT=0
Mi 21. Jan 10:02:01 CET 2015
=====
Mi 21. Jan 10:01:58 CET 2015
echo Hallo
-----
Hallo
-----
RESULT=0
Mi 21. Jan 10:01:58 CET 2015
=====
Mi 21. Jan 10:01:58 CET 2015
sleep 2
-----

-----
RESULT=0
Mi 21. Jan 10:02:00 CET 2015
=====
Mi 21. Jan 10:02:01 CET 2015

Alle Jobs starten zur gleichen Zeit, werden aber zu unterschiedlichen Zeiten fertig. Das Skript fährt fort, nachdem der langsamste Job endete.

Ein Problem gibt es noch, dass ich mit folgendem Aufruf verdeutlichen kann:

echo "=====" \
| in_pipe false \
| in_pipe sleep 1
echo $?

Das Problem wird in der letzten Zeile der Ausgabe offenbar:

=====
Mi 21. Jan 10:09:08 CET 2015
false
-----

-----
RESULT=1
Mi 21. Jan 10:09:08 CET 2015
=====
Mi 21. Jan 10:09:08 CET 2015
sleep 1
-----

-----
RESULT=0
Mi 21. Jan 10:09:09 CET 2015
=====
0

Obwohl ein Job einen Fehlerwert gemeldet hat, registriert die Shell diesen nicht, weil der letzte Job in der Pipe ohne Fehler endete.

Bei der POSIX- oder Bourne-Shell muss ich mit diesem Umstand leben oder die Ausgabe der Pipe untersuchen. Verwende ich bash, ksh oder zsh, bekomme ich den Fehler mit folgender Anweisung im Skript vor der Pipe gemeldet:

set -o pipefail

Damit war mein Kollege zufrieden, ein anderer lobte die Übersichtlichkeit der Ausgabe, da die einzelnen Jobs im Protokoll gut voneinander zu unterscheiden sind.

Allerdings hat auch diese Lösung Einschränkungen.

Updates

2017-03-27

Der Artikel erschien, leicht überarbeitet, in der Uptimes 2/2016 und bei Pro-Linux. Insbesondere bei Pro-Linux finden sich einige interessante Kommentare:

Posted 2015-01-23
Tags: