blog · git · desktop · images · contact
2010-09-25
Hin und wieder taucht in Foren die folgende Frage auf: "Wie kann ich in
Shellskripten die Progressbar von wget
live auslesen?" Oder: "Wie kann
ich die aktuelle Position live aus der Ausgabe von mplayer
herausholen?" Derartige Probleme kann man alleine mit Tools wie grep
nicht lösen. Um zu verstehen, wieso das so ist, muss man sich einmal
ansehen, wie "Progressbars" oder allgemein "sich aktualisierende Zeilen"
am Terminal dargestellt werden können. Dann findet man auch eine Lösung,
die, zusammen mit einem nur selten benutzten (weil vergleichsweise
neuen) Programm, recht gut funktioniert: stdbuf
aus den GNU
Coreutils.
Alles beginnt mit einer "leeren" Zeile. Der Cursor soll durch einen Underscore symbolisiert werden und befindet sich im Moment am Anfang der Zeile:
_
Nun wird der aktuelle Zustand ausgegeben:
Position: 1.234_
Der Knackpunkt ist, dass kein abschließendes Newline-Zeichen
("\n
") folgt, der Cursor befindet sich nun also noch in derselben
Zeile hinter der Ausgabe. Ein Carriage Return-Zeichen ("\r
")
sorgt jetzt dafür, dass er an den Zeilenanfang zurückgesetzt wird:
_ Position: 1.234
Fängt der Zyklus jetzt von vorne an -- wird also wieder der aktuelle Stand ausgegeben --, dann wird der alte Stand überschrieben. Passiert das häufig hintereinander, ohne dass der Cursor die Zeile verlässt, dann hat man eine Ausgabe, die "aktualisiert" wird. Die folgende einfache Bash-Schleife zeigt diesen Effekt:
#!/bin/bash
for (( i = 0; i < 10; i++ ))
do
echo -ne "Position: $i\r"
sleep 0.5
done
echo
Das letzte, alleine stehende echo
gibt dabei nur ein Newline-Zeichen
aus (eigentlich ein Thema für sich), um den Cursor nach Ende des
Skriptes in die nächste Zeile zu setzen. Im Folgenden sei dieses Skript
als "status.sh
" verfügbar. Es soll als einfacher Ersatz für Ausgaben
von "echten" Programmen wie wget
oder mplayer
dienen. Das ist okay
-- deren Ausgaben arbeiten genauso. Sie könnten zwar auch den Cursor mit
anderen Steuerzeichen platzieren, tun es aber nicht.
Wendet man auf die Ausgabe des Skriptes nun grep
an, dann passiert
zunächst einmal gar nichts. Erst nach rund 5 Sekunden erhält man die
Ausgabe -- und zwar auf einen Schlag.
$ ./status.sh | grep -o '[[:digit:]]'
Wenn man weiß, wie grep
arbeitet, dann ist dieses Verhalten erklärbar:
grep
arbeitet zeilenweise. Eine "Zeile" ist aber eine Reihe von
Zeichen, die mit einem Newline-Zeichen abgeschlossen wird und jenes
Zeichen wird erst durch das letzte "echo
" oben ausgegeben. Das heißt,
grep
sieht nur eine einzige Zeile, welche so aussieht:
Position: 0\rPosition: 1\rPosition: 2\rPosition: 3\rPosition: 4\rPosition: 5\rPosition: 6\rPosition: 7\rPosition: 8\rPosition: 9\r\n
Diese Zeile erscheint auch erst nach 5 Sekunden, nachdem "Position: 9
"
geschrieben und das Skript beendet wurde.
Was man also tun muss, ist, die Carriage Return-Zeichen durch Newlines zu ersetzen. Dadurch würde jede Statusausgabe zu einer vollständige Zeile werden, die grep
richtig verarbeiten könnte. Das kleine Tool tr
kann diese Ersetzung vornehmen:
$ ./status.sh | tr '\r' '\n' | grep -o '[[:digit:]]'
Die Ausgabe hiervon erscheint allerdings auch verzögert nach rund 5
Sekunden. Und das ist der Punkt, an dem stdbuf
ins Spiel kommt.
Diesmal ist die Ausgabe verzögert, weil sie gepuffert wird. Das System
sieht, dass die Ausgabe des Skriptes nicht direkt auf ein Terminal,
sondern in eine Pipe läuft -- dann puffert es (mehr). Das ist auch gut
so, sonst wären Pipes, durch die viele Daten laufen, extrem langsam. Für
eine "Live-Ausgabe" ist es aber unbrauchbar. stdbuf
kann nun dazu
benutzt werden, diesen Puffer abzuschalten.
Grundsätzlich ist der Aufruf:
$ stdbuf -i0 -o0 programm
"-i0
" und "-o0
" schalten den Puffer von STDIN und STDOUT ab.
Möchte man obige Pipe mit stdbuf
benutzen, müssen prinzipiell jeweils
beide Enden auf beiden Seiten jedes Stückes erweitert werden. In voller
Länge entstünde also folgender Aufruf:
$ stdbuf -o0 ./status.sh | stdbuf -i0 -o0 tr '\r' '\n' | stdbuf -i0 grep -o '[[:digit:]]'
(Am Anfang spielt der Input keine Rolle und der finale Output läuft ohnehin auf ein Terminal, daher fallen zwei Parameter weg. Jenachdem, welche Optionen die Programme selbst noch setzen, können auch weitere Parameter wegfallen -- bei diesem Beispiel geht es nur ums Prinzip.)
Es ist natürlich recht lästig, jedes Stück der Pipe jedesmal zu
erweitern. Zum Glück geht es kürzer, was man aber erst verstehen kann,
wenn man einen kurzen Blick in den Quellcode von stdbuf
geworfen
hat. Dort sieht man -- neben einem amüsanten Kommentar ;) --, dass
dieses Programm kaum mehr tut, als vier Umgebungsvariablen zu setzen:
$LD_PRELOAD
, $_STDBUF_I
, $_STDBUF_O
und $_STDBUF_E
. Dass
$LD_PRELOAD
vorkommt, lässt es schon vermuten: Es wird eine Bibliothek
"eingespeist", libstdbuf.so, die ebenfalls in den Coreutils
enthalten ist. Deren Code läuft im Kontext des jeweiligen Prozesses, den
man über stdbuf
startet, und kann so die Optionen zum Puffer auf den
Ein- und Ausgabeströmen setzen. Durch die drei anderen Variablen gibt
stdbuf
der Bibliothek bekannt, welche Optionen denn gesetzt werden
sollen.
Hält man sich an diese konkrete Implementierung, dann ist das Programm
stdbuf
also nicht einmal nötig, sondern nur das Einschieben der
Bibliothek. Es genügt folgendes:
$ export _STDBUF_I=0
$ export _STDBUF_O=0
$ export LD_PRELOAD=/usr/lib/coreutils/libstdbuf.so
$ ./status.sh | tr '\r' '\n' | grep -o '[[:digit:]]'
Dieser Aufruf der normalen Pipe mit präparierter Umgebung ist somit
äquivalent zur obigen Pipe, bei der jedes Teilstück um stdbuf
erweitert wurde.
Es geht aber noch schöner. Da stdbuf
nur Umgebungsvariablen setzt und
exportiert, bewirkt folgender Aufruf dasselbe:
$ stdbuf -i0 -o0 bash -c "./status.sh | tr '\r' '\n' | grep -o '[[:digit:]]'"
Nimmt man stdbuf
wörtlich, dürfte das nicht funktionieren, da nur die
Kanäle des bash
-Prozesses geändert werden würden -- und nicht die der
Prozesse, die wiederum von der inneren Shell gestartet werden. Nur mit
Kenntnis der inneren Abläufe von stdbuf
lässt sich verstehen, wieso
der Aufruf in dieser Form auch klappt.
stdbuf
gibt es in den Coreutils erst seit Commit a5a2a40 vom 17.
Dezember 2008. Veröffentlicht wurde es in Version 7.5 der Coreutils
am 20. August 2009. In Arch Linux ist es seit diesem Zeitpunkt auch
verfügbar, bei "großen" Distributionen sieht das etwas anders aus:
stdbuf
kann also leider noch nicht überall Anwendung finden.