Qu'est-ce qui empêche stdout / stderr de s'entrelacer?

13

Supposons que j'exécute certains processus:

#!/usr/bin/env bash

foo &
bar &
baz &

wait;

Je lance le script ci-dessus comme suit:

foobarbaz | cat

pour autant que je sache, lorsque l'un des processus écrit sur stdout / stderr, leur sortie ne s'entrelace jamais - chaque ligne de stdio semble être atomique. Comment ça marche? Quel utilitaire contrôle la façon dont chaque ligne est atomique?

Alexander Mills
la source
3
Combien de données vos commandes produisent-elles? Essayez de les faire sortir quelques kilo-octets.
Kusalananda
Vous voulez dire où l'une des commandes sort quelques ko avant une nouvelle ligne?
Alexander Mills
Non, quelque chose comme ça: unix.stackexchange.com/a/452762/70524
muru

Réponses:

22

Ils s'entrelacent! Vous n'avez essayé que de courtes rafales de sortie, qui restent non divisées, mais dans la pratique, il est difficile de garantir qu'une sortie particulière reste non fractionnée.

Mise en mémoire tampon de sortie

Cela dépend de la façon dont les programmes tamponnent leur sortie. La bibliothèque stdio que la plupart des programmes utilisent lors de l'écriture utilise des tampons pour rendre la sortie plus efficace. Au lieu de sortir des données dès que le programme appelle une fonction de bibliothèque pour écrire dans un fichier, la fonction stocke ces données dans un tampon et ne sort réellement les données qu'une fois le tampon rempli. Cela signifie que la sortie se fait par lots. Plus précisément, il existe trois modes de sortie:

  • Sans tampon: les données sont écrites immédiatement, sans utiliser de tampon. Cela peut être lent si le programme écrit sa sortie en petits morceaux, par exemple caractère par caractère. Il s'agit du mode par défaut pour l'erreur standard.
  • Entièrement tamponné: les données ne sont écrites que lorsque le tampon est plein. Il s'agit du mode par défaut lors de l'écriture dans un canal ou dans un fichier normal, sauf avec stderr.
  • Mise en mémoire tampon de ligne: les données sont écrites après chaque nouvelle ligne ou lorsque la mémoire tampon est pleine. Il s'agit du mode par défaut lors de l'écriture sur un terminal, sauf avec stderr.

Les programmes peuvent reprogrammer chaque fichier pour se comporter différemment et vider explicitement le tampon. Le tampon est vidé automatiquement lorsqu'un programme ferme le fichier ou se ferme normalement.

Si tous les programmes qui écrivent dans le même canal utilisent le mode ligne tamponnée, ou utilisent le mode sans tampon et écrivent chaque ligne avec un seul appel à une fonction de sortie, et si les lignes sont suffisamment courtes pour écrire en un seul morceau, alors la sortie sera un entrelacement de lignes entières. Mais si l'un des programmes utilise le mode entièrement tamponné, ou si les lignes sont trop longues, vous verrez des lignes mixtes.

Voici un exemple où j'entrelace la sortie de deux programmes. J'ai utilisé GNU coreutils sur Linux; différentes versions de ces utilitaires peuvent se comporter différemment.

  • yes aaaaécrit aaaapour toujours dans ce qui est essentiellement équivalent au mode ligne-tampon. L' yesutilitaire écrit en fait plusieurs lignes à la fois, mais chaque fois qu'il émet une sortie, la sortie est un nombre entier de lignes.
  • echo bbbb; done | grep bécrit bbbbpour toujours en mode entièrement tamponné. Il utilise une taille de tampon de 8192 et chaque ligne fait 5 octets de long. Étant donné que 5 ne divise pas 8192, les frontières entre les écritures ne sont généralement pas à une frontière de ligne.

Posons-les ensemble.

$ { yes aaaa & while true; do echo bbbb; done | grep b & } | head -n 999999 | grep -e ab -e ba
bbaaaa
bbbbaaaa
baaaa
bbbaaaa
bbaaaa
bbbaaaa
ab
bbbbaaa

Comme vous pouvez le voir, oui parfois la grep interrompue et vice versa. Seulement environ 0,001% des lignes ont été interrompues, mais c'est arrivé. La sortie est randomisée donc le nombre d'interruptions variera, mais j'ai vu au moins quelques interruptions à chaque fois. Il y aurait une fraction plus élevée de lignes interrompues si les lignes étaient plus longues, car la probabilité d'une interruption augmente à mesure que le nombre de lignes par tampon diminue.

Il existe plusieurs façons d' ajuster la mise en mémoire tampon de sortie . Les principaux sont:

  • Désactivez la mise en mémoire tampon dans les programmes qui utilisent la bibliothèque stdio sans modifier ses paramètres par défaut avec le programme stdbuf -o0trouvé dans GNU coreutils et certains autres systèmes tels que FreeBSD. Vous pouvez également basculer vers la mise en mémoire tampon de ligne avec stdbuf -oL.
  • Passez à la mise en mémoire tampon de ligne en dirigeant la sortie du programme via un terminal créé spécialement à cet effet avec unbuffer. Certains programmes peuvent se comporter différemment d'autres manières, par exemple greputilise des couleurs par défaut si sa sortie est un terminal.
  • Configurez le programme, par exemple en passant --line-bufferedà GNU grep.

Voyons à nouveau l'extrait ci-dessus, cette fois avec la mise en mémoire tampon des lignes des deux côtés.

{ stdbuf -oL yes aaaa & while true; do echo bbbb; done | grep --line-buffered b & } | head -n 999999 | grep -e ab -e ba
abbbb
abbbb
abbbb
abbbb
abbbb
abbbb
abbbb
abbbb
abbbb
abbbb
abbbb
abbbb
abbbb

Donc, cette fois, oui n'a jamais interrompu grep, mais grep a parfois interrompu oui. Je reviendrai sur pourquoi plus tard.

Entrelacement de tuyaux

Tant que chaque programme produit une ligne à la fois et que les lignes sont suffisamment courtes, les lignes de sortie seront soigneusement séparées. Mais il y a une limite à la durée des lignes pour que cela fonctionne. Le tuyau lui-même a un tampon de transfert. Lorsqu'un programme sort sur un canal, les données sont copiées du programme d'écriture dans le tampon de transfert du canal, puis plus tard du tampon de transfert du canal dans le programme de lecture. (Du moins sur le plan conceptuel - le noyau peut parfois optimiser cela en une seule copie.)

S'il y a plus de données à copier qu'il n'y en a dans le tampon de transfert du tube, le noyau copie un tampon à la fois. Si plusieurs programmes écrivent dans le même canal et que le premier programme que le noyau choisit veut écrire plus d'un tampon, il n'y a aucune garantie que le noyau choisira à nouveau le même programme la deuxième fois. Par exemple, si P est la taille du tampon, fooveut écrire 2 * P octets et barveut écrire 3 octets, alors un entrelacement possible est P octets de foo, puis 3 octets de bar, et P octets de foo.

Pour revenir à l'exemple yes + grep ci-dessus, sur mon système, yes aaaail arrive d'écrire autant de lignes que possible dans un tampon de 8192 octets en une seule fois. Puisqu'il y a 5 octets à écrire (4 caractères imprimables et la nouvelle ligne), cela signifie qu'il écrit 8190 octets à chaque fois. La taille de la mémoire tampon du canal est de 4096 octets. Il est donc possible d'obtenir 4096 octets de oui, puis une sortie de grep, puis le reste de l'écriture de oui (8190 - 4096 = 4094 octets). 4096 octets laisse de la place pour 819 lignes avec aaaaet un seul a. D'où une ligne avec ce seul asuivi d'une écriture de grep, donnant une ligne avec abbbb.

Si vous voulez voir les détails de ce qui se passe, alors getconf PIPE_BUF .vous dira la taille de la mémoire tampon de tuyau sur votre système, et vous pouvez voir une liste complète des appels système effectués par chaque programme avec

strace -s9999 -f -o line_buffered.strace sh -c '{ stdbuf -oL yes aaaa & while true; do echo bbbb; done | grep --line-buffered b & }' | head -n 999999 | grep -e ab -e ba

Comment garantir l'entrelacement de lignes propres

Si les longueurs de ligne sont inférieures à la taille de la mémoire tampon de tuyau, la mise en mémoire tampon de ligne garantit qu'il n'y aura pas de ligne mixte dans la sortie.

Si les longueurs de ligne peuvent être plus grandes, il n'y a aucun moyen d'éviter un mélange arbitraire lorsque plusieurs programmes écrivent dans le même canal. Pour garantir la séparation, vous devez faire en sorte que chaque programme écrive sur un tuyau différent et utilisez un programme pour combiner les lignes. Par exemple, GNU Parallel fait cela par défaut.

Gilles 'SO- arrête d'être méchant'
la source
intéressant, donc ce qui pourrait être un bon moyen de s'assurer que toutes les lignes ont été écrites catatomiquement, de sorte que le processus cat reçoive des lignes entières de foo / bar / baz mais pas une demi-ligne d'une et une demi-ligne d'une autre, etc. Puis-je faire quelque chose avec le script bash?
Alexander Mills
1
cela s'applique également à mon cas où j'avais des centaines de fichiers et où awkdeux (ou plus) lignes de sortie ont été produites pour le même ID, find -type f -name 'myfiles*' -print0 | xargs -0 awk '{ seen[$1]= seen[$1] $2} END { for(x in seen) print x, seen[x] }' mais avec find -type f -name 'myfiles*' -print0 | xargs -0 cat| awk '{ seen[$1]= seen[$1] $2} END { for(x in seen) print x, seen[x] }'cela, il n'a correctement produit qu'une seule ligne pour chaque ID.
αғsнιη
Pour éviter tout entrelacement, je peux le faire avec un environnement de programmation comme Node.js, mais avec bash / shell, je ne sais pas comment le faire.
Alexander Mills
1
@JoL C'est dû au remplissage du tampon de tuyau. Je savais que je devrais écrire la deuxième partie de l'histoire… Terminé.
Gilles 'SO- arrête d'être méchant'
1
@OlegzandrDenman TLDR a ajouté: ils s'entrelacent. La raison est compliquée.
Gilles 'SO- arrête d'être méchant'
1

http://mywiki.wooledge.org/BashPitfalls#Non-atomic_writes_with_xargs_-P a examiné ceci:

GNU xargs prend en charge l'exécution de plusieurs travaux en parallèle. -P n où n est le nombre de travaux à exécuter en parallèle.

seq 100 | xargs -n1 -P10 echo "$a" | grep 5
seq 100 | xargs -n1 -P10 echo "$a" > myoutput.txt

Cela fonctionnera bien dans de nombreuses situations mais a un défaut trompeur: si $ a contient plus de ~ 1000 caractères, l'écho peut ne pas être atomique (il peut être divisé en plusieurs appels write ()), et il y a un risque que deux lignes sera mélangé.

$ perl -e 'print "a"x2000, "\n"' > foo
$ strace -e write bash -c 'read -r foo < foo; echo "$foo"' >/dev/null
write(1, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., 1008) = 1008
write(1, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., 993) = 993
+++ exited with 0 +++

Évidemment, le même problème se pose s'il y a plusieurs appels à echo ou printf:

slowprint() {
  printf 'Start-%s ' "$1"
  sleep "$1"
  printf '%s-End\n' "$1"
}
export -f slowprint
seq 10 | xargs -n1 -I {} -P4 bash -c "slowprint {}"
# Compare to no parallelization
seq 10 | xargs -n1 -I {} bash -c "slowprint {}"
# Be sure to see the warnings in the next Pitfall!

Les sorties des travaux parallèles sont mélangées, car chaque travail se compose de deux (ou plusieurs) appels d'écriture () distincts.

Si vous avez besoin de sorties non mélangées, il est donc recommandé d'utiliser un outil qui garantit que les sorties seront sérialisées (comme GNU Parallel).

Ole Tange
la source
Cette section est fausse. xargs echon'appelle pas la fonction intégrée echo bash, mais l' echoutilitaire de $PATH. Et de toute façon je ne peux pas reproduire ce comportement d'écho bash avec bash 4.4. Sous Linux, les écritures sur un tube (pas / dev / null) plus grand que 4K ne sont pas garanties d'être atomiques.
Stéphane Chazelas