2 Punkte von GN⁺ 2024-11-30 | 1 Kommentare | Auf WhatsApp teilen
  • Pipelines, die langsam eintreffende Ausgaben über mehrere Befehle verbinden, etwa tail -f /some/log/file | grep thing1 | grep thing2, bleiben nicht wirklich stehen; sie können leer wirken, weil ein Zwischenbefehl Ausgaben im Puffer sammelt
  • grep und viele Programme prüfen mit isatty, ob stdout ein Terminal ist; bei einem Terminal nutzen sie Zeilenpufferung, bei einer Pipe oder Datei ungefähr Blockpufferung in 8-KB-Einheiten
  • tail, cat und tee sind Beispiele für Befehle ohne Ausgabepufferung; Optionen zur Verringerung der Pufferung unterscheiden sich jedoch je nach Befehl, etwa grep --line-buffered, sed -u, tcpdump -l, jq -u, tr -u
  • Wenn man eine Pipeline mit Ctrl-C abbricht, können Ausgaben verschwinden, die noch im Puffer eines Programms wie tcpdump lagen; beendet man es mit kill -TERM $PID, kann der Puffer geflusht werden und die Ausgabe sichtbar werden
  • Praktische Lösungen sind der Wechsel zu schnell beendeten Befehlen, grep --line-buffered, ein einzelnes awk oder ein komplexeres grep, stdbuf, unbuffer usw.; bei allen sollte man jedoch Funktionsbedingungen und Nebenwirkungen prüfen

Warum Pipes so wirken, als würden sie hängen

  • Wenn Zeilen nur langsam an eine Logdatei angehängt werden, zeigt die folgende Pipeline möglicherweise keine Ausgabe, obwohl es Treffer gibt
    • tail -f /some/log/file | grep thing1 | grep thing2
  • Ursache ist nicht die Pipe selbst, sondern dass das zwischengeschaltete grep thing1 Ergebnisse nicht sofort schreibt, sondern im Puffer speichert
  • Würde ein Programm jedes Mal sofort schreiben, nähme die Zahl der Systemaufrufe zu; daher sammelt es aus Performancegründen eine gewisse Datenmenge und schreibt sie erst dann in eine Pipe oder Datei
  • In diesem Beispiel kann grep thing1 warten, bis sich ungefähr 8KB Ausgabe angesammelt haben; bei einem langsamen Log kann diese Bedingung praktisch nie eintreten

Unterschiedliche Ausgabeweise bei Terminal und Pipe

  • tail -f file | grep thing funktioniert gut, aber hängt man ein zweites grep dahinter, kann es so aussehen, als bliebe die Ausgabe stehen
  • grep und viele Programme prüfen mit der Funktion isatty, ob stdout ein Terminal ist
    • Wenn stdout ein Terminal ist, verwenden sie Zeilenpufferung und geben zeilenweise sofort aus
    • Wenn stdout eine Pipe oder Datei ist, verwenden sie Blockpufferung und geben erst aus, wenn sich Daten oberhalb einer bestimmten Größe angesammelt haben
  • Schreibt grep also direkt ins Terminal, erscheinen Zeilen sofort; schreibt es in eine Pipe zum nächsten Befehl, sind sie möglicherweise nicht sichtbar
  • Die Puffergröße unterscheidet sich je nach Programm
    • Bei grep übernimmt libc die Pufferung, und die Größe von libc ist über die Variable BUFSIZ definiert
    • Die Definition in glibc findet sich in stdio.h
  • Dass beim Schreiben in ein Terminal kein 8KB-Ausgabepuffer verwendet wird, ist kein Naturgesetz; ein Programm könnte es so implementieren, wenn es wollte, aber das käme einem sehr ungewöhnlichen Verhalten nahe

Unterschiedliches Pufferungsverhalten je nach Befehl

  • Ausgabepufferung ist deshalb schwierig, weil Nutzer sich merken müssen, welche Befehle bei Pipe-Ausgabe puffern
  • Beispiele für Befehle ohne Ausgabepufferung sind:
    • tail
    • cat
    • tee
  • Häufige Befehle, die beim Schreiben in eine Pipe Ausgaben puffern, und Möglichkeiten zur Abmilderung sind:
    • grep: --line-buffered
    • sed: -u
    • awk: Funktion fflush()
    • tcpdump: -l
    • jq: -u
    • tr: -u
    • cut: Pufferung kann nicht deaktiviert werden
  • Bei Befehlen wie sort, die erst arbeiten können, nachdem sie die gesamte Eingabe erhalten haben, ist die Frage der Pufferung praktisch nicht relevant
  • Es wurde versucht, sowohl Mac-OS- als auch GNU-Versionen zu testen, aber wegen der vielen Varianten können einzelne Fehler enthalten sein

Auch Standardausgaben von Programmiersprachen puffern

  • Die standardmäßige print-Ausgabe mancher Programmiersprachen puffert ebenfalls, wenn in eine Pipe geschrieben wird
  • Methoden zum Deaktivieren je nach Sprache:
    • C: setvbuf
    • Python: python -u, PYTHONUNBUFFERED=1, sys.stdout.reconfigure(line_buffering=False), print(x, flush=True)
    • Ruby: STDOUT.sync = true
    • Perl: $| = 1
  • Dieses Standardverhalten scheint darauf ausgelegt zu sein, die Standardausgabefunktionen bei Batch-Verarbeitung schneller zu machen
  • Je nach Ausgabemethode kann sich unterscheiden, ob gepuffert wird
    • In C++ puffert cout << "hello\n" beim Schreiben in eine Pipe
    • cout << "hello" << endl flusht die Ausgabe

Unterschiede bei Ctrl-C und Datei-Redirects

  • Wenn man wie folgt die Ausgabe von tcpdump an grep weiterleitet und -l vergisst, kann Ausgabe im Puffer verbleiben
    • sudo tcpdump -ni any port 53 | grep example.com
  • Idealerweise könnte man erwarten, dass tcpdump beim Drücken von Ctrl-C den Puffer flusht und grep sucht, sodass die fehlende Ausgabe sichtbar wird
  • In der Praxis geht die Ausgabe, die im Puffer von tcpdump lag, beim Beenden der Programme verloren
  • Eine Prüfung mit strace zeigte, dass grep vor tcpdump SIGINT erhält; selbst wenn tcpdump also flushen möchte, kann grep bereits beendet sein
  • Als Workaround kann man die PID von tcpdump ermitteln und kill -TERM $PID ausführen; dann kann tcpdump den Puffer flushen und die Ausgabe sichtbar werden
  • Auch Datei-Redirects puffern
    • sudo tcpdump -ni any port 53 > output.txt
  • Anders als bei dem Problem, dass Ctrl-C den Pufferinhalt vollständig verwerfen kann, wird bei Datei-Redirects der Pufferinhalt erfahrungsgemäß oft vor dem Programmende in die Datei geschrieben
  • Ob man sich immer auf dieses Verhalten verlassen kann, ist unklar

Fünf Wege, Pufferung zu vermeiden

  • Zu einem schnell beendeten Programm wechseln

    • Man kann die Situation selbst vermeiden, in der langsam in eine Pipe geschrieben wird, und zu einem schnell beendeten Befehl wechseln
    • Beispiel:
      • cat /some/log/file | grep thing1 | grep thing2 | tail
    • Das verhält sich nicht genauso wie der ursprüngliche tail -f-Befehl, kann aber komplexe Pufferungsprobleme vermeiden
  • Die Zeilenpuffer-Option von grep verwenden

    • grep hat ein Flag, um Pufferung zu vermeiden
    • Beispiel:
      • tail -f /some/log/file | grep --line-buffered thing1 | grep thing2
  • Mit awk oder einem komplexeren grep zusammenfassen

    • Situationen mit mehreren grep-Aufrufen lassen sich durch ein einzelnes awk ersetzen
      • tail -f /some/log/file | awk '/thing1/ && /thing2/'
    • Oder durch grep mit einer komplexeren Regex
      • tail -f /some/log/file | grep -E 'thing1.*thing2'
    • Da auch awk puffert, muss awk der letzte Befehl in der Pipeline sein, damit diese Methode funktioniert
  • stdbuf verwenden

    • stdbuf nutzt LD_PRELOAD, um die Pufferung von libc abzuschalten
    • Beispiel zum Abschalten der Ausgabepufferung:
      • tail -f /some/log/file | stdbuf -o0 grep thing1 | grep thing2
    • Wie bei Lösungen auf Basis von LD_PRELOAD ist die Zuverlässigkeit begrenzt
      • Funktioniert nicht bei statischen Binaries
      • Funktioniert möglicherweise nicht, wenn das Programm keine libc-Pufferung verwendet
      • Funktioniert unter Mac OS nicht immer
    • Eine verwandte Erklärung ist Harry Marrs How stdbuf works
  • unbuffer verwenden

    • unbuffer program erzwingt, dass die Programmausgabe wie ein TTY wirkt, sodass wie bei einem normalen TTY weniger gepuffert wird und etwa farbige Ausgabe verwendet wird
    • Beispiel:
      • tail -f /some/log/file | unbuffer grep thing1 | grep thing2
    • Anders als stdbuf funktioniert es immer, kann aber unerwünschte Nebenwirkungen haben
      • Zum Beispiel könnte grep thing1 Treffer farbig markieren
    • unbuffer ist im Paket expect enthalten

Situationen, in denen das Problem vor allem auftritt, und Idee einer Umgebungsvariable

  • Dieses Problem tritt vor allem bei Programmen auf, die Daten langsam in eine Pipe fließen lassen
  • Beispiele:
    • tcpdump
    • tail -f
    • Log-Beobachtung etwa mit kubectl logs
    • Ausgaben langsamer Berechnungen
  • Eine standardisierte Umgebungsvariable zum Abschalten der Pufferung, ähnlich wie Pythons PYTHONUNBUFFERED, könnte nützlich sein
  • Die Idee stammt aus Mark Dominus’ Blogbeitrag von 2018 und dem Folgebeitrag
  • Als Name wäre analog zu NO_COLOR etwa NO_BUFFER denkbar
  • Das Design ist schwierig
    • NETBSD hat Umgebungsvariablen wie STDBUF, STDBUF1 usw., die viel Kontrolle bieten
    • Die meisten Entwickler möchten für einen vergleichsweise kleinen Edge Case möglicherweise nicht mehrere Umgebungsvariablen implementieren
  • Es wäre auch interessant, ob es Programme gibt, die Ausgabepuffer automatisch in Intervallen wie einer Sekunde flushen; mir fällt jedoch keines ein, und es könnte Nachteile haben

Nicht behandelte Bereiche

  • Der Unterschied zwischen Zeilenpufferung und vollständig ungepufferter Ausgabe wird ausgelassen
  • Der Unterschied zwischen stderr-Pufferung und stdout-Pufferung wird ausgelassen
  • Dieser Inhalt behandelt nur Pufferung, die innerhalb von Programmen entsteht
  • Auch der TTY-Treiber des Betriebssystems puffert gelegentlich ein wenig
  • Andere Gründe außerhalb des Schreibens in eine Pipe, warum Ausgaben geflusht werden müssen, werden ausgelassen

1 Kommentare

 
GN⁺ 2024-11-30
Hacker-News-Kommentare
  • Ein gepufferter Ansatz sollte fast immer nach dem Prinzip „Schwellenwert oder Timeout“ arbeiten: Flush, wenn ein Byte-Schwellenwert erreicht ist oder nach einer bestimmten Zeit, sofern mindestens 1 Byte vorhanden ist.
    In Hardware-Interfaces ist das ein übliches Muster, um ähnliche Probleme zu lösen.
    In diesem Fall müsste die Library, die im User Space puffert, einen passenden Timer setzen, sobald sie die ersten Daten in den Puffer legt. Der Timeout-Wert könnte als Argument übergeben werden, auf für Menschen kurze 1–100 ms gesetzt werden, proportional zu {Bandbreite / Schwellenwert} sein oder so gewählt werden, dass der System-Call-Overhead nicht mehr als 0,1 % der Gesamtzeit ausmacht.
    Dieser Ansatz gilt nicht nur fürs Schreiben, sondern auch fürs Lesen. Wenn man gebündelte oder zusammengeführte Reads macht, braucht man etwas Ähnliches; allerdings muss der Datenkanal eine effiziente Möglichkeit bieten, „wartende Daten“ abzufragen oder darüber benachrichtigt zu werden, weshalb das stärker vom Channel-Design abhängt. In Hardware sind Verfahren wie Interrupt Coalescing üblich.

    • Ich denke, diese Richtung stimmt, aber wenn libc automatische Timer setzt, ändert das das erwartete Verhalten und erzeugt viele knifflige Probleme.
      I/O-Fehler können dann nicht nur beim Schreiben, sondern jederzeit auftreten, und verschiedene System-Calls können durch den Timer unterbrochen werden – nicht nur Stellen, an denen das Programm selbst Timer gesetzt hat oder Signale eintreffen.
      Wenn sowohl die Anwendung als auch libc Timer setzen, kann das ebenfalls Verwirrung stiften. Die heutigen Kernel-Timer-APIs sehen besser aus, als ich sie in Erinnerung hatte, daher ist das vielleicht weniger relevant; aber wenn eine Anwendung in kritischen Abschnitten Signale kurz blockiert, wirkt sich das auch auf I/O-Timer aus.
      Wegen Zeitpunkt und Art der Signalbehandlung müsste man beim Zugriff auf I/O-Strukturen vorsichtiger sein.
    • Solche Timeouts transparent zu handhaben, dürfte unter den Einschränkungen von POSIX und ISO C schwierig sein. Eine gewisse Zusammenarbeit auf Anwendungsebene scheint nötig.
    • Normale Linux-Alarme sind signalbasiert und daher sehr schwer zu verwalten; fürs Rescheduling muss man in den Kernel, was Performance kosten kann.
      Mit io_uring und User-Space-Timern skaliert das deutlich besser, aber um sehr viele schnelle kleine Writes zu unterstützen, braucht man immer noch Tricks. Ab etwa 1 Million pro Sekunde werden beispielsweise die Kosten der Timer-Verwaltung zunehmend sichtbar, und um bis auf 100 Millionen Writes pro Sekunde zu kommen, waren ziemlich ungewöhnliche Techniken nötig.
    • Dem kann ich schwer zustimmen. Das Buffering tut hier genau das, was es tun soll.
      Die Ursache des Problems liegt darin, dass Interaktives mit einem Vertrag vermischt wird, der nicht auf Interaktion ausgelegt ist. Zum Beispiel, wenn man die Follow-Ausgabe von tail in eine Pipe schickt.
      Ich sehe kein echtes Problem, das gelöst werden müsste. Als Hardware-Analogie wäre das ein Wassertank, der Regenwasser sammelt und erst dann weiterleitet, wenn er voll ist. Ich weiß nicht, welche Beispiele gemeint sind, aber soweit ich weiß, ist zeitbasiertes Flushen in Hardware nicht üblich.
      Der vorgeschlagene Fix macht den Vertrag deutlich komplizierter.
    • Ich halte eine vorhersehbare Footgun für besser. Die Idee ist gut, sollte aber ein separates Flag sein; dann muss man allerdings wissen, dass es existiert.
      Das Problem liegt weniger in der Semantik selbst als darin, die Semantik nicht zu kennen.
  • Ich arbeite seit über 20 Jahren mit NIX-Systemen und weiß, dass so etwas passiert, aber jedes Mal fällt es mir erst wieder ein, nachdem ich lange gerätselt habe, warum keine Ausgabe erscheint.

  • Zu der Stelle „Die letzten Beiträge werden ziemlich lang; will wirklich jemand einen 3000-Wörter-Artikel über Buffering lesen?“: Ich persönlich will das lesen.

    • Kommt auf den Artikel an.
      Manchmal ist ein ausufernder Text auch nur Ballast für Suchmaschinenoptimierung.
    • Bei so etwas finde ich KI-Zusammenfassungen ziemlich hilfreich. Man lässt den Artikel zusammenfassen und prüft das dann.
      Man könnte auch Abschnitte wie TLDR und NTLDR haben, also „lang, aber gelesen“.
  • Es wäre schön, wenn alle Puffer geleert würden, sobald die CPU im gesamten System idle ist.
    Buffering ist im Wesentlichen eine Technik, um CPU zu sparen. Wenn CPU unendlich verfügbar wäre, wäre jeder Puffer 1 Byte groß. Puffer sammeln Daten, um sie aus Effizienzgründen gebündelt zu verarbeiten.
    Wenn die CPU aber idle wird, sollte keine „später zu erledigende Arbeit“ übrig bleiben. Sobald der Kernel-Scheduler idle wird, sollte er allen Prozessen ein Signal schicken: Puffer flushen.

    • Interessante Idee. Allerdings wirkt es extrem teuer, allen Prozessen ein Signal zu schicken.
      Man würde all diese Arbeit erledigen und sie dann erneut System-Calls ausführen lassen, um ihre Puffer zu flushen. Vielleicht könnte man einen Mechanismus ergänzen, mit dem der Kernel User-Space-Puffer kennt und sie im Idle-Zustand direkt von dort holt.
      Ich frage mich, ob das in gewisser Weise so etwas wie io_uring https://man7.org/linux/man-pages/man3/io_uring_register_buff... ist.
    • Coole Idee, aber vermutlich sollte man nicht alles auf einmal tun. Und auf stromsparenden Systemen könnte es die Effizienz senken, wenn man schlafende Prozesse spekulativ aufweckt.
  • Der Artikel verwechselt zwei verschiedene Dinge: ungepuffert und zeilengepuffert.
    Ungepuffert verschlechtert die Performance unnötig und kann falsche Ausgaben erzeugen, wenn mehrere Quellen in dieselbe Pipe schreiben. Ausreichend lange Zeilen werden sich ohnehin vermischen, aber die meisten realen Ausgabezeilen sind selbst mit Format-/Steuerzeichen und Zeichen aus Supplementary Planes kürzer als 4096 Byte.
    Zeilenpufferung ist der Standard für Terminals und in der Regel auch bei Pipes das gewünschte Verhalten. Man kann jeden Befehl unter stdbuf -oL -eL ausführen. Die seltenen Programme, die Aktualisierungen innerhalb einer Zeile wollen, müssen ohnehin manuell flushen und verhalten sich daher auch hier korrekt.
    Was stdbuf tatsächlich macht, kann man etwa so sehen:
    env -i \command -v stdbuf` -oL -eL `command -v env``

  • Ich habe früher schon einmal über dieses Problem geschrieben: https://world-playground-deceit.net/blog/2024/09/bourne_shel...
    Bei Befehlen ohne Buffering ist das implementationsabhängig oder, im Fall von cat, möglicherweise falsch. Siehe https://pubs.opengroup.org/onlinepubs/9799919799/utilities/c... und -u. Es ist sehr schmerzhaft, dass POSIX keinen offiziellen Weg aufgenommen hat, das zu steuern.
    Nicht erwähnt wurde auch Input-Buffering, das zu solchen merkwürdigen Ergebnissen führt:
    $ seq 5 | { v1=$(head -1); v2=$(head -1); printf '%s=%s\n' v1 "$v1" v2 "$v2"; }
    v1=1
    v2=
    Die Lösung ist in diesem Fall, stdbuf -i0 head -1 zu verwenden.

    • Ich glaube nicht, dass ein Prozess, der aus einer Pipe oder einem socketpair liest, dem schreibenden Prozess solche Einschränkungen aufzwingen kann. Außer man nutzt schwere Hacks wie ptrace().
      Vielleicht kann man die Größe des Pipe-Buffers anpassen, aber ich kenne keine Konvention, nach der die Standard-C-Ein-/Ausgabe dem folgen müsste.
      Jedenfalls scheint stdbuf in diesem Fall nicht zu helfen:
      $ ./a | stdbuf -i0 -- cat
      #include
      #include
      int main(void) {
      for (;;) {
      printf("n");
      usleep(100000);
      }
      }
  • Es gibt gute Gründe dafür, dass es Buffer gibt. Ausgaben auf den Bildschirm zu schreiben ist im Vergleich zum Schreiben in einen Buffer relativ sehr langsam.
    Zeichen einzeln auszugeben ist extrem ineffizient.
    Das ist ein altes Problem und begegnet einem oft beim Umgang mit UARTs. Es gibt mehrere mögliche Lösungen: ein zeilenbasierter Ansatz, bei dem ein spezielles Zeichen wie ein Zeilenumbruch das Ende der Ausgabe markiert; ein längenbasierter Ansatz, bei dem man wartet, bis eine Länge wie 8 KB erreicht ist; oder ein zeitbasierter Ansatz, bei dem alle X Millisekunden ausgegeben wird.
    Jeder Ansatz hat Vor- und Nachteile, und was optimal ist, hängt von der Anwendung ab. Ich halte die Stelle im Artikel, an der behauptet wird, manche Programme verwendeten kein Buffering, für falsch. Diese Programme verwenden lediglich offensichtlich keinen längenbasierten Ansatz.

    • Am besten funktioniert es, wenn man die Constraints ein oder zwei Schichten oberhalb des Interfaces kennt.
      Der zeilenbasierte Ansatz ist so eine Methode, erfordert aber eine Einigung darüber, welches Zeichen verwendet wird. Üblicherweise ist das der Zeilenumbruch.
    • Es geht nicht nur um die Kosten dafür, dass das Backend die eigentlichen Schreibvorgänge erledigt. Schon allein so viele System Calls nach /dev/null zu machen, kann die Performance massiv ruinieren.
  • Ich benutze Unix seit über 35 Jahren, habe aber nie vollständig verstanden, wie das funktioniert.
    Es war gut, eine Gesamtbeschreibung des Buffering-Verhaltens über mehrere Systeme und Komponenten hinweg zu bekommen, und ich habe definitiv etwas gelernt.

  • Zu der Stelle „Wenn man in einer Pipe Ctrl-C drückt, geht der Buffer-Inhalt verloren“: Ich würde vermuten, dass die meisten Programme bei SIGINT den Buffer flushen.
    Damit das in der Shell so funktioniert, müsste SIGINT allerdings nur an das erste Programm in der Pipeline weitergegeben werden; vermutlich ist das in der Praxis aber nicht so.

    • Ich erinnere mich, dass der letzte Prozess sigint bekommt und die übrigen sigpipe.