Fortschritt auf Raten (Linux-Magazin, Dezember 2018)

Nicht nur hibbelige Millennials, auch altgediente Internetnutzer verlieren heute schnell die Geduld, wenn's mal nicht so flutscht. Dauert es länger als ein paar Sekunden, bis eine angeforderte Webseite im Browser erscheint, beginnt der User bereits, am Reload-Button herumzufummeln. Es ist aber auch wirklich nervig, falls nicht klar ist, was los ist. Deswegen erfand ein kluger Kopf schon vor fast 40 Jahren den Progress-Bar ([1]), der beruhigend auf den User einredet: "10% sind schon durch, und die restlichen 90% werden wir auch noch schaffen, und zwar in folgendem Tempo".

Auch kein Agententhriller aus Hollywood kommt ohne Progress-Bar aus. Wenn der Spion die Mainframe-Daten auf den USB-Stick abzieht, scheint der Fortschrittsbalken nur endlos langsam voranzuschreiten, während doch die Bösewichte nahen und alles aufzufliegen droht!

Abbildung 1: So mancher Hollywoodfilm wäre ohne Progress-Bar nur halb so spannend.

Einige Unix-Tools bringen bereits Fortschrittsbalken mit. So lernt curl normalerweise beim Einholen einer Webseite ganz zu Anfang, wieviele Bytes diese enthält und zeigt mit der Option -# (oder --progress-bar) das anschließende Eintrudeln der Daten an:

    $ curl -# -o data http://...
    ###########            50.6%

Auch das oft zum massenweisen Kopieren von Diskdaten genutzte Tool dd zeigt seit neuestem (ab GNU coreutils 8.24) den Fortschritt an, wenn der User wie in Abbildung 2 die Option status=progress setzt.

Abbildung 2: Seit GNU coreutils 8.24 zeigt das Tool "dd" mit der Option status=progress den Fortschritt an.

Bash & Co.

Freunde der Shell-Programmierung greifen zum Linux-Werkzeug pv, das auch Utilities ohne eingebauten Fortschrittsbalken auf die Sprünge hilft. Als Zwischenstück zwischen zwei Abschnitte einer Pipe geklemmt, zeigt es mittels eines ASCII-Balkens den Fortschritt der Daten durch die Pipe an, in dem es die durchfließenden Bytes mitzählt. Damit es weiß, welcher Bruchteil der Daten schon geflossen ist und was noch ansteht, um in regelmässigen Abständen den Balken aufzufrischen, muss es vorab die Gesamtdatenmenge wissen, dann ergibt sich der angezeigte Prozentwert aus der Division von mitgezählten Bytes zur Gesamtmenge.

Abbildung 3: Das Tool "pv" zeigt den Fortschritt beim Einlesen einer Datei als Balken an.

Einfach zwischen zwei Pipe-Abschnitte eingebaut, weiß pv allerdings nichts über die zu erwartende Datenmenge und kann demnach nur bereits geflossene Bytes zählen (Abbildung 3 oben). Wer pv in einem Pipe-Zwischenstück unter die Arme greifen möchte, kann die (eventuell vorab bekannte) Gesamtdatenmenge mit der Option -s bytes angeben, dann malt pv ebenfalls einen Fortschrittsbalken. Übergibt der User pv allerdings den Namen einer Datei, kann es feststellen, wie groß diese ist, bevor es die Daten stückweise weiterreicht und ohne Hilfestellung den Fortschritt anzeigt, wie beim letzten Backup-Kommando in Abbildung 2.

Abbildung 4: Einer der futuristischten Fortschrittsbalken in der Softwareindustrie: npm install

Selbst ist der User

Wer gerne selbst Tools zusammenklopft, findet Progress-Bars zur verwendeten Programmiersprache oft auf Github. Für Go bietet sich der simple ASCII-Balken progressbar an, den

    $ go get github.com/schollz/progressbar

direkt von Github auf den Rechner holt. Listing 1 zeigt einen einfachen Web-Client webpgb, der einen ihm auf der Kommandozeile übergebenen URL vom Netz holt und nebenher in einem Progress-Bar anzeigt, wie weit der Download schon fortgeschritten ist:

    $ ./webpgb http://...
    13%[##----------][16s:1m49s]

Dazu importiert Zeile 7 die Progressbar-Library als pb ins Hauptprogramm, welches mit os.Args[1] den ersten ihm überreichten Kommandozeilenparameter als URL interpretiert und mittels der Get-Funkion aus dem Standard-Paket net/http vom Netz holt. Zum stückweisen Einsammeln der Daten definiert Zeile 12 einen Puffer als Go-Slice von 4096 Bytes Länge, den die Endlosschleife ab Zeile 36 solange mittels Read() aus der HTTP-Antwort mit bis zu 4096 Zeichen befüllt, bis der Webserver den Rolladen schließt und EOF schickt, was Zeile 40 abfängt und main beendet. Zwischenzeitlich frischt Zeile 39 mittels Add() die Anzeige des Progressbars auf, in dem sie ihm die jeweils im Puffer liegende Anzahl von erhaltenen Bytes schickt.

Listing 1: webpgb.go

    01 package main
    02 
    03 import (
    04   "os"
    05   "net/http"
    06   "io"
    07   pb "github.com/schollz/progressbar"
    08 )
    09 
    10 func main() {
    11   resp, err := http.Get(os.Args[1])
    12   buffer := make([]byte, 4096)
    13 
    14   if err != nil {
    15     panic(err)
    16   }
    17 
    18   if resp.StatusCode != 200 {
    19     panic(resp.StatusCode)
    20     return
    21   }
    22 
    23   bar := pb.NewOptions(
    24     int(resp.ContentLength),
    25     pb.OptionSetTheme(
    26       pb.Theme{Saucer: "#",
    27         SaucerPadding: "-",
    28         BarStart:      "[",
    29         BarEnd:        "]"}),
    30     pb.OptionSetWidth(30))
    31 
    32   bar.RenderBlank()
    33 
    34   defer resp.Body.Close()
    35 
    36   for {
    37     n, err := resp.Body.Read(buffer)
    38     if err == nil {
    39       bar.Add(n)
    40     } else if err == io.EOF {
    41       return
    42     } else {
    43       panic(err)
    44     }
    45   }
    46 }

Anfangs hat Zeile 23 eine neue Balkenstruktur in der Variablen bar definiert und ihre Maximallänge auf die Gesamtzahl der aus dem Web-Request erwarteten Bytes initialisiert. Dabei definieren die Zeilen 24-30 auch noch kosmetische Einstellungen wie das ASCII-Zeichen für die sogenannte "Saucer" (Untertasse), das fliegende Objekt zur Illustration den Fortschritts, als "#", sowie den Balkenrahmen als "[]" und das Füllzeichen des leeren Balkens als "-".

Da die Daten aus der Web-Anfrage sowieso stückweise aus dem Netz eintreffen, fügt sich der Balken und die Logik, um ihn aufzufrischen, organisch in den Code ein. Falls langlaufende System-Calls die Laufzeit des Programms bestimmen, müssen diese umgeschrieben werden, damit ein Balken schrittweise voranschreiten kann und nicht etwa bis kurz vor Schluss still steht und dann ruckartig bis ans Ende schnalzt.

Retro-Look

Wem nach etwas mehr Eye-Candy als nur Terminal-Buchstaben gelüstet, der kann als nächste Stufe mit einer Terminal-UI wie dem letztens vorgestellten Termui-Projekt ([2]) wie in Abbildung 5 illustriert punkten. Am Prinzip zeigt Listing 2 auch wie Systemfunktionen beim Einholen von URLs vom Web und grafische Elemente einer GUI sich generell verzahnen lassen.

Abbildung 5: Fortschrittsbalken beim Kopieren einer großen Datei in Go mit der Termui-Library.

Da GUIs und damit der Fortschrittsbalken in einer Event-Schleife laufen, bietet sich asynchrones Einholen der Daten an, wie zum Beispiel in NodeJS. Dort springt der Code beim Eintreffen von Daten regelmässig Callbacks an, aus denen der findige Programmierer dann den Balken mit der eingetroffenen Datenmenge auffrischt.

Channels und Goroutines

In Go stehen hingegen zum Abfeuern nebenläufiger Programmteile und deren Synchronisation die letztens erörterten Goroutines und sogenannte Channels zur Verfügung [4]. Listing 2 zeigt, wie sich der lang dauernde Kopiervorgang einer großen Datei in Go mit einem Progressbar aus dem Termui-Paket illustrieren lässt.

Nachdem das Paket mit

    $ go get github.com/gizak/termui

installiert und das Programm mit

    $ go build cpgui.go

übersetzt wurde, kopiert der Aufruf cpgui foo eine Datei foo nach foo.bak. Ist sie relativ groß (z.B. 100MB), zeichnet die aufgespannte UI einen Progress-Bar ins Terminal, dessen Balken sich schrittweise nach rechts vergrößert, bis er am rechten Ende des Diagramms angelangt ist, die Datei fertig kopiert ist und das Programm sich beendet (Abbildung 5).

Listing 2 definiert hierzu in Zeile 19 mit NewGauge() aus dem termui-Paket eine neue Struktur, die das UI-Element eines Fortschrittsbalkens repräsentiert. Der Anfangswert des von links nach rechts wachsenden Balkens ist mit dem Attribut Percent auf 0 gesetzt, der Balken steht also bei 0% und damit ganz links und ist unsichtbar. Die Zeilen 21 bis 26 definieren noch weitere Attribute wie Farben der einzelnen Elemente, ihre Größe oder die Beschriftung des Grafikelements.

Kommunikationskanäle

Zur Kommunikation zwischen den verschiedenen Programmteilen, die die Daten schreiben beziehungsweise den Balken auffrischen, nutzt Listing 2 die in den Zeilen 29 und 30 definierten Channels, update und done. Über update teilt die Funktion backup() (die den Channel ab Zeile 56 als Parameter mitbekommt) dem Hauptprogramm mit, wieviele Bytes sie gerade in die neue Datei geschrieben hat. main() wartet dazu in einer parallel laufenden Goroutine in einer Endlosschleife ab Zeile 40 auf neue Daten im Channel und blockiert, falls noch keine anliegen. Kommt ein neuer Integerwert aus dem Channel hervor, setzt Zeile 41 das Percent-Attribut des Progress-Bars in g auf den neuen von backup() nach oben geschickten Prozentwert und zeichnet dann mit ui.Render(g) das Grafikelement neu.

Listing 2: cpgui.go

    01 package main
    02 
    03 import (
    04     ui "github.com/gizak/termui"
    05     "io/ioutil"
    06     "os"
    07     "fmt"
    08     "log"
    09 )
    10 
    11 func main() {
    12   file := os.Args[1];
    13   err := ui.Init()
    14   if err != nil {
    15     panic(err)
    16   }
    17   defer ui.Close()
    18 
    19   g := ui.NewGauge()
    20   g.Percent = 0
    21   g.Width = 50
    22   g.Height = 7
    23   g.BorderLabel = "Copying"
    24   g.BarColor = ui.ColorRed
    25   g.BorderFg = ui.ColorWhite
    26   g.BorderLabelFg = ui.ColorCyan
    27   ui.Render(g)
    28 
    29   update := make(chan int)
    30   done := make(chan bool)
    31 
    32     // wait for completion
    33   go func() {
    34     <-done
    35     ui.StopLoop()
    36   }()
    37 
    38    // process updates
    39   go func() {
    40     for {
    41       g.Percent = <-update
    42       ui.Render(g)
    43     }
    44   }()
    45 
    46   go backup(file, fmt.Sprintf("%s.bak", file),
    47             update, done)
    48 
    49   ui.Handle("/sys/kbd/q", func(ui.Event) {
    50     ui.StopLoop()
    51   })
    52 
    53   ui.Loop()
    54 }
    55 
    56 func backup(src string, dst string,
    57  update chan int, done chan bool) error {
    58 
    59  input, err := ioutil.ReadFile(src)
    60  if err != nil {
    61    log.Println(err)
    62    done <- true
    63  }
    64  total := len(input)
    65  total_written := 0
    66 
    67  out, err := os.Create(dst)
    68  if err != nil {
    69    log.Println(err)
    70    done <- true
    71  }
    72 
    73  lim := 4096
    74  var chunk []byte
    75 
    76  for len(input) >= lim {
    77    chunk, input = input[:lim], input[lim:]
    78    out.Write(chunk)
    79    total_written += len(chunk)
    80    update<- total_written*100/total
    81  }
    82  out.Write(input)
    83 
    84  done <- true
    85  return nil
    86 }

Der zweite Channel, done, erlaubt es der der Kopierfunktion backup(), die auch diesen Channel als Parameter vom Hauptprogramm mitbekommt, das Programmende einzuleiten. Hierzu wartet das Hauptprogramm in einer ab Zeile 33 gestarteten Goroutine auf Daten im done-Channel, und sobald welche vorliegen (weil Zeile 70 "true" hineingeschickt hat), löst Zeile 35 mit ui.StopLoop() den Abschluss der UI-Eventschleife ein, was in Zeile 53 die GUI einreißt und die main()-Funktion beendet.

Die Daten der zu kopierenden Datei liest die Funktion ReadFile() aus dem mit go get io/ioutil zu installierenden Paket ioutil in einem Rutsch in ein Array Slice ein, das Daten vom Type Byte beherbergt. Die Gesamtlänge der Datei in Bytes ermittelt die Funktion len() in Zeile 64 und speichert sie in der Variablen total. Zeile 67 legt dann die neue Datei mit der Endung .bak auf der Festplatte an, und gibt in out ein Writer-Interface zurück. Dieses bietet die Funktion Write() an, die in Zeile 78 dann solange Brocken mit 4096 Bytes Länge hineinschreibt, bis alle Bytes der Originaldatei erfolgreich kopiert wurden. Den nächsten 4096 Bytes langen Brocken aus dem Puffer input holt die Anweisung

    chunk, input = input[:lim], input[lim:]

in Zeile 77 in den Array Slice chunk und streicht die Daten gleichzeitig aus dem Original-Puffer input. Die Schleife ab Zeile 76 wiederholt den Reigen, bis nur noch ein Rest mit weniger als 4096 Bytes im Array Slice input verbleibt, den Zeile 82 außerhalb der For-Schleife dann auch noch in die neue Datei schreibt und damit den Kopiervorgang abschließt.

Damit der User den Kopiervorgang manuell unterbrechen kann, falls er es sich anders überlegt, definiert Zeile 49 einen Tastatur-Handler für die Taste "q", die mit StopLoop() die GUI zusammenfaltet und das Programm ordnungsgemäß beendet.

Am Ende des Kopiervorgangs schickt Zeile 84 einen Wert in den done-Channel, was das Hauptprogramm in Zeile 34 mitbekommt, den Block der Goroutine in Zeile 34 beendet, und mit ebenfalls ui.StopLoop() zum geordneten Rückzug veranlasst.

Teurer Spaß

Die Unterhaltung des Users während des Kopiervorgangs ist allerdings teuer erkauft: Das Schreiben der Daten in 4096-Byte-Brocken bremst bei größeren Dateien erheblich. Außerdem liest Listing 2 den Gesamtinhalt der zu kopierenden Datei erstmal in einem Rutsch in den Speicher, was bei Gigabyte-großen Trümmern vielleicht keine gute Idee ist. Für Hollywoods Filmindustrie reicht's aber allemal.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2018/12/snapshot/

[2]

Michael Schilli, "Klassiker neu verpackt": Linux-Magazin 10/18, S.XXX, <U>http://www.linux-magazin.de/ausgaben/2018/10/snapshot-7/<U>

[3]

"Fortschrittsbalken", https://de.wikipedia.org/wiki/Fortschrittsbalken

[4]

Michael Schilli, "Gleichzeitiges Arbeiten": Linux-Magazin 12/18, S.XXX, <U>http://www.linux-magazin.de/ausgaben/2018/11/snapshot-8/<U>

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc