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

Tema 15

Los bucles while y until

El bucle for recorre una lista que se conoce de antemano. Pero muchas repeticiones no tienen lista: esperar a que un servidor arranque, reintentar una descarga que falló, preguntar hasta recibir una respuesta válida. En esos casos no se sabe cuántas vueltas harán falta, solo cuándo detenerse. Para eso están while y until, y este tema muestra sus usos típicos en automatización, con especial atención a que siempre terminen.

1. La forma de while

while ejecuta un comando de condición y, si termina con éxito, ejecuta el cuerpo; después vuelve a evaluar la condición, y así sucesivamente, hasta que la condición falle.

la forma de while
while condición; do
  comandos
done

Como en if, la condición es un comando cualquiera, y lo que importa es su código de salida (tema 10): mientras sea 0, el bucle sigue. Puede ser una prueba con [[ ]], una comparación con (( )) o cualquier programa.

  1. Se ejecuta la condición. cada vuelta
  2. Si su código es distinto de 0, el bucle termina y el script sigue después de done. salida
  3. Si es 0, se ejecuta el cuerpo y se vuelve al paso 1. cada vuelta

Si la condición falla desde el principio, el cuerpo no se ejecuta nunca. Y como la condición se vuelve a evaluar en cada vuelta, algo en el cuerpo, o fuera del script, tiene que cambiar para que alguna vez falle. Si nada cambia, el bucle no termina.

2. until: hasta que se cumpla

until es el mismo bucle con la condición invertida: repite mientras la condición falle, y termina cuando tiene éxito. Se lee «hasta que».

dos formas de la misma espera
until [[ -e listo.flag ]]; do       # hasta que exista el archivo
  sleep 1
done

while [[ ! -e listo.flag ]]; do     # mientras no exista
  sleep 1
done

Las dos hacen exactamente lo mismo. until evita la negación, y suele leerse mejor cuando lo que se espera es un éxito: «hasta que el servidor responda», «hasta que el comando funcione». Dentro del cuerpo de un until, $? contiene todavía el código de la condición que falló, lo que permite saber por qué falló, como veremos en la sección 11.

3. Contadores

La forma más simple de controlar un while es un contador que cambia en cada vuelta:

terminal
$ i=1
$ while (( i <= 3 )); do echo "Vuelta $i"; i=$(( i + 1 )); done
Vuelta 1
Vuelta 2
Vuelta 3

Para contar de un número a otro, el for al estilo C del tema 14 es más compacto, porque reúne el inicio, la condición y el incremento en una línea, y no hay riesgo de olvidar el incremento. El while con contador conviene cuando el contador no avanza en todas las vueltas, o cuando hay otras razones para terminar además de llegar al límite, como en los ejemplos que siguen.

4. Comandos como condición

La fuerza de while y until está en que la condición puede ser cualquier comando, y el bucle reacciona a lo que pasa fuera del script:

bucles que dependen del mundo exterior
until ping -c1 -W2 servidor > /dev/null; do      # hasta que el servidor responda
  sleep 5
done

while pgrep -x rsync > /dev/null; do              # mientras haya un rsync en ejecución
  echo "Esperando a que termine la copia anterior..."
  sleep 30
done

while ! mkdir /tmp/mi-script.lock 2> /dev/null; do   # hasta conseguir el bloqueo
  sleep 1
done

El último ejemplo usa un truco clásico: mkdir falla si el directorio ya existe, y crearlo es una operación atómica, de modo que dos procesos nunca pueden crearlo a la vez. El primero que lo consigue tiene el «bloqueo», y los demás esperan. Veremos una forma más robusta, con flock, en el tema 27.

5. Bucles infinitos a propósito

while true es un bucle cuya condición nunca falla. Tiene dos usos legítimos: programas que deben correr hasta que alguien los detenga, como un monitor que revisa algo cada minuto, y bucles cuya condición de salida está en medio del cuerpo, y no al comienzo.

un monitor que corre hasta que se lo detenga
while true; do
  uso=$(df --output=pcent / | tail -1 | tr -dc '0-9')
  (( uso >= 90 )) && echo "$(date +%T) disco al $uso %" >&2
  sleep 60
done
la salida en medio del cuerpo
while true; do
  read -rp "Nombre del servidor: " servidor
  [[ -n "$servidor" ]] && break            # válido: salir del bucle
  echo "El nombre no puede estar vacío." >&2
done

El segundo patrón lo usamos en el tema 9 para validar respuestas. break termina el bucle en el punto en que se ejecuta, y lo estudiaremos con detalle en el tema 17. Un bucle infinito en la terminal se detiene con Ctrl+C; uno que corre como servicio, con kill.

6. Esperar a que algo esté listo

Una situación muy frecuente en automatización: un script pone en marcha algo, como un servidor, una base de datos o un contenedor, y debe esperar a que esté listo antes de continuar. Esperar un tiempo fijo con sleep 10 es frágil: si arranca en dos segundos, se pierden ocho; si tarda doce, el script sigue antes de tiempo y falla. Lo correcto es preguntar repetidamente hasta que esté listo, lo que se llama sondeo (polling):

esperar a que una base de datos acepte conexiones
systemctl start postgresql

until pg_isready -q; do
  sleep 1
done
echo "La base de datos está lista"

Cada vuelta hace una pregunta barata y, si la respuesta es «todavía no», espera un poco. El intervalo es un compromiso: muy corto desperdicia recursos y puede saturar lo que se consulta; muy largo agrega demora innecesaria. Uno o dos segundos suelen ser razonables para algo que tarda segundos en estar listo; un minuto, para algo que tarda horas.

Bash permite incluso comprobar si un puerto TCP acepta conexiones sin ningún programa externo, abriendo el archivo especial /dev/tcp/HOST/PUERTO, que Bash interpreta como una conexión de red:

esperar a que un puerto esté abierto
until (exec 3<>"/dev/tcp/localhost/8080") 2> /dev/null; do
  sleep 1
done
echo "El servicio escucha en el puerto 8080"

Los paréntesis ejecutan la prueba en una subshell, de modo que la conexión abierta se cierra sola al terminar, y la redirección de errores oculta los mensajes de conexión rechazada mientras se espera.

7. Siempre con un límite

Los bucles de las dos secciones anteriores tienen un problema serio: si lo que se espera nunca ocurre, no terminan. Un servidor que no arranca por un error de configuración deja al script esperando para siempre, y si lo ejecuta cron, cada día se acumula una copia más esperando. En automatización, toda espera debe tener un límite, de tiempo o de intentos, y al alcanzarlo el script debe fallar con un mensaje claro.

La variable SECONDS del tema 5, que cuenta los segundos desde que empezó el script, hace muy simple el límite de tiempo:

esperar, como máximo, 30 segundos
limite=30
inicio=$SECONDS

until pg_isready -q; do
  if (( SECONDS - inicio >= limite )); then
    echo "La base de datos no respondió en $limite segundos" >&2
    exit 1
  fi
  sleep 1
done
echo "Lista en $(( SECONDS - inicio )) segundos"

Y un límite de intentos se expresa con un contador, que puede ir directamente en la condición:

como máximo, cinco intentos
intento=1
until descargar-datos; do
  if (( intento == 5 )); then
    echo "La descarga falló cinco veces" >&2
    exit 1
  fi
  intento=$(( intento + 1 ))
  sleep 10
done

8. El comando timeout

El límite de la sección anterior protege la espera entre intentos, pero no un intento que se cuelga: si descargar-datos se queda bloqueado esperando una red que no responde, el bucle nunca llega a comprobar el contador. Para eso existe timeout, que ejecuta un comando y lo interrumpe si tarda más de lo indicado:

terminal
$ timeout 5 sleep 60; echo "código: $?"
código: 124
$ timeout 30s rsync -a origen/ servidor:/destino/   # también admite s, m, h y d

Si el comando termina a tiempo, timeout devuelve su código de salida; si se agota el tiempo, lo interrumpe y devuelve 124. Con la opción -k envía además la señal KILL un tiempo después, para los programas que ignoran la interrupción normal: timeout -k 10 60 comando. Combinado con un bucle, cubre los dos riesgos: cada intento tiene un tiempo máximo, y la cantidad de intentos también.

9. Reintentos con esperas crecientes

Muchos fallos son pasajeros: una red congestionada, un servidor que se está reiniciando, un servicio que rechaza pedidos por exceso de carga. Reintentar es la respuesta correcta, pero reintentar inmediatamente y muchas veces seguidas empeora justamente el último caso, porque agrega carga a un servicio que ya está saturado.

La técnica habitual es la espera exponencial (exponential backoff): después de cada fallo se espera el doble que la vez anterior, 2, 4, 8, 16 segundos, hasta un máximo. Los primeros reintentos son rápidos, por si el fallo fue momentáneo, y los siguientes se espacian, para dar tiempo a que el problema se resuelva.

IntentoEspera fija de 5 sEspera exponencial desde 2 s (máx. 60)
1 → 25 s2 s
2 → 35 s4 s
3 → 45 s8 s
4 → 55 s16 s
5 → 65 s32 s
6 → 75 s60 s

Cuando muchos clientes reintentan contra el mismo servidor, se suele agregar una pequeña cantidad al azar a cada espera, para que no vuelvan a intentarlo todos en el mismo instante: sleep $(( espera + RANDOM % 3 )). El script de la sección 11 implementa la espera exponencial de forma general, para cualquier comando.

10. while, until o for

SituaciónConviene
Recorrer una lista conocida: archivos, argumentos, elementos de un arrayfor … in
Contar de un número a otrofor (( … ))
Repetir mientras algo sea cierto, sin saber cuántas vueltaswhile
Repetir hasta que algo funcione o esté listountil
Repetir sin fin, o con la salida en medio del cuerpowhile true con break
Leer un archivo o una salida línea por líneawhile read (tema 16)

11. Un script completo

Muchos scripts necesitan reintentar algo: una descarga, una conexión, una copia a un servidor. En lugar de escribir el bucle cada vez, este script lo hace para cualquier comando, que se le pasa como argumentos. Si el comando falla, lo reintenta con esperas crecientes, y si falla todas las veces, termina con el código del último intento.

reintentar
#!/usr/bin/env bash
#
# reintentar — ejecuta un comando hasta que funcione, con esperas crecientes.
#
# Uso:     reintentar COMANDO [ARGUMENTOS...]
# Entorno: INTENTOS    máximo de intentos (5)
#          ESPERA      segundos de espera tras el primer fallo (2); se duplica
#          ESPERA_MAX  espera máxima entre intentos (60)
# Salida:  0 si el comando funcionó; si no, el código del último intento.
#
set -euo pipefail

if (( $# == 0 )); then
  echo "Uso: ${0##*/} COMANDO [ARGUMENTOS...]" >&2
  exit 2
fi

intentos="${INTENTOS:-5}"
espera="${ESPERA:-2}"
espera_max="${ESPERA_MAX:-60}"

intento=1
until "$@"; do
  codigo=$?                               # el código del comando que falló
  if (( intento >= intentos )); then
    echo "${0##*/}: falló $intento veces; me rindo (código $codigo)" >&2
    exit "$codigo"
  fi
  echo "${0##*/}: intento $intento falló (código $codigo); reintento en ${espera} s" >&2
  sleep "$espera"
  intento=$(( intento + 1 ))
  espera=$(( espera * 2 > espera_max ? espera_max : espera * 2 ))
done
terminal
$ INTENTOS=3 ESPERA=1 reintentar ls /no/existe; echo "código $?"
ls: cannot access '/no/existe': No such file or directory
reintentar: intento 1 falló (código 2); reintento en 1 s
ls: cannot access '/no/existe': No such file or directory
reintentar: intento 2 falló (código 2); reintento en 2 s
ls: cannot access '/no/existe': No such file or directory
reintentar: falló 3 veces; me rindo (código 2)
código 2
$ reintentar curl -fsS -o datos.json https://api.example.com/datos
curl: (7) Failed to connect to api.example.com port 443
reintentar: intento 1 falló (código 7); reintento en 2 s
$ reintentar timeout 60 rsync -a proyecto/ respaldo:/copias/     # cada intento, como máximo un minuto

12. Errores frecuentes

EscribisteQué pasaCorrección
while (( i < 10 )); do …; done sin incrementar iEl bucle no termina nunca.Actualizar la variable en el cuerpo, o usar for (( )).
until servicio_listo; do sleep 1; doneSi el servicio nunca arranca, espera para siempre.Un límite con SECONDS o con un contador.
sleep 10 para esperar un servicioDemasiado o demasiado poco, según el día.Sondear hasta que esté listo, con límite.
Un intento sin timeoutUn comando colgado bloquea el bucle y su límite.timeout N comando
Reintentar sin esperaCientos de intentos por segundo contra un servicio caído.sleep entre intentos, idealmente creciente.
while true sin break ni exitSolo se detiene con Ctrl+C o kill.Correcto solo para monitores que deben correr siempre.
echo "$?" después de un if en el cuerpoMuestra el código del if, no el de la condición del bucle.Guardar codigo=$? en la primera línea del cuerpo.

13. Resumen

14. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Qué diferencia hay entre while y until?

while repite mientras la condición tenga éxito; until, mientras falle. until c equivale a while ! c.

2. ¿Cuántas veces se ejecuta el cuerpo de while false; do …; done?

Ninguna. La condición se evalúa antes de cada vuelta, y falla desde el principio.

3. ¿Por qué esperar a un servicio con sleep 10 es frágil?

Porque el tiempo de arranque varía. Si el servicio arranca antes, se pierde tiempo; si tarda más, el script continúa antes de que esté listo y falla. Es mejor preguntar repetidamente si está listo.

4. ¿Por qué toda espera de un script debe tener un límite?

Porque si lo que se espera nunca ocurre, el script quedaría bloqueado para siempre, sin informar el problema. Con un límite, falla con un mensaje y un código de salida que permiten detectarlo.

5. ¿Cómo se limita a 60 segundos un bucle de espera?

Guardando inicio=$SECONDS antes del bucle y comprobando en cada vuelta si (( SECONDS - inicio >= 60 )); si es así, se termina con un error.

6. ¿Qué problema resuelve timeout que un límite en el bucle no resuelve?

El de un intento que se cuelga. Un límite en el bucle se comprueba entre intentos; si un intento nunca termina, nunca se llega a comprobarlo. timeout interrumpe el comando mismo.

7. ¿Qué código devuelve timeout cuando interrumpe el comando?

124. Si el comando termina a tiempo, devuelve el código del comando.

8. ¿Qué es la espera exponencial, y por qué se usa?

Duplicar la espera después de cada fallo, hasta un máximo. Los primeros reintentos son rápidos, por si el fallo fue momentáneo, y los siguientes se espacian para no saturar un servicio que tiene problemas.

9. En el cuerpo de un until, ¿qué contiene $? en la primera línea?

El código de salida de la condición, que acaba de fallar. Hay que guardarlo enseguida si se lo necesita, porque el siguiente comando lo reemplaza.

10. ¿Por qué reintentar termina con el código del último intento y no con 1?

Para que quien lo llama reciba la misma información que habría recibido del comando original, y pueda distinguir, por ejemplo, un error de red de uno de permisos.

11. ¿Cuándo conviene while true?

Cuando el bucle debe correr indefinidamente, como un monitor, o cuando la condición de salida se conoce recién en medio del cuerpo, y se sale con break.

Para practicar

  1. Escribe un bucle while que muestre las potencias de 2 menores que 1000.
  2. Escribe un script que espere hasta que exista un archivo dado como argumento, con un límite de 20 segundos. Pruébalo creando el archivo con touch desde otra terminal.
  3. Lanza python3 -m http.server 8000 en segundo plano y escribe un bucle que espere, con límite, a que el puerto 8000 acepte conexiones usando /dev/tcp.
  4. Comprueba el código de salida de timeout 2 sleep 5 y de timeout 5 sleep 2.
  5. Escribe un monitor con while true que muestre cada cinco segundos la hora y la carga del sistema (primera columna de /proc/loadavg). Detenlo con Ctrl+C.
  6. Instala reintentar y pruébalo con un comando que siempre falla, uno que siempre funciona, y un script que falle las dos primeras veces y funcione la tercera (puedes usar un archivo como contador).
  7. Agrega a reintentar una pequeña espera aleatoria, de 0 a 2 segundos, sumada a cada espera.