2 puntos por GN⁺ 2024-11-30 | 1 comentarios | Compartir por WhatsApp
  • Las tuberías que encadenan varios comandos con una salida que llega lentamente, como tail -f /some/log/file | grep thing1 | grep thing2, en realidad pueden no estar atascadas; pueden verse vacías porque el comando intermedio acumula la salida en un búfer
  • grep y muchos programas revisan con isatty si stdout es una terminal; si lo es, usan buffering por línea, y si es una tubería o archivo, suelen usar buffering por bloques de ~8 KB
  • tail, cat y tee son ejemplos que no hacen buffering de salida, pero las opciones para reducirlo varían según el comando, como grep --line-buffered, sed -u, tcpdump -l, jq -u, tr -u
  • Si interrumpes la tubería con Ctrl-C, puede perderse la salida que seguía en el búfer de programas como tcpdump; si terminas con kill -TERM $PID, el búfer puede vaciarse y la salida hacerse visible
  • Las soluciones prácticas incluyen cambiar a comandos que terminen rápido, usar grep --line-buffered, un solo awk o un grep más complejo, stdbuf o unbuffer, pero en cada caso hay que revisar las condiciones de funcionamiento y efectos secundarios

Por qué una tubería parece haberse detenido

  • Cuando se agregan líneas lentamente a un archivo de logs, la siguiente tubería puede no mostrar salida aunque sí haya coincidencias
    • tail -f /some/log/file | grep thing1 | grep thing2
  • La causa no es la tubería en sí, sino que el grep thing1 intermedio no escribe el resultado de inmediato, sino que lo guarda en un búfer
  • Si un programa escribiera inmediatamente cada vez, aumentaría la cantidad de llamadas al sistema, así que por rendimiento junta cierta cantidad de datos antes de escribir en una tubería o archivo
  • En este ejemplo, grep thing1 puede esperar hasta acumular aproximadamente 8 KB de salida, y con logs lentos esa condición puede no cumplirse nunca en la práctica

La salida cambia entre terminal y tubería

  • tail -f file | grep thing funciona bien, pero si agregas un segundo grep, la salida puede parecer detenida
  • grep y muchos programas revisan con la función isatty si stdout es una terminal
    • Si stdout es una terminal, usan buffering por línea y muestran cada línea de inmediato
    • Si stdout es una tubería o archivo, usan buffering por bloques y escriben cuando se acumula una cierta cantidad de datos
  • Por eso, si grep escribe directamente en la terminal ves las líneas enseguida, pero si escribe a una tubería conectada al siguiente comando puede que no veas nada
  • El tamaño del búfer varía según el programa
    • En grep, el buffering lo maneja libc, y su tamaño está definido por la variable BUFSIZ
    • La ubicación de la definición en glibc está en stdio.h
  • No es una ley física que al escribir en una terminal no se use un búfer de salida de 8 KB; un programa podría implementarlo si quisiera, aunque sería un comportamiento bastante extraño

El buffering cambia según el comando

  • Lo complicado del buffering de salida es que el usuario tiene que recordar qué comandos hacen buffering cuando escriben a una tubería
  • Algunos ejemplos de comandos que no hacen buffering de salida son los siguientes
    • tail
    • cat
    • tee
  • Estos son comandos comunes que sí hacen buffering al escribir a una tubería, junto con formas de mitigarlo
    • grep: --line-buffered
    • sed: -u
    • awk: función fflush()
    • tcpdump: -l
    • jq: -u
    • tr: -u
    • cut: no se puede desactivar el buffering
  • En comandos como sort, que solo pueden trabajar después de recibir toda la entrada, el buffering no importa mucho en la práctica
  • Se intentó probar tanto las versiones de Mac OS como las de GNU, pero como hay muchas variantes puede haber algunos errores

La salida predeterminada de los lenguajes de programación también hace buffering

  • La salida predeterminada de print en algunos lenguajes de programación también hace buffering al escribir a una tubería
  • Estas son formas de desactivarlo según el lenguaje
    • C: setvbuf
    • Python: python -u, PYTHONUNBUFFERED=1, sys.stdout.reconfigure(line_buffering=False), print(x, flush=True)
    • Ruby: STDOUT.sync = true
    • Perl: $| = 1
  • Este comportamiento predeterminado parece estar diseñado para hacer más rápidas las funciones de salida estándar en procesamiento por lotes
  • Si hay buffering o no también puede cambiar según la forma de escribir la salida
    • En C++, cout << "hello\n" hace buffering al escribir a una tubería
    • cout << "hello" << endl vacía la salida

Diferencias con Ctrl-C y la redirección a archivo

  • Si conectas la salida de tcpdump a grep como sigue y omites -l, la salida puede quedarse en el búfer
    • sudo tcpdump -ni any port 53 | grep example.com
  • Idealmente, al presionar Ctrl-C esperarías que tcpdump vaciara el búfer y que grep buscara en él para mostrar la salida faltante
  • En la práctica, al terminar los programas se pierde la salida que seguía en el búfer de tcpdump
  • Al verificar con strace, grep recibe SIGINT antes que tcpdump, así que aunque tcpdump intente vaciar el búfer, grep puede ya haber terminado
  • Como alternativa, puedes buscar el PID de tcpdump y ejecutar kill -TERM $PID; así tcpdump puede vaciar el búfer y la salida podría hacerse visible
  • La redirección a archivo también hace buffering
    • sudo tcpdump -ni any port 53 > output.txt
  • Sin embargo, a diferencia del problema de Ctrl-C, donde el contenido del búfer puede perderse por completo, con la redirección a archivo suele pasar, al menos en la experiencia observada, que el contenido del búfer se escribe en el archivo antes de que termine el programa
  • No está claro si siempre se puede confiar en ese comportamiento

Cinco formas de evitar el buffering

  • Cambiar a programas que terminen rápido

    • Puedes evitar por completo la situación de escribir lentamente a una tubería cambiando a comandos que terminen rápido
    • Por ejemplo
      • cat /some/log/file | grep thing1 | grep thing2 | tail
    • No es exactamente el mismo comportamiento que el comando original con tail -f, pero evita los problemas complejos de buffering
  • Usar la opción de buffering por línea de grep

    • grep tiene una opción para evitar el buffering
    • Por ejemplo
      • tail -f /some/log/file | grep --line-buffered thing1 | grep thing2
  • Unir todo con awk o con un grep más complejo

    • En situaciones donde usas varios grep, puedes cambiarlo por un solo awk
      • tail -f /some/log/file | awk '/thing1/ && /thing2/'
    • O también puedes escribirlo con un grep de expresión regular más complejo
      • tail -f /some/log/file | grep -E 'thing1.*thing2'
    • awk también hace buffering, así que para que este método funcione, awk tiene que ser el último comando de la tubería
  • Usar stdbuf

    • stdbuf usa LD_PRELOAD para desactivar el buffering de libc
    • Ejemplo para desactivar el buffering de salida
      • tail -f /some/log/file | stdbuf -o0 grep thing1 | grep thing2
    • Como otras soluciones basadas en LD_PRELOAD, su confiabilidad es limitada
      • No funciona con binarios estáticos
      • Puede no funcionar si el programa no usa el buffering de libc
      • No siempre funciona en Mac OS
    • Una explicación relacionada está en How stdbuf works de Harry Marr
  • Usar unbuffer

    • unbuffer program fuerza a que la salida del programa parezca una TTY, para que use menos buffering y también funciones como salida con color, igual que en una TTY normal
    • Por ejemplo
      • tail -f /some/log/file | unbuffer grep thing1 | grep thing2
    • A diferencia de stdbuf, siempre funciona, pero puede traer efectos secundarios no deseados
      • Por ejemplo, grep thing1 puede agregar color a los resultados coincidentes
    • unbuffer viene en el paquete expect

Cuándo suele aparecer este problema y la idea de una variable de entorno

  • Este problema suele aparecer sobre todo en programas que envían datos lentamente por una tubería
  • Algunos ejemplos son los siguientes
    • tcpdump
    • tail -f
    • monitoreo de logs al estilo de kubectl logs
    • la salida de cálculos lentos
  • Podría ser útil tener una variable de entorno estándar para desactivar el buffering, como PYTHONUNBUFFERED en Python
  • La idea viene de una entrada de blog de 2018 de Mark Dominus y una publicación posterior
  • Como ejemplo de nombre, algo como NO_BUFFER, al estilo de NO_COLOR, podría ser posible
  • El diseño es complicado
    • En NETBSD existen variables de entorno como STDBUF, STDBUF1, etc. que dan mucho control
    • Puede que la mayoría de desarrolladores no quiera implementar varias variables de entorno para un caso borde relativamente pequeño
  • También surge la duda de si existen programas que vacíen automáticamente el búfer de salida cada cierto tiempo, por ejemplo cada 1 segundo, pero no viene ninguno a la mente y podría tener desventajas

Alcance que no se cubrió

  • No se trata la diferencia entre buffering por línea y salida completamente sin buffering
  • No se trata la diferencia entre el buffering de stderr y el de stdout
  • Este contenido solo trata el buffering que ocurre dentro del programa
  • El driver TTY del sistema operativo también a veces hace un poco de buffering
  • No se cubren otras razones para vaciar la salida además del caso de escribir a una tubería

1 comentarios

 
GN⁺ 2024-11-30
Opiniones de Hacker News
  • Un enfoque con buffering casi siempre debería ser de “umbral o timeout”: hacer flush cuando se alcanza un umbral de bytes, o después de cierto tiempo si hay al menos 1 byte
    Es una forma común en interfaces de hardware para resolver problemas similares
    En este caso, una biblioteca que hace buffering en espacio de usuario debería configurar un temporizador adecuado cuando pone datos por primera vez en el buffer. El valor del timeout podría recibirse como argumento, fijarse en algo corto para la percepción humana, como 1~100 ms, hacerse proporcional a {ancho de banda / umbral}, o elegirse de modo que el overhead de las llamadas al sistema no supere el 0.1% del tiempo total
    Este enfoque se aplica no solo a escrituras, sino también a lecturas. Si se hacen lecturas por lotes o lecturas combinadas, se necesita algo parecido, pero depende más del diseño del canal, porque el canal de datos debe tener una forma eficiente de consultar o recibir notificaciones sobre “datos pendientes”. En hardware, son comunes técnicas como la coalescencia de interrupciones

    • Creo que esa es la dirección correcta, pero si libc configura temporizadores automáticos, cambia el comportamiento esperado y aparecen muchos problemas delicados
      Los errores de E/S podrían ocurrir no solo al momento de escribir, sino en cualquier momento, y varios llamados al sistema podrían ser interrumpidos por el temporizador, no solo donde el programa puso su propio temporizador o donde llegó una señal
      También podría haber confusión si tanto la aplicación como libc configuran temporizadores. Las APIs modernas de temporizadores del kernel parecen mejores que como las recuerdo, así que quizá esto sea menos relevante, pero si la aplicación bloquea señales temporalmente en una sección crítica, eso también afecta al temporizador de E/S
      Por el momento y la forma en que se manejan las señales, habría que tener más cuidado al acceder a las estructuras de E/S
    • Manejar esos timeouts de forma transparente parece difícil bajo las restricciones de POSIX e ISO C. Parece que se necesitaría cierta cooperación de la capa de aplicación
    • Las alarmas comunes en Linux están basadas en señales, por lo que son muy difíciles de administrar, y para reprogramarlas hay que entrar al kernel, lo que puede tener impacto en el rendimiento
      Con io_uring y temporizadores en espacio de usuario esto escala mucho mejor, pero para soportar muchas escrituras pequeñas y rápidas todavía hacen falta trucos. Por ejemplo, por encima de aproximadamente 1 millón por segundo el costo de gestionar temporizadores empieza a notarse, y para llegar a 100 millones de escrituras por segundo hicieron falta técnicas bastante extrañas
    • Me cuesta estar de acuerdo. El buffering aquí está haciendo exactamente lo que debe hacer
      La causa del problema es mezclar algo que debería ser interactivo con un contrato que no presupone interacción. Por ejemplo, cuando se manda por pipe la salida de seguimiento de tail
      No creo que haya un problema real que resolver. Usando una analogía de hardware, sería como un tanque que junta agua de lluvia y solo la mueve cuando está lleno. No sé qué ejemplo tienen en mente, pero hasta donde sé, en hardware no es común el flush basado en tiempo
      La corrección propuesta hace que el contrato sea mucho más complejo
    • Prefiero un footgun predecible. La idea es buena, pero debería ser una bandera aparte, y entonces habría que saber que existe
      El problema no está tanto en la semántica en sí, sino en no conocer la semántica
  • Llevo más de 20 años manejando sistemas NIX y sé que esto pasa, pero cada vez solo me acuerdo después de pasar un buen rato preguntándome por qué no aparece la salida

  • Sobre la parte que dice “los artículos recientes se están volviendo bastante largos; ¿de verdad alguien querría leer 3000 palabras sobre buffering?”, personalmente sí quisiera leerlo

    • Depende del artículo
      Creo que a veces los textos largos son relleno para optimización en buscadores
    • En estos casos creo que los resúmenes con IA ayudan bastante. Puedes pedirle que resuma el artículo y luego revisarlo
      También se podría tener secciones TLDR y NTLDR, es decir, “lo leí aunque era largo”
  • Me gustaría que todos los buffers se hicieran flush cada vez que la CPU de todo el sistema queda inactiva
    El buffering, en general, es una técnica para ahorrar CPU. Si la CPU fuera infinita, todos los buffers serían de 1 byte. Los buffers juntan datos para procesarlos por lotes en favor de la eficiencia
    Pero cuando la CPU queda inactiva, no debería quedar “trabajo para después”. En cuanto el scheduler del kernel quede inactivo, debería enviar una señal a todos los procesos para hacer flush de los buffers

    • Es una idea interesante. Dicho eso, enviar una señal a todos los procesos suena tremendamente caro
      Sería hacer todo ese trabajo y luego obligarlos a hacer llamadas al sistema para el flush del buffer. Tal vez se podría agregar un mecanismo para que el kernel conozca los buffers en espacio de usuario y, cuando esté inactivo, los tome directamente desde ahí
      Me pregunto si esto se parece en cierta medida a io_uring https://man7.org/linux/man-pages/man3/io_uring_register_buff...
    • Es una idea genial, pero parece mejor no hacerlo todo de golpe. Además, en sistemas de bajo consumo podría despertar procesos dormidos de forma especulativa y reducir la eficiencia
  • Este artículo confunde dos cosas distintas: sin buffer y buffering por línea
    Sin buffer empeora innecesariamente el rendimiento y puede producir una salida incorrecta cuando varias fuentes escriben al mismo pipe. Las líneas suficientemente largas se mezclarán de todos modos, pero en la práctica la mayoría de las líneas de salida miden menos de 4096 bytes, incluso incluyendo caracteres de formato/control y caracteres de planos suplementarios
    El buffering por línea es el valor predeterminado en terminales, y normalmente también es el comportamiento deseado en pipes. Basta con ejecutar cada comando bajo stdbuf -oL -eL. Los raros programas que quieren actualizar dentro de una misma línea ya tienen que hacer flush manual, así que también se comportan correctamente aquí
    Lo que stdbuf hace en realidad puede verse así:
    env -i \command -v stdbuf` -oL -eL `command -v env``

  • Hace tiempo escribí sobre este problema: https://world-playground-deceit.net/blog/2024/09/bourne_shel...
    En cuanto a los comandos que no hacen buffering, eso depende de la implementación o, en el caso de cat, quizá sea incorrecto. Ver https://pubs.opengroup.org/onlinepubs/9799919799/utilities/c... y -u. Es un gran dolor que POSIX no haya incluido una forma oficial de manejar esto.
    Algo que no se mencionó es el buffering de entrada, que produce resultados raros como este:
    $ seq 5 | { v1=$(head -1); v2=$(head -1); printf '%s=%s\n' v1 "$v1" v2 "$v2"; }
    v1=1
    v2=
    En este caso, la solución es usar stdbuf -i0 head -1.

    • No creo que un proceso que lee desde algo como un pipe o un socketpair pueda imponerle ese tipo de restricción al proceso que escribe, salvo usando hacks pesados como ptrace().
      Quizá sea posible ajustar el tamaño del búfer del pipe, pero no conozco una convención según la cual la E/S estándar de C deba respetarlo.
      De todos modos, en este caso stdbuf no parece ayudar:
      $ ./a | stdbuf -i0 -- cat
      #include
      #include
      int main(void) {
      for (;;) {
      printf("n");
      usleep(100000);
      }
      }
  • Hay buenas razones para que existan los búferes. Imprimir salida en la pantalla es relativamente muy lento en comparación con escribir en un búfer.
    Imprimir caracteres uno por uno es tremendamente ineficiente.
    Es un problema antiguo y se encuentra a menudo al trabajar con UART. Hay varias soluciones posibles: un enfoque basado en líneas que marca el final de la salida con un carácter especial como una nueva línea; uno basado en longitud, que espera hasta alcanzar un tamaño como 8 KB; o uno basado en tiempo, que emite salida cada X milisegundos.
    Cada enfoque tiene sus ventajas y desventajas, y lo óptimo depende de la aplicación. Creo que la parte del artículo que dice que algunos programas no usan buffering es incorrecta. Esos programas simplemente no usan un enfoque basado en longitud evidente.

    • Funciona mejor cuando se puede conocer la restricción una o dos capas por encima de la interfaz.
      El enfoque basado en líneas es así, pero requiere ponerse de acuerdo sobre qué carácter usar. Normalmente es una nueva línea.
    • No es solo una cuestión del costo de que el backend haga la escritura real. Hacer tantas llamadas al sistema a /dev/null por sí solo también puede destruir mucho el rendimiento.
  • Llevo más de 35 años usando Unix, pero nunca había entendido del todo cómo funcionaba esto.
    Me gustó que explicara de forma integral el comportamiento del buffering a través de varios sistemas y componentes, y definitivamente aprendí algo.

  • Sobre la parte de que “si presionas Ctrl-C en un pipe, desaparece el contenido del búfer”, creo que la mayoría de los programas vaciarían el búfer ante SIGINT.
    Pero para que eso funcione así en el shell, tendría que enviar SIGINT solo al primer programa del pipeline, y probablemente no sea así como funciona en realidad.

    • Recuerdo que el último proceso recibe sigint y los demás reciben sigpipe.