Wanze im Dateisystem (Linux-Magazin, April 2021)

Wer in einem Datei-Manager schon einmal beobachtet hat, wie dieser neu entstehende Dateien im dargestellten Verzeichnis sofort mitbekommt und anzeigt, hat sich vielleicht gefragt, wie das denn funktioniert. Wiederholtes Abfragen des Dateisystems verbietet sich aus Performance-Gründen, vielmehr nutzen solche Applikationen die inotify-Schnittstelle des verwendeten Linux-Dateisystems.

Betriebssysteme implementieren den Mechanismus auf unterschiedliche Art und Weise, Linux nutzt inotify, der Mac kqueue, und Windows eine unaussprechbare Extrawurst. Zum Glück abstrahiert die Go-Library fsnotify auf Github diesen Wildwuchs auf eine einfache Schnittstelle, und Autoren müssen ihre Applikationen nur einmal schreiben, um alle Plattformen abzudecken.

Ohne Klimmzüge

Vor etwa 15 Jahren gab's zu dem Thema schon einmal etwas im Programmier-Snapshot in Perl [2], damals musste noch mit FUSE ein Spezialdateisystem ran, heutzutage ist die Funktion Standard. In Go geht das Ganze ohne große Klimmzüge von der Hand, Listing 1 zeigt ein einfaches Beispiel zum Aufwärmen. Abbildung 1 zeigt, wie aus dem Go-Code in watch.go ein ausführbares Binary watch entsteht, das anschließend ein neu erzeugtes Verzeichnis /tmp/test überwacht. In einem weiteren Terminal setzt nun der User die Kommandos aus Abbildung 2 ab, die im Testverzeichnis erst eine neue Datei anlegen, Daten hineinschreiben, ihre Ausführungsrechte ändern und sie schließlich mit rm löschen. Abbildung 1 bestätigt, dass das Go-Programm tatsächlich alle Änderungen mitbekommt und die Aktionen protokolliert.

Abbildung 1: Listing 1 lauscht auf Meldungen des Dateisystems in /tmp/test ...

Abbildung 2: ... ausgelöst durch User-Aktionen in einem anderen Terminal.

Dafür holt Listing 1 in Zeile 5 den Library-Code von Github, legt als ersten Schritt im main-Programm einen neuen Watcher an und teilt mit defer mit, dass dieser bei Programmschluss zu schließen ist. Da die Überwachung des Dateisystems asynchron über Go-Channels abläuft, feuert Zeile 15 mit go func eine Goroutine ab, die sofort in eine Endlosschleife mit einer select-Anweisung einbiegt. Letztere blockiert den Programmfluss der Goroutine, bis Nachrichten aus dem Channel watcher.Events ankommen, gesendet vom Library-Code aus fsnotify, der dafür das Betriebssystem anzapft.

Listing 1: watch.go

    01 package main
    02 
    03 import (
    04   "fmt"
    05   "github.com/fsnotify/fsnotify"
    06 )
    07 
    08 func main() {
    09   watcher, err := fsnotify.NewWatcher()
    10   if err != nil {
    11     panic(err)
    12   }
    13   defer watcher.Close()
    14 
    15   go func() {
    16     for {
    17       select {
    18       case event, ok := <-watcher.Events:
    19         if !ok {
    20           return
    21         }
    22         fmt.Printf("%+v\n", event)
    23       }
    24     }
    25   }()
    26 
    27   err = watcher.Add("/tmp/test")
    28   if err != nil {
    29     panic(err)
    30   }
    31 
    32   done := make(chan struct{})
    33   <-done
    34 }

Routinen und Blockade

Das Hauptprogramm fließt derweil ungehindert weiter, und Zeile 27 teilt fsnotify mittels watcher.Add() mit, dass sie das Verzeichnis /tmp/test zu überwachen wünscht. Damit wäre das Hauptprogramm am Ende angekommen, aber da es weiterlaufen und in der vorher abgefeuerten Goroutine auf Ereignisse lauschen soll, erzeugt Zeile 32 kurz vor Schluss noch einen ungenutzten Channel, aus dem in Zeile 33 nie eine Nachricht ankommt, da seine einzige Aufgabe darin besteht, das Hauptprogramm solange zu blockieren, bis der User es mit Ctrl-C abbricht.

Nicht rekursiv

Dabei setzt die Go-Library fsnotify mit jedem Aufruf von Add() nur jeweils ein weiteres Verzeichnis auf die Überwachungsliste, rekursives Einklinken eines ganzen Dateibaums steht angeblich auf der Roadmap des Projekts, ist aber zur Zeit noch nicht verfügbar und muss deshalb von der Applikation manuell vorgenommen werden.

Um zum Beispiel zu verfolgen, welche Dateien der Go-Compiler in der Verzeichnishierarchie unterhalb von ~/go im Homeverzeichnis des Users während der Arbeitsphase herunterlädt oder generiert, muss Listing 2 mit der Funktion Walk() aus dem Standardpaket filepath ab Zeile 24 erst einmal hinab in die Tiefen der Verzeichnisstruktur steigen.

Die Funktion nimmt als Parameter eine Callback-Funktion entgegen, die sie bei jedem gefundenen Dateisystemeintrag mit dessen Namen und seiner FileInfo-Struktur mit den Metadaten wie Datei oder Verzeichnis, Größe in Bytes oder Zugangsberechtigungen aufruft. Trat beim Einsteigen in einen Teilbereich ein Fehler auf, ist statt dessen die Variable err auf den entsprechenden Fehler gesetzt.

Kurzer Prozess wegen Platzmangel

Um die ewigen Fehlerprüfungen nach Funktionsaufrufen mit err != nil und einer if-Bedingung auf eine magazinfreundliche Code-Länge zu kürzen, definiert Listing 2 in Zeile 77 die Funktion dieOnErr(), die bei jedwegem Fehler einfach abbricht. Unter Produktionsbedingungen würde man hier statt dessen den Fehler loggen und eventuell behandeln, denn dort herrscht nicht der Druckerzeugnissen inhärente Platzmangel.

Listing 2: fswatch.go

    01 package main
    02 
    03 import (
    04   "fmt"
    05   "github.com/fsnotify/fsnotify"
    06   "log"
    07   "os"
    08   "os/user"
    09   "path/filepath"
    10   "strings"
    11 )
    12 
    13 func main() {
    14   cur, err := user.Current()
    15   dieOnErr(err)
    16   home := cur.HomeDir
    17 
    18   watcher, err := fsnotify.NewWatcher()
    19   dieOnErr(err)
    20   defer watcher.Close()
    21 
    22   watchInit(watcher)
    23   err = walkAndWatch(watcher, filepath.Join(home, "go"))
    24   dieOnErr(err)
    25 
    26   done := make(chan bool)
    27   <-done
    28 }
    29 
    30 func walkAndWatch(watcher *fsnotify.Watcher, root string) error {
    31   return filepath.Walk(root,
    32     func(path string, info os.FileInfo, err error) error {
    33       dieOnErr(err)
    34       if info.IsDir() {
    35         log.Printf("Adding watch: %s\n", path)
    36         err := watcher.Add(path)
    37         dieOnErr(err)
    38       }
    39       return nil
    40     })
    41 }
    42 
    43 func eventAsString(event fsnotify.Event) string {
    44   info, err := os.Stat(event.Name)
    45   if err != nil {
    46       return ""
    47   }
    48   evShort := (strings.ToLower(event.Op.String()))[0:2]
    49   dirParts := strings.Split(event.Name, "/")
    50   pathShort := event.Name
    51   if len(dirParts) > 3 {
    52     pathShort = filepath.Join(dirParts[len(dirParts)-3 : len(dirParts)]...)
    53   }
    54   return fmt.Sprintf("%s %s %d", evShort, pathShort, info.Size())
    55 }
    56 
    57 func watchInit(watcher *fsnotify.Watcher) {
    58   go func() {
    59     for {
    60       select {
    61       case event, ok := <-watcher.Events:
    62         if !ok {
    63           return
    64         }
    65         if event.Op&fsnotify.Rename == fsnotify.Rename ||
    66           event.Op&fsnotify.Remove == fsnotify.Remove {
    67           continue
    68         }
    69         log.Printf("%s\n", eventAsString(event))
    70         info, err := os.Stat(event.Name)
    71 	if err != nil {
    72 	    continue
    73 	}
    74         if info.IsDir() {
    75 	  err := walkAndWatch(watcher, event.Name)
    76           dieOnErr(err)
    77         }
    78       case err, _ := <-watcher.Errors:
    79         panic(err)
    80       }
    81     }
    82   }()
    83 }
    84 
    85 func dieOnErr(err error) {
    86   if err != nil {
    87     panic(err)
    88   }
    89 }

Die Callback-Funktion prüft nun bei jedem gefundenen Eintrag unterhalb des ~/go-Verzeichnisses mit IsDir(), ob es sich um ein Directory handelt, und falls ja, setzt es mit Add() einen Watcher darauf auf. Jeder dieser Watcher verbrät unter Linux einen File-Deskriptor, von denen das Betriebssystem keinen unbegrenzten Vorrat vorhält. Das Kommando ulimit -n zeigt die verfügbare Anzahl an und erlaubt dem Administrator, sie hochzuschrauben. Normalerweise reichen aber die voreingestellten 1024 mehr als aus.

Was im Falle eines eintreffenden inotify-Events passiert, definiert das Hauptprogramm in Zeile 22 mit dem Aufruf von watchInit() vor, das ab Zeile 51 im Listing steht. Dort wartet eine asynchron laufende Go-Routine auf Events allen definierten Watchern. Handelt es sich um das Ereignis eines neu generierten Verzeichnisses innerhalb der überwachten Dateistruktur, setzt Zeile 67 darauf ebenfalls einen Watcher an. Wegen eventuell doppelt defininierter Watcher braucht sich das Programm jedoch keine Gedanken zu machen, fsnotify ist schlau genug, Duplikate zu ignorieren.

Kopf-an-Kopf-Rennen

Allerdings ist dieses Verfahren nicht ganz zuverlässig: Bekommt der Tracker die Geburt eines neuen Verzeichnisses mit, muss es schnell einen Watcher darauf aufsetzen, um zukünftige Änderungen darin mitzubekommen. Erzeugt aber eine Applikation erst ein Verzeichnis, um dann sofort darin Dateien anzulegen, kann sie dem Tracker eventuell zuvorkommen, und letzterer bekommt die Änderung nicht mit.

Außerdem verfolgt das ursprünglich aufgerufene Walk() zum Einsammeln aller Unterverzeichnisse keine symbolischen Links weiter, wer das möchte, muss diese mit der Funktion EvalSymlinks() auflösen, aber dabei aufpassen wie ein Haftelmacher, damit der Walker sich nicht in einer Endlosschleife festfrisst.

Events, die die Umbenennung oder Löschung eines Eintrags melden, filtert die if-Bedingung in Zeile 59 heraus, denn ein os.Stat() auf einen derartigen Eintrag würde fehlschlagen. Alle anderen Events druckt Zeile 63 schön formatiert aus. Alles was der Watcher während des Laufs des Compilers mit go build fswatch.go so findet, zeigt Abbildung 3. Der Watcher meldet das Anlegen etlicher Cache-Verzeichnisse, um den von Github heruntergeladenen fsnotify-Code zu übersetzen und einzubinden. Das Kürzel cr steht hierbei für die Action "Create", andere Meldungen tragen ch für chmod wenn der Compiler die Zugangsbits manipuliert. Zieht sich also das Compilieren eines länglichen Go-Programms mit vielen Abhängigkeiten hin, weiß der User mit diesem Helferlein genau, was der Compiler treibt und kann deswegen ungefähr abschätzen, wie lange es voraussichtlich noch dauern wird.

Abbildung 3: Während der Go-Compiler Sourcen von Github lädt, um ein Binary zu kompilieren, verfolgt fsnotify die dabei neu erzeugte Dateien.

Um die aufgetretenen Ereignisse sauber zu protokollieren, formatiert sie die Funktion eventAsString() ab Zeile 39 noch rudimentär um. Namen von Events kürzt Zeile 42 auf ihre ersten zwei Zeichen und macht ummit ToLower() Kleinbuchstaben daraus. Ellenlange Verzeichnispfade teilt Zeile 43 in ihre Bestandteile auf und Zeile 46 kürzt sie auf die letzten drei Teilpfade, falls sie deren mehr findet. Mit der Array-Slice-Syntax [m:n] extrahiert sie die letzten drei mit len(dirPaths)-3 für m und len(dirParts) für n, wobei das Element am Index m definitionsgemäß im Ergebnis enthalten ist und das für n nicht. Da Join() aus dem filepath-Paket nicht etwa einen Array von Teilpfaden zusammenfügt, sondern eine variable Anzahl von Einzelelementen, machen die abschließenden drei Punkte "..." aus dem vom Slice-Operator kommenden Array-Slice eine ausgeflachte Liste von Einzelelementen.

Das ganze ließe sich nun schön in eine UI einbetten, die zu jedem Compilerlauf den User stetig mit Updates unterhält, sodass dieser sofort merkt, ob nur das Netzwerk hängt oder der Vorgang wegen Code-Bloat tatsächlich so lange dauert.

Infos

[1]

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

[2]

Michael Schilli, "Auferstanden aus Archiven": Linux-Magazin 06/01, S.XXX, <U>https://www.linux-magazin.de/ausgaben/2006/01/auferstanden-aus-archiven/<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