Parallelität in Shell

Shell-Scripte, die größere Datenmengen umher schubsen, können schon mal ganz schön lange laufen. Ein typischer Fall sind Backup-Scripte, die in einer Schleife zwischen Erstellen und Übertragen eines Archivs hin und her pendeln. Natürlich will man nicht alles voll parallel fahren, sondern hübsch nacheinander. Allerdings sollte Datentransfer und Archiverstellung schon parallel ablaufen. Das ist aber etwas trickreich.

Schwache Parallelität

Was man eigentlich will, sind zwei Scripte. Das eine generiert die Archive und das andere überträgt die auf ein entferntes System. Die Scripte sollen sich nicht gegenseitig behindern, d.h. eine noch laufende Übertragung darf nicht die Erzeugung des nächsten Archivs verzögern und umgekehrt.

Selbstverständlich dürfen nur die Archive übertragen werden, die auch fertig sind. Man braucht also eine Liste von fertig gestellten Archiven, die aus dem einen Script heraus fallen und vom zweiten Script übertragen werden.

Das klingt nach einer Pipe? Aber natürlich! Das ist eine Pipe!

(
  tar czf xxx.tgz ~xxx && echo xxx.tgz
  ...
) | while read a; do
  upload "$a"
done

Ganz klassisch. Leider aber nicht benutzbar, denn die Backupscripte existieren bereits.

#! /bin/bash

echo "Übertrage Test1"
echo "X1"
echo "Übertrage Test2"
echo "X2"
echo "Übertrage Test3"
echo "X3"
echo "Übertrage Test4"

Insbesondere sollen es nicht mehr Scripte werden, die per Helperscripte zusammen geklebt werden müssen.

Der Crux mit dem Standard

Diese Scripte benutzen bereits Standardeingabe, -ausgabe und -error. Diese Filedeskriptoren werden nicht nur von dem betreffenden Script, sondern auch von den aufgerufenen externen Programmen (wie tar) benutzt. Es ist also nicht möglich, den Zweck von STDOUT neu zu definieren.

Eine Lösung besteht darin, die Pipe auf einem anderen Filedeskriptor laufen zu lassen. Das ist aber nicht ganz so einfach.

Anstatt die eigentliche Aktion auszuführen, wird der zu übertragende Dateinamen auf einen anderen Filedeskriptor drei geschrieben. Dieser wird dann als STDOUT für die Pipe kopiert.

Was aber tun mit dem aktuellen STDOUT? Würde man ihn lassen, würde diese Ausgabe ebenfalls Übertragungen anstoßen. Definitiv ungewollt. Also retten wir den Filedeskriptor eins (STDOUT) temporär auf den Deskriptor vier.

#! /bin/bash

{ {
  echo "Test1" >&3
  echo "X1"
  echo "Test2" >&3
  echo "X2"
  sleep .1
  echo "Test3" >&3
  echo "X3"
  echo "Test4" >&3
  echo "Main done"
} 3>&1 1>&4 | {
  while read a; do
    echo "Übertrage >$a<"
    sleep 1
  done
  echo "Sub done"
} } 4>&1
echo "All done"

Und zum Schluss fügen wir den temporären Filedeskriptor vier wieder STDOUT (eins) hinzu. Das Ergebnis kann sich sehen lassen:

X1
X2
Übertrage >Test1<
X3
Main done
Übertrage >Test2<
Übertrage >Test3<
Übertrage >Test4<
Sub done
All done

Minimalinvasive Eingriffe

Die Methode der Pipe erzwingt einen riesigen Block, der aus dem bisherigen Skript besteht. Solche riesigen Blöcke sind anfällig für subtile Fehler und lassen sich sehr schwer pflegen. Schließlich sollen die Änderungen möglichst wenig Zeilen betreffen und das Ergebnis weiterhin patchbar bleiben. Eine Klammerung, die im Editor eine andere Einrückung erzwingt, scheidet damit aus.

Aber was dann?

Coprozesse sind der Kern dessen, was die Shell mit sich selbst veranstaltet, wenn sie eine Pipe generieren muss. Also kann man diese auch direkt verwenden.

Ein Coprozess ist ein fork der Shell an der Aufrufstelle, wobei der Kindprozess nur noch den Teil der Coroutine ausführt. Sein STDIN und STDOUT sind zwei Pipes, deren Enden dem Mutterprozess in die Hand gedrückt werden.

$ coproc my { read a; echo "Test $a"; };
[1] 12548
$ jobs
[1]+  Running                 coproc my { read a; echo "Test $a"; } &
$ set | grep my
my=([0]="63" [1]="60")
my_PID=12548 

Von der Muttershell aus müssen nun diese Filedeskriptoren gelesen und geschrieben werden:

$ cat - <&63
^Z
[2]+  Stopped(SIGTSTP)        cat - 0<&63
$ bg
[2] cat - 0<&63 &
$ echo Hallo >&60
Test Hallo
[1]-  Done                    coproc my { read a; echo "Test $a"; }
[2]+  Done                    cat - 0<&63 

Das Prinzip sollte damit klar sein. Natürlich muss man die Nummern der Deskriptoren aus der Umgebungsvariable auslesen, denn die sind variabel.

Um sich keinen useless use of cat einzuhandeln, sollte man aber den Filedeskriptor direkt auf STDOUT umbiegen.

Gießt man das alles zusammen, bekommt man:

#! /bin/bash

{ coproc my {
  while read a; do
    echo "Übertrage >$a<"
    sleep 1
  done
  echo "Sub done"
} >&3; } 3>&1

echo "Test1" >&${my[1]}
echo "X1"
echo "Test2" >&${my[1]}
echo "X2"
sleep .1
echo "Test3" >&${my[1]}
echo "X3"
echo "Test4" >&${my[1]}
echo "Main done"

wait
echo "All done"

Und das Ergebnis lautet:

X1
X2
Übertrage >Test1<
X3
Main done
Übertrage >Test2<
Übertrage >Test3<
Übertrage >Test4<
[ ... ewiges Warten ...]

Irgendwie bekommt der Kindprozess nicht mit, dass die Arbeit erledigt ist.

Man müsste die Pipe noch schließen, damit der Kindprozess sich beenden kann. (Oder halt ein Inband-Signalwort übertragen.)

Das Schließen des Filedeskriptors ist schwieriger als erwartet, weil die Variablensyntax anders ist. Das Prozessing von Variablensubstitution und Redirect der Filedescriptoren intereferiert auf unerwartete Weise.

exec {my[1]}>&-

Zusammen gefasst kommt dann folgende Strukturänderung heraus:

#! /bin/bash

# Initialisierung der Variablen, Konfiguration
...
# Einschub der Helperfunktion für die Hintergrundübertragung
{ coproc uploadhelper {
  while read a; do
    upload "$a"
  done
} >&3; } 3>&1

# Normales Hauptprogramm
...
echo "$FILE" >>${uploadhelper[1]}   # ersetzt die Zeile: upload "$FILE"
...
# Ende des Hauptprogrammes, Warten auf das Ende der Uploads
exec {uploadhelper[1]}>&-
wait

# Nacharbeiten des Hauptprogrammes
....
# EOF 

Tut.

Post a comment

Verwandter Inhalt