← Automatización con Scripts en Bash Bloque 4 · Bucles · Tema 16 de 42

Tema 16

Leer un archivo línea por línea

Listas de servidores, archivos de configuración, registros, resultados de un comando: buena parte de los datos que procesa un script llegan como líneas de texto. El tema 14 mostró que for no sirve para recorrerlas. La herramienta correcta es un bucle while combinado con read, y tiene una forma canónica que conviene aprender de memoria, junto con tres trampas que explican casi todos los errores al usarla.

1. La forma correcta

Esta es la forma de leer un archivo línea por línea, cada línea exactamente como está escrita:

leer un archivo línea por línea
while IFS= read -r linea; do
  printf '%s\n' "$linea"
done < archivo.txt

En cada vuelta, read lee la línea siguiente y la guarda en linea. Al llegar al final del archivo, read termina con código 1, la condición del while falla y el bucle termina. La redirección < archivo.txt, escrita después de done, conecta el archivo a la entrada de todo el bucle: read va consumiendo líneas de esa entrada, una por vuelta.

2. Cada pieza, y por qué

La forma tiene tres detalles que parecen opcionales y no lo son. Cada uno corrige un comportamiento de read que vimos en el tema 9:

PiezaSin ellaEjemplo de lo que se pierde
IFS=read quita los espacios y tabuladores del principio y del final de la línea.La sangría de un archivo de configuración o de código.
-rread interpreta las barras invertidas y las elimina.Rutas de Windows, expresiones regulares, una línea terminada en \, que se une con la siguiente.
< archivo después de doneSi se escribe en el read, el archivo se reabre en cada vuelta y siempre se lee la primera línea.Un bucle infinito sobre la primera línea.
terminal · un archivo con sangría y una barra invertida
$ cat -A ejemplo.txt                     # -A muestra los caracteres invisibles
sin sangría$
    con cuatro espacios$
C:\temp\nuevo$
$ while read linea; do echo "[$linea]"; done < ejemplo.txt
[sin sangría]
[con cuatro espacios]
[C:tempnuevo]
$ while IFS= read -r linea; do echo "[$linea]"; done < ejemplo.txt
[sin sangría]
[    con cuatro espacios]
[C:\temp\nuevo]

Si lo que interesa es procesar palabras, y no conservar la línea intacta, quitar los espacios de los extremos puede ser deseable, y entonces IFS= sobra. Pero -r va siempre. En caso de duda, la forma completa es la segura.

3. La última línea sin salto final

En Unix, cada línea de un archivo de texto termina con un salto de línea, incluida la última. Pero muchos editores, sobre todo en Windows, guardan la última línea sin él. read lee esa línea, pero como llegó al final del archivo sin encontrar el salto, termina con código 1, y el while la descarta:

terminal · un archivo cuya última línea no termina en salto
$ printf 'uno\ndos\nsin salto' > lineas.txt
$ while IFS= read -r l; do echo "[$l]"; done < lineas.txt
[uno]
[dos]
$ while IFS= read -r l || [[ -n $l ]]; do echo "[$l]"; done < lineas.txt
[uno]
[dos]
[sin salto]

La corrección es agregar || [[ -n $linea ]] a la condición: si read falló pero dejó algo en la variable, esa última línea se procesa igual. En la vuelta siguiente, read falla y la variable queda vacía, y el bucle termina. Cuando el archivo viene de otras personas o de otros sistemas, conviene usar siempre esta versión.

4. Separar cada línea en campos

Con varias variables, read separa cada línea en campos, igual que en el tema 9. Con IFS indicando el separador, el bucle recorre un archivo de datos estructurado:

los usuarios con una shell de inicio de sesión
while IFS=: read -r usuario _ uid _ _ casa shell; do
  if (( uid >= 1000 )) && [[ "$shell" != */nologin ]]; then
    printf '%-12s %s\n' "$usuario" "$casa"
  fi
done < /etc/passwd
salida
ana          /home/ana
luis         /home/luis

Con separadores de espacio (el valor normal de IFS), los espacios repetidos cuentan como uno, lo que permite alinear las columnas de un archivo de configuración. Con otro separador, como : o ,, cada separador delimita un campo, aunque esté vacío. Y recuerda que la última variable recibe todo lo que sobra: es la forma de capturar un último campo que puede tener espacios, como una descripción.

5. Comentarios y líneas vacías

Los archivos que escriben las personas tienen comentarios y líneas en blanco para hacerlos legibles. El bucle debe saltarlos, y lo habitual es hacerlo al comienzo del cuerpo, con continue, que pasa directamente a la vuelta siguiente (lo veremos en el tema 17):

servidores.txt
# Servidores de producción
web01
web02

# Bases de datos
db01      # la principal
saltar comentarios y líneas vacías
while read -r servidor _ || [[ -n $servidor ]]; do
  [[ -z "$servidor" || "$servidor" == \#* ]] && continue
  echo "Revisando $servidor"
done < servidores.txt

Aquí read se usa sin IFS= y con dos variables a propósito: quita los espacios de los extremos, de modo que una línea con solo espacios queda vacía, y la variable _ se lleva todo lo que sigue al primer campo, incluido un comentario al final de la línea, como el de db01. El patrón \#* reconoce las líneas que empiezan con #; la barra es necesaria para que # no inicie un comentario de Bash.

6. Leer la salida de un comando

El bucle puede leer la salida de un comando en lugar de un archivo. Hay dos formas de conectarlos, y no son equivalentes:

dos formas de leer la salida de un comando
df -h --output=target,pcent | tail -n +2 | while read -r punto uso; do   # con una tubería
  echo "$punto: $uso"
done

while read -r punto uso; do                                             # con sustitución de procesos
  echo "$punto: $uso"
done < <(df -h --output=target,pcent | tail -n +2)

La segunda forma, < <(comando), ya apareció con mapfile en el tema 8. Es una sustitución de procesos: <(comando) ejecuta el comando y presenta su salida como si fuera un archivo, y el primer < redirige ese «archivo» a la entrada del bucle. El espacio entre los dos < es obligatorio. En este ejemplo las dos formas producen la misma salida. La diferencia aparece en cuanto el bucle modifica una variable.

7. El problema de la tubería

Supón que el bucle cuenta algo, y el script usa el total después:

terminal
$ total=0
$ printf 'a\nb\nc\n' | while read -r x; do total=$(( total + 1 )); done
$ echo "total: $total"
total: 0
$ while read -r x; do total=$(( total + 1 )); done < <(printf 'a\nb\nc\n')
$ echo "total: $total"
total: 3

Con la tubería, el contador vale 0 al final. No hay ningún error: el bucle contó correctamente, pero lo hizo en otro proceso. En Bash, cada parte de una tubería se ejecuta en una subshell, un proceso hijo con una copia de las variables. El bucle incrementó la copia, y la copia desapareció al terminar la tubería. Es el mismo fenómeno que vimos con read y mapfile, y que explicamos con el diagrama del tema 5: los cambios en un proceso hijo no vuelven al padre.

SoluciónFormaCuándo usarla
Sustitución de procesoswhile …; done < <(comando)La solución general, y la que usa este curso.
Leer de un archivowhile …; done < archivoCuando los datos ya están en un archivo: no hay subshell.
La opción lastpipeshopt -s lastpipe al comienzo del scriptHace que la última parte de una tubería corra en la shell actual. Solo funciona en scripts, no en la terminal interactiva.
Usar el resultado dentro de la tuberíacomando | { while …; done; echo "$total"; }Cuando el valor solo hace falta para mostrarlo al final.

Mientras el bucle solo muestra cosas, la tubería es perfectamente válida y a veces más legible. La regla práctica: si el bucle modifica variables que se usarán después, la entrada se conecta con < <(…), no con una tubería.

8. Nombres de archivo: find -print0

Leer nombres de archivo de a uno por línea, por ejemplo de la salida de find, funciona casi siempre. El «casi» viene de que un nombre de archivo en Linux puede contener cualquier carácter salvo la barra y el carácter nulo, incluido el salto de línea. Un archivo llamado así rompería el bucle en dos líneas.

La solución robusta es separar los nombres con el carácter nulo, el único que no puede aparecer en ellos. find lo hace con -print0, y read lee hasta un nulo con -d '':

recorrer archivos de forma robusta
while IFS= read -r -d '' archivo; do
  echo "Procesando: $archivo"
done < <(find "$dir" -type f -name '*.log' -mtime +7 -print0)

Es la forma que usó el script por-extension del tema 8, y la que conviene usar cuando los nombres vienen de find y el script debe funcionar con cualquier archivo. Muchos otros programas tienen una opción equivalente: xargs -0, sort -z, grep -z. Volveremos sobre ellas en el tema 32.

9. Comandos que se comen la entrada

Esta es la trampa más desconcertante de los bucles de lectura. El bucle debería procesar tres servidores, pero procesa solo el primero, sin ningún error:

un bucle que se detiene después de la primera vuelta
while read -r servidor; do
  echo "== $servidor"
  ssh "$servidor" uptime
done < servidores.txt

La causa es que todo lo que se ejecuta dentro del bucle comparte su entrada estándar, que es el archivo. ssh, que está pensado para reenviar al servidor remoto lo que se escribe en el teclado, lee de su entrada todo lo que encuentra: el resto del archivo. Cuando le toca de nuevo a read, ya no queda nada. Lo mismo hacen ffmpeg, mysql, otro read que pregunte algo al usuario, o cualquier programa que lea de su entrada.

Hay tres soluciones:

SoluciónForma
Quitarle la entrada al comandossh "$servidor" uptime < /dev/null
Usar su opción para no leer la entradassh -n "$servidor" uptime
Leer el archivo por otro descriptorwhile read -r -u 3 servidor; do …; done 3< servidores.txt

La tercera es la más general: el archivo se abre en el descriptor 3 en lugar del 0, y read -u 3 lee de él. La entrada estándar queda libre, conectada al teclado, y los comandos del cuerpo pueden usarla, por ejemplo para preguntar algo al usuario, sin interferir con la lectura del archivo.

10. Cuándo usar awk en su lugar

Un bucle while read es cómodo pero lento: Bash procesa cada línea con varias operaciones internas, y si el cuerpo ejecuta programas externos, crea procesos en cada vuelta. Para un archivo de cien líneas, irrelevante; para uno de un millón, la diferencia entre segundos y muchos minutos.

TareaConviene
Hacer algo con cada línea: ejecutar un comando, conectarse a un servidor, copiar un archivowhile read
Calcular sobre las líneas: sumar, contar, promediar, filtrar columnasawk
Transformar el texto: reemplazar, extraer, reordenarsed, awk, cut
Cargar todas las líneas para usarlas despuésmapfile -t (tema 8)
la misma suma, de dos formas
total=0                                       # Bash: lento en archivos grandes
while read -r _ bytes _; do
  total=$(( total + bytes ))
done < transferencias.txt

total=$(awk '{ s += $2 } END { print s }' transferencias.txt)   # awk: una sola pasada

La regla de fondo es la del tema 1: Bash coordina; el trabajo pesado sobre los datos lo hacen herramientas pensadas para eso.

11. Un script completo

Este script lee un archivo de configuración con una lista de servicios, uno por línea con su nombre, su servidor y su puerto, y comprueba que cada uno acepte conexiones. Reúne las técnicas del tema: comentarios y líneas vacías, la última línea sin salto, validación de cada línea con su número, y contadores que se usan después del bucle.

servicios.conf
# nombre       servidor           puerto
web            www.example.com    443
correo         mail.example.com   25

# bases de datos
base-datos     10.0.0.12          5432
cache          10.0.0.13
comprobar-servicios
#!/usr/bin/env bash
#
# comprobar-servicios — comprueba que los servicios de una lista acepten conexiones.
#
# Uso:    comprobar-servicios [ARCHIVO]   (por defecto, servicios.conf)
#         Cada línea: NOMBRE SERVIDOR PUERTO. Admite comentarios con # y líneas vacías.
# Salida: 0 si todos responden; 1 si alguno no; 2 si no se puede leer el archivo.
#
set -euo pipefail

conf="${1:-servicios.conf}"
[[ -r "$conf" ]] || { echo "No se puede leer $conf" >&2; exit 2; }

numero=0 total=0 caidos=0
while read -r nombre host puerto resto || [[ -n "${nombre:-}" ]]; do
  numero=$(( numero + 1 ))
  [[ -z "${nombre:-}" || "$nombre" == \#* ]] && continue

  if [[ -z "${puerto:-}" || -n "${resto:-}" || ! "$puerto" =~ ^[0-9]+$ ]]; then
    echo "$conf:$numero: línea mal formada, se ignora" >&2
    continue
  fi

  total=$(( total + 1 ))
  if timeout 3 bash -c 'exec 3<> "/dev/tcp/$1/$2"' _ "$host" "$puerto" 2> /dev/null; then
    printf 'OK     %-14s %s:%s\n' "$nombre" "$host" "$puerto"
  else
    printf 'CAÍDO  %-14s %s:%s\n' "$nombre" "$host" "$puerto"
    caidos=$(( caidos + 1 ))
  fi
done < "$conf"

echo "$(( total - caidos )) de $total servicios responden"
(( caidos == 0 ))
terminal
$ comprobar-servicios; echo "código $?"
OK     web            www.example.com:443
OK     correo         mail.example.com:25
CAÍDO  base-datos     10.0.0.12:5432
servicios.conf:7: línea mal formada, se ignora
2 de 3 servicios responden
código 1

12. Errores frecuentes

EscribisteQué pasaCorrección
for l in $(cat archivo)Recorre palabras, no líneas, y expande comodines.while IFS= read -r l; do …; done < archivo
while read lSe pierden la sangría y las barras invertidas.while IFS= read -r l
while read -r l < archivo; doLee siempre la primera línea: bucle infinito.La redirección después de done.
Archivo sin salto finalLa última línea se pierde.|| [[ -n $l ]] en la condición.
cmd | while …; done; echo "$total"El total no refleja lo contado en el bucle.done < <(cmd)
<<(cmd)Error de sintaxis.< <(cmd), con espacio.
ssh o read dentro del bucleConsumen el resto del archivo; el bucle termina antes.< /dev/null, ssh -n o read -u 3.
Leer la salida de find por líneasFalla con nombres que contienen saltos de línea.find … -print0 y read -d ''.
Sumar columnas con while read en archivos enormesMuy lento.awk.

13. Resumen

14. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Qué se pierde si se escribe while read linea en lugar de while IFS= read -r linea?

Los espacios y tabuladores del principio y del final de cada línea, por la falta de IFS=, y las barras invertidas, por la falta de -r.

2. ¿Por qué la redirección < archivo va después de done y no junto a read?

Porque así el archivo se abre una sola vez para todo el bucle, y cada read lee la línea siguiente. Junto a read, el archivo se abriría en cada vuelta y siempre se leería la primera línea.

3. ¿Por qué se pierde la última línea de un archivo que no termina en salto de línea, y cómo se evita?

Porque read la lee, pero termina con código 1 al no encontrar el salto, y el while termina sin procesarla. Se evita con while IFS= read -r l || [[ -n $l ]].

4. ¿Cómo se recorre /etc/passwd obteniendo el usuario y la shell de cada línea?

Con while IFS=: read -r usuario _ _ _ _ _ shell; do …; done < /etc/passwd, usando _ para los campos que no interesan.

5. ¿Por qué un contador incrementado en cmd | while read … vale 0 después del bucle?

Porque en una tubería el bucle se ejecuta en una subshell, un proceso hijo con una copia de las variables. El incremento ocurre en la copia, que desaparece al terminar la tubería.

6. ¿Qué significa done < <(comando)?

<(comando) es una sustitución de procesos, que presenta la salida del comando como si fuera un archivo; el primer < la redirige a la entrada del bucle. El bucle se ejecuta en la shell actual, y sus variables se conservan.

7. ¿Por qué se usa find -print0 con read -d ''?

Porque los nombres de archivo pueden contener saltos de línea, pero nunca el carácter nulo. Separando los nombres con nulos, el bucle funciona con cualquier nombre.

8. Un bucle que lee una lista de servidores y ejecuta ssh en cada uno procesa solo el primero. ¿Por qué?

Porque ssh lee de su entrada estándar, que es la del bucle, y consume el resto del archivo. Se corrige con ssh -n, con < /dev/null, o leyendo el archivo por otro descriptor con read -u 3.

9. ¿Cuándo conviene awk en lugar de un bucle while read?

Cuando se trata de calcular o transformar datos de muchas líneas, como sumar una columna. awk procesa el archivo en una sola pasada, mucho más rápido. while read conviene cuando hay que ejecutar acciones por cada línea.

10. En comprobar-servicios, ¿para qué sirve la cuarta variable, resto?

Para recibir cualquier campo de más. Si no está vacía, la línea tiene más campos de los esperados y se informa como mal formada.

Para practicar

  1. Crea un archivo con líneas que tengan sangría, barras invertidas y una última línea sin salto final. Léelo con la forma incompleta y con la completa, y compara.
  2. Lista los usuarios de /etc/passwd con UID mayor o igual que 1000, mostrando su nombre completo (el quinto campo).
  3. Reproduce el problema del contador con una tubería, y corrígelo de dos formas: con sustitución de procesos y con shopt -s lastpipe dentro de un script.
  4. Recorre con find -print0 los archivos de tu directorio personal modificados en el último día y muestra su tamaño.
  5. Escribe un bucle que lea una lista de nombres y, para cada uno, pregunte al usuario si lo procesa, usando read -u 3 para el archivo.
  6. Crea un archivo de un millón de números con seq 1000000 y súmalos con un bucle while read y con awk. Mide el tiempo de cada uno con time.
  7. Escribe tu propio servicios.conf con algunos servicios locales y remotos y prueba comprobar-servicios, incluyendo líneas mal formadas.