2 points par GN⁺ 2024-11-30 | 1 commentaires | Partager sur WhatsApp
  • Les pipelines qui enchaînent plusieurs commandes avec une sortie qui arrive lentement, comme tail -f /some/log/file | grep thing1 | grep thing2, peuvent sembler bloqués alors qu’en réalité ce n’est pas le cas : une commande intermédiaire accumule la sortie dans un tampon
  • grep et beaucoup de programmes vérifient avec isatty si stdout est un terminal ; si oui, ils utilisent un buffering par ligne, et si c’est un pipe ou un fichier, un buffering par blocs d’environ 8 Ko
  • tail, cat et tee sont des exemples qui ne bufferisent pas leur sortie, mais les options pour atténuer le buffering varient selon les commandes, comme grep --line-buffered, sed -u, tcpdump -l, jq -u, tr -u
  • Si on interrompt un pipeline avec Ctrl-C, la sortie restée dans le tampon de programmes comme tcpdump peut être perdue ; avec kill -TERM $PID, le tampon peut être vidé et la sortie devenir visible
  • En pratique, les solutions sont d’utiliser des commandes qui se terminent vite, grep --line-buffered, un awk unique ou un grep plus complexe, stdbuf, unbuffer, mais il faut vérifier leurs conditions de fonctionnement et leurs effets de bord

Pourquoi un pipe peut sembler bloqué

  • Quand des lignes sont ajoutées lentement à un fichier log, le pipeline suivant peut ne rien afficher même s’il y a des correspondances
    • tail -f /some/log/file | grep thing1 | grep thing2
  • La cause n’est pas le pipe lui-même, mais le grep thing1 intermédiaire, qui n’écrit pas immédiatement ses résultats et les stocke dans un tampon
  • Si un programme écrivait immédiatement à chaque fois, cela augmenterait le nombre d’appels système ; pour des raisons de performance, il accumule donc une certaine quantité de données avant d’écrire dans un pipe ou un fichier
  • Dans cet exemple, grep thing1 peut attendre qu’environ 8 Ko de sortie soient accumulés, et avec un log lent cette condition peut pratiquement ne jamais se produire

Un mode de sortie différent entre terminal et pipe

  • tail -f file | grep thing fonctionne bien, mais si on ajoute un deuxième grep derrière, la sortie peut sembler s’arrêter
  • grep et beaucoup de programmes vérifient avec la fonction isatty si stdout est un terminal
    • si stdout est un terminal, ils utilisent un buffering par ligne et affichent immédiatement ligne par ligne
    • si stdout est un pipe ou un fichier, ils utilisent un buffering par blocs et n’émettent la sortie qu’une fois une certaine taille atteinte
  • Ainsi, si grep écrit directement vers un terminal, les lignes apparaissent tout de suite, mais s’il écrit dans un pipe vers une commande suivante, elles peuvent ne pas apparaître
  • La taille du tampon varie selon les programmes
    • grep délègue le buffering à la libc, et la taille dans la libc est définie par la variable BUFSIZ
    • l’emplacement de cette définition dans glibc se trouve dans stdio.h
  • Le fait de ne pas utiliser un tampon de sortie de 8 Ko quand on écrit vers un terminal n’est pas une loi de la physique ; un programme pourrait l’implémenter ainsi s’il le voulait, mais ce serait un comportement très étrange

Un comportement de buffering qui varie selon les commandes

  • Le buffering de sortie est pénible parce que l’utilisateur doit se souvenir de quelles commandes bufferisent leur sortie dans un pipe
  • Exemples de commandes qui ne bufferisent pas leur sortie
    • tail
    • cat
    • tee
  • Commandes courantes qui bufferisent en écrivant dans un pipe, avec des moyens d’atténuation
    • grep : --line-buffered
    • sed : -u
    • awk : fonction fflush()
    • tcpdump : -l
    • jq : -u
    • tr : -u
    • cut : impossible de désactiver le buffering
  • Pour des commandes comme sort, qui ne peuvent travailler qu’une fois toute l’entrée reçue, la question du buffering n’a pas vraiment d’importance en pratique
  • L’auteur a essayé de tester à la fois les versions Mac OS et GNU, mais comme il existe beaucoup de variantes, quelques erreurs restent possibles

La sortie par défaut des langages de programmation est elle aussi bufferisée

  • Dans certains langages, la sortie du print par défaut est elle aussi bufferisée lorsqu’elle est envoyée dans un pipe
  • Moyens de désactivation selon le langage
    • C : setvbuf
    • Python : python -u, PYTHONUNBUFFERED=1, sys.stdout.reconfigure(line_buffering=False), print(x, flush=True)
    • Ruby : STDOUT.sync = true
    • Perl : $| = 1
  • Ce comportement par défaut semble conçu pour rendre les fonctions de sortie standard plus rapides dans les traitements batch
  • Le buffering peut aussi dépendre de la manière d’écrire la sortie
    • en C++, cout << "hello\n" bufferise quand la sortie va dans un pipe
    • cout << "hello" << endl vide le tampon

Différences avec Ctrl-C et la redirection vers un fichier

  • Si on connecte la sortie de tcpdump à grep comme ci-dessous et qu’on oublie -l, la sortie peut rester dans un tampon
    • sudo tcpdump -ni any port 53 | grep example.com
  • Idéalement, on pourrait s’attendre à ce qu’en appuyant sur Ctrl-C, tcpdump vide son tampon et que grep traite enfin cette sortie manquante
  • En pratique, au moment où les programmes se terminent, la sortie restée dans le tampon de tcpdump est perdue
  • D’après une vérification avec strace, grep reçoit SIGINT avant tcpdump, donc même si tcpdump tente de vider son tampon, grep peut déjà être mort
  • Une solution de contournement consiste à trouver le PID de tcpdump et exécuter kill -TERM $PID ; tcpdump peut alors vider son tampon et rendre la sortie visible
  • La redirection vers un fichier bufferise aussi
    • sudo tcpdump -ni any port 53 > output.txt
  • Mais contrairement au cas où Ctrl-C peut faire perdre complètement le contenu du tampon, avec une redirection vers un fichier, l’expérience montre que ce contenu est souvent écrit dans le fichier avant la fin du programme
  • Il n’est toutefois pas certain qu’on puisse toujours se fier à ce comportement

Cinq façons d’éviter le buffering

  • Remplacer par un programme qui se termine vite

    • On peut éviter la situation même d’une écriture lente dans un pipe en remplaçant la commande par une autre qui se termine rapidement
    • Exemple
      • cat /some/log/file | grep thing1 | grep thing2 | tail
    • Ce n’est pas exactement le même comportement que la commande initiale avec tail -f, mais cela évite les problèmes de buffering complexes
  • Utiliser l’option de buffering par ligne de grep

    • grep dispose d’un flag pour éviter ce problème de buffering
    • Exemple
      • tail -f /some/log/file | grep --line-buffered thing1 | grep thing2
  • Fusionner avec awk ou un grep plus complexe

    • Une suite de plusieurs grep peut être remplacée par un seul awk
      • tail -f /some/log/file | awk '/thing1/ && /thing2/'
    • Ou par un grep avec une expression régulière plus complexe
      • tail -f /some/log/file | grep -E 'thing1.*thing2'
    • awk bufferise lui aussi, donc pour que cette méthode fonctionne, awk doit être la dernière commande du pipeline
  • Utiliser stdbuf

    • stdbuf utilise LD_PRELOAD pour désactiver le buffering de la libc
    • Exemple pour désactiver le buffering de sortie
      • tail -f /some/log/file | stdbuf -o0 grep thing1 | grep thing2
    • Comme les solutions basées sur LD_PRELOAD, sa fiabilité est limitée
      • ça ne fonctionne pas avec les binaires statiques
      • ça peut ne pas fonctionner si le programme n’utilise pas le buffering de la libc
      • sur Mac OS, ce n’est pas toujours fiable
    • Pour plus de détails, voir l’explication de Harry Marr : How stdbuf works
  • Utiliser unbuffer

    • unbuffer program force le programme à croire que sa sortie est un TTY, ce qui le fait bufferiser moins comme sur un TTY normal et peut aussi activer la couleur
    • Exemple
      • tail -f /some/log/file | unbuffer grep thing1 | grep thing2
    • Contrairement à stdbuf, cela fonctionne toujours, mais avec des effets de bord possibles
      • par exemple, grep thing1 peut ajouter des couleurs aux lignes correspondantes
    • unbuffer fait partie du paquet expect

Situations où le problème apparaît le plus souvent et idée de variable d’environnement

  • Ce problème apparaît surtout avec des programmes qui envoient les données lentement dans un pipe
  • Exemples
    • tcpdump
    • tail -f
    • la surveillance de logs du type kubectl logs
    • la sortie de calculs lents
  • À l’image de PYTHONUNBUFFERED pour Python, il pourrait être utile d’avoir une variable d’environnement standard pour désactiver le buffering
  • L’idée vient d’un article de blog de 2018 par Mark Dominus et d’un billet de suivi
  • Comme exemple de nom, on pourrait imaginer NO_BUFFER, à la manière de NO_COLOR
  • La conception est délicate
  • L’auteur se demande aussi s’il existe des programmes qui vident automatiquement leur tampon de sortie toutes les secondes environ, mais n’en connaît pas, et cela pourrait avoir des inconvénients

Ce qui n’est pas couvert

  • La différence entre buffering par ligne et sortie totalement non bufferisée n’est pas traitée
  • La différence entre le buffering de stderr et celui de stdout n’est pas traitée
  • Ce texte ne parle que du buffering qui se produit à l’intérieur des programmes
  • Le pilote TTY du système d’exploitation fait parfois lui aussi un peu de buffering
  • Les autres raisons de vider explicitement la sortie, en dehors de l’écriture dans un pipe, ne sont pas abordées

1 commentaires

 
GN⁺ 2024-11-30
Commentaires de Hacker News
  • Une approche avec buffering devrait presque toujours être de type « seuil ou timeout » : on flush lorsqu’un seuil en octets est atteint, ou après un certain délai dès qu’il y a au moins 1 octet
    C’est une approche courante dans les interfaces matérielles pour résoudre des problèmes similaires
    Dans ce cas, la bibliothèque qui bufferise en espace utilisateur devrait configurer un timer approprié lorsqu’elle place des données dans le tampon pour la première fois. La valeur du timeout pourrait être passée en argument, fixée à quelque chose de court à l’échelle humaine, autour de 1 à 100 ms, rendue proportionnelle à {bande passante / seuil}, ou choisie de sorte que le surcoût des appels système ne dépasse pas 0,1 % du temps total
    Cette approche s’applique non seulement aux écritures, mais aussi aux lectures. Si l’on fait des lectures groupées ou fusionnées, il faut un mécanisme similaire, mais cela dépend davantage de la conception du canal, car celui-ci doit offrir un moyen efficace d’interroger les « données en attente » ou d’en être notifié. Côté matériel, des techniques comme la coalescence d’interruptions sont courantes

    • Je pense que c’est la bonne direction, mais si la libc configure des timers automatiques, le comportement attendu change, ce qui crée beaucoup de problèmes délicats
      Les erreurs d’E/S peuvent survenir non seulement au moment de l’écriture, mais à n’importe quel moment, et plusieurs appels système peuvent être interrompus par le timer, pas seulement ceux où le programme a placé son propre timer ou ceux où un signal arrive
      Si l’application et la libc configurent toutes deux des timers, cela peut aussi devenir confus. Les API de timers des noyaux modernes semblent meilleures que dans mes souvenirs, donc c’est peut-être moins pertinent, mais si une application bloque temporairement les signaux dans une section critique, cela affecte aussi les timers d’E/S
      À cause du moment et de la manière dont les signaux sont traités, il faut être encore plus prudent avec l’accès aux structures d’E/S
    • Gérer ce genre de timeout de manière transparente semble délicat sous les contraintes de POSIX et ISO C. Il faudra probablement une certaine coopération de la couche applicative
    • Les alarmes Linux classiques reposent sur les signaux, ce qui les rend très difficiles à gérer, et leur replanification oblige à repasser dans le noyau, avec un possible impact sur les performances
      Avec io_uring et des timers en espace utilisateur, cela passe beaucoup mieux à l’échelle, mais pour prendre en charge beaucoup de petites écritures rapides, il faut encore des astuces. Par exemple, au-delà d’environ 1 million d’opérations par seconde, le coût de gestion des timers commence à se voir, et pour atteindre 100 millions d’écritures par seconde, il a fallu des techniques assez étranges
    • Je ne suis pas d’accord. Ici, le buffering fait exactement ce qu’il est censé faire
      La cause du problème vient du mélange entre quelque chose qui devrait être interactif et un contrat qui ne suppose pas l’interaction. Par exemple, envoyer la sortie de suivi de tail dans un pipe
      À mon avis, il n’y a pas de vrai problème à résoudre. Pour reprendre une analogie matérielle, c’est comme une citerne qui collecte l’eau de pluie et ne la transfère que lorsqu’elle est pleine. Je ne sais pas quel exemple précis était visé, mais à ma connaissance, les flushs basés sur le temps ne sont pas courants dans le matériel
      La correction proposée rend le contrat beaucoup plus complexe
    • Je préfère un footgun prévisible. L’idée est bonne, mais cela devrait être un flag séparé, ce qui suppose alors d’en connaître l’existence
      Le problème n’est pas tant la sémantique elle-même que le fait de ne pas connaître la sémantique
  • Même après plus de 20 ans à travailler avec des systèmes NIX, je sais que ce genre de chose arrive, mais à chaque fois je ne m’en souviens qu’après être resté perplexe un bon moment à me demander pourquoi la sortie n’apparaît pas

  • À propos du passage disant en substance : « les articles récents deviennent assez longs, qui a vraiment envie de lire 3000 mots sur le buffering ? », personnellement, moi oui

    • Ça dépend de l’article
      Je pense que certains articles interminables ne sont que du remplissage pour l’optimisation SEO
    • Sur ce point, les résumés par IA peuvent être assez utiles. On peut faire résumer l’article puis vérifier le résultat
      On pourrait aussi avoir des sections TLDR et NTLDR, c’est-à-dire « trop long, mais lu quand même »
  • J’aimerais que tous les buffers soient flushés chaque fois que le CPU de l’ensemble du système devient idle
    Le buffering sert généralement à économiser du CPU. Si le CPU était infini, tous les buffers feraient 1 octet. Un buffer regroupe les données pour les traiter par lots, par souci d’efficacité
    Mais lorsque le CPU devient idle, il ne devrait pas rester du « travail à faire plus tard ». Dès que le scheduler du noyau passe en idle, il devrait envoyer à tous les processus un signal leur demandant de flusher leurs buffers

    • Idée intéressante. Cela dit, envoyer un signal à tous les processus semble extrêmement coûteux
      On ferait tout ce travail, puis on les pousserait à faire des appels système pour flusher les buffers. Peut-être pourrait-on ajouter un mécanisme permettant au noyau de connaître les buffers en espace utilisateur et d’y prélever directement les données lorsqu’il est idle
      Je me demande si cela ressemble, dans une certaine mesure, à io_uring https://man7.org/linux/man-pages/man3/io_uring_register_buff...
    • Belle idée, mais il vaudrait mieux ne pas tout faire d’un coup. Et sur les systèmes basse consommation, réveiller spéculativement des processus endormis pourrait aussi réduire l’efficacité
  • Cet article confond deux choses différentes : le mode sans buffer et le buffering par ligne
    Le mode sans buffer dégrade inutilement les performances et peut produire une sortie incorrecte lorsque plusieurs sources écrivent dans le même pipe. Des lignes suffisamment longues se mélangeront de toute façon, mais en pratique, la plupart des lignes de sortie font moins de 4096 octets, même avec des caractères de formatage/contrôle et des caractères des plans supplémentaires
    Le buffering par ligne est le comportement par défaut des terminaux et c’est aussi généralement le comportement souhaité avec les pipes. Il suffit d’exécuter chaque commande sous stdbuf -oL -eL. Les rares programmes qui veulent mettre à jour du contenu à l’intérieur d’une ligne doivent déjà effectuer des flushs manuels, donc ils se comportent correctement ici aussi
    Voici ce que fait réellement stdbuf :
    env -i \command -v stdbuf` -oL -eL `command -v env``

  • J’ai déjà écrit à ce sujet auparavant : https://world-playground-deceit.net/blog/2024/09/bourne_shel...
    Pour les commandes qui ne font pas de buffering, c’est dépendant de l’implémentation, ou bien, dans le cas de cat, c’est peut-être faux. Voir https://pubs.opengroup.org/onlinepubs/9799919799/utilities/c... et -u. C’est une vraie plaie que POSIX n’ait pas prévu de méthode officielle pour gérer ça.
    Il y a aussi un point non mentionné : le buffering en entrée, qui produit ce genre de résultat étrange :
    $ seq 5 | { v1=$(head -1); v2=$(head -1); printf '%s=%s\n' v1 "$v1" v2 "$v2"; }
    v1=1
    v2=
    Dans ce cas, la solution consiste à utiliser stdbuf -i0 head -1.

    • Je ne vois pas comment un processus qui lit depuis un pipe ou un socketpair pourrait imposer ce genre de contrainte au processus qui écrit, sauf à utiliser des hacks lourds comme ptrace().
      Il est peut-être possible d’ajuster la taille du buffer du pipe, mais je ne connais pas de convention selon laquelle les entrées/sorties standard du C devraient s’y conformer.
      Quoi qu’il en soit, dans ce cas, stdbuf ne semble pas aider :
      $ ./a | stdbuf -i0 -- cat
      #include
      #include
      int main(void) {
      for (;;) {
      printf("n");
      usleep(100000);
      }
      }
  • Il y a de bonnes raisons à l’existence des buffers. Afficher une sortie à l’écran est relativement très lent par rapport à l’écriture dans un buffer.
    Émettre les caractères un par un est extrêmement inefficace.
    C’est un vieux problème, qu’on rencontre souvent lorsqu’on travaille avec des UART. Il existe plusieurs solutions possibles : une approche par lignes, où la fin de la sortie est signalée par un caractère spécial comme un saut de ligne ; une approche par taille, où l’on attend d’atteindre une longueur donnée, par exemple 8 Ko ; ou une approche temporelle, où l’on émet toutes les X millisecondes.
    Chacune a ses avantages et ses inconvénients, et ce qui est optimal dépend de l’application. Je pense que l’article se trompe lorsqu’il dit que certains programmes n’utilisent pas de buffering. Ces programmes n’utilisent simplement pas une approche par taille évidente.

    • Cela fonctionne le mieux quand la couche qui connaît les contraintes se situe un ou deux niveaux au-dessus de l’interface.
      L’approche par lignes en est un exemple, mais elle nécessite un accord sur le caractère à utiliser. En général, c’est le saut de ligne.
    • Ce n’est pas seulement une question de coût du traitement de l’écriture réelle côté backend. Faire autant d’appels système vers /dev/null peut à lui seul tuer les performances.
  • Je fais de l’Unix depuis plus de 35 ans, mais je n’avais jamais vraiment compris complètement comment cela fonctionnait.
    J’ai apprécié l’explication globale du comportement du buffering à travers les différents systèmes et composants, et j’ai clairement appris quelque chose.

  • À propos du passage disant que « lorsqu’on appuie sur Ctrl-C dans un pipe, le contenu du buffer disparaît », j’imagine que la plupart des programmes vident leur buffer sur SIGINT.
    Cela dit, pour que cela se comporte ainsi dans un shell, il faudrait que SIGINT ne soit transmis qu’au premier programme du pipeline, et ce n’est probablement pas ce qui se passe réellement.

    • Dans mon souvenir, le dernier processus reçoit sigint et les autres reçoivent sigpipe.