- 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 grepet beaucoup de programmes vérifient avecisattysi 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 Kotail,catetteesont des exemples qui ne bufferisent pas leur sortie, mais les options pour atténuer le buffering varient selon les commandes, commegrep --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 commetcpdumppeut être perdue ; aveckill -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, unawkunique ou ungrepplus 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 thing1intermé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 thing1peut 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 thingfonctionne bien, mais si on ajoute un deuxièmegrepderrière, la sortie peut sembler s’arrêtergrepet beaucoup de programmes vérifient avec la fonctionisattysi 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
grepdélègue le buffering à la libc, et la taille dans la libc est définie par la variableBUFSIZ- 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
tailcattee
- Commandes courantes qui bufferisent en écrivant dans un pipe, avec des moyens d’atténuation
grep:--line-bufferedsed:-uawk: fonctionfflush()tcpdump:-ljq:-utr:-ucut: 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
printpar 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
- C :
- 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" << endlvide le tampon
- en C++,
Différences avec Ctrl-C et la redirection vers un fichier
- Si on connecte la sortie de
tcpdumpàgrepcomme ci-dessous et qu’on oublie-l, la sortie peut rester dans un tamponsudo tcpdump -ni any port 53 | grep example.com
- Idéalement, on pourrait s’attendre à ce qu’en appuyant sur
Ctrl-C,tcpdumpvide son tampon et quegreptraite enfin cette sortie manquante - En pratique, au moment où les programmes se terminent, la sortie restée dans le tampon de
tcpdumpest perdue - D’après une vérification avec
strace,grepreçoitSIGINTavanttcpdump, donc même sitcpdumptente de vider son tampon,greppeut déjà être mort - Une solution de contournement consiste à trouver le PID de
tcpdumpet exécuterkill -TERM $PID;tcpdumppeut 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-Cpeut 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
grepgrepdispose d’un flag pour éviter ce problème de buffering- Exemple
tail -f /some/log/file | grep --line-buffered thing1 | grep thing2
-
Fusionner avec
awkou ungrepplus complexe- Une suite de plusieurs
greppeut être remplacée par un seulawktail -f /some/log/file | awk '/thing1/ && /thing2/'
- Ou par un
grepavec une expression régulière plus complexetail -f /some/log/file | grep -E 'thing1.*thing2'
awkbufferise lui aussi, donc pour que cette méthode fonctionne,awkdoit être la dernière commande du pipeline
- Une suite de plusieurs
-
Utiliser
stdbufstdbufutiliseLD_PRELOADpour 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
unbufferunbuffer programforce 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 thing1peut ajouter des couleurs aux lignes correspondantes
- par exemple,
unbufferfait partie du paquetexpect
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
tcpdumptail -f- la surveillance de logs du type
kubectl logs - la sortie de calculs lents
- À l’image de
PYTHONUNBUFFEREDpour 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 deNO_COLOR - La conception est délicate
- sur NETBSD, il existe des variables d’environnement comme
STDBUF,STDBUF1, etc. qui offrent beaucoup de contrôle - la plupart des développeurs n’auront peut-être pas envie d’implémenter plusieurs variables d’environnement pour un edge case relativement mineur
- sur NETBSD, il existe des variables d’environnement comme
- 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
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 totalCette 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
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
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
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
taildans 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
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
Je pense que certains articles interminables ne sont que du remplissage pour l’optimisation SEO
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
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...
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 aussiVoici 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=1v2=Dans ce cas, la solution consiste à utiliser
stdbuf -i0 head -1.socketpairpourrait imposer ce genre de contrainte au processus qui écrit, sauf à utiliser des hacks lourds commeptrace().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,
stdbufne semble pas aider :$ ./a | stdbuf -i0 -- cat#include#includeint 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.
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.
/dev/nullpeut à 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.
sigintet les autres reçoiventsigpipe.