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.
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:
- Bei Hintergrund-Jobs bekomme ich im Shell-Skript nicht mit, wann diese fertig sind. Ich muss t1 und t3 daher schätzen und verschenke Zeit, weil ich zu viel Reserve lasse oder starte die nächste Staffel zu früh, während die vorherige noch läuft.
- Die Ausgaben der einzelnen Jobs vermischen sich und ich kann nicht vorhersagen, wessen Ausgabe an welcher Stelle erscheint.
- Ich bekomme keine Rückgabewerte der Jobs und kann im Skript nicht darauf reagieren.
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.
- Da ich die Ausgabe der Jobs in Variablen zwischenspeichere, sollte diese sich auf den maximalen Wert für Shellvariablen beschränken. Brauche ich mehr, muss ich mit temporären Dateien arbeiten.
- Ich kann keine Ausgabeumleitungen an die Funktion durchreichen. Diese könnte ich allerdings in der Funktion selbst einbauen, wodurch sie für alle Jobs gelten würde.
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:
Benutzer mekeor verweist auf einen Vortrag von Diomedis Spinellis bei FOSDEM, in dem er dgsh, die Directed Graph Shell vorstellt.
Ein anonymer Benutzer empfahl
make -j
.Benutzer tim_ empfahl
sponge
, ein Tool dass bei Ubuntu zum Beispiel im Packet moreutils zu finden ist. Sponge liest zuerst die gesamte Eingabe, bevor es in die Ausgabedatei schreibt. Damit trägt es nicht direkt zur Lösung des hier beschriebenen Problems bei, könnte aber nützlich sein, wenn die einzelnen Jobs soviel Text erzeugen, dass Shell-Variablen nicht mehr ausreichen.