blog · git · desktop · images · contact


Know your tools: „stdbuf“

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.

Comments?