Bash Scripting II: procesos y sistema operativo
Qué ocurre cuando Bash ejecuta un programa: procesos, hilos, señales, entrada y salida, y por qué todo esto importa al construir herramientas para agentes de IA.
1. Introducción
1.1. Qué ocurre cuando ejecutamos un programa
En la primera parte de esta serie dedicada a bash escribimos scripts, consultamos variables como $$ y vimos que cada comando devuelve un código de salida. Pero ¿qué está pasando en el sistema operativo mientras Bash ejecuta todo eso?
Para entenderlo tenemos que hablar de procesos y comprender cómo funcionan nos resultará muy útil si trabajamos con agentes de IA capaces de utilizar herramientas. Cuando un agente pide ejecutar npm test, alguien tiene que iniciar el programa, esperar a que termine, recoger su salida y decidir qué hacer si falla o se queda bloqueado. Antes de construir ese ejecutor, hay que entender qué es exactamente lo que está ejecutando.
Bueno, pues sin más preámbulos, vamos a recorrer los conceptos desde lo más básico, con ejemplos que podemos probar en Linux, macOS y Windows mediante WSL o Git Bash.
1.2. Programa, proceso e hilo
Es fundamental que entendamos la diferencia entre estas tres palabras.
- Un programa es código almacenado en un archivo, como el ejecutable de Python o de Node.js.
- Un proceso es una ejecución concreta de un programa, con recursos y un estado que el sistema operativo tiene que gestionar.
- Un hilo es una línea de ejecución dentro de un proceso. Un proceso puede tener uno o varios.
Por ejemplo:
python app.py
Aquí app.py contiene el código de nuestra aplicación y python es el programa que lo interpreta. Al lanzar el comando aparece un proceso que ejecuta Python con ese archivo. Si repetimos el comando en tres terminales, tendremos tres procesos distintos aunque todos utilicen el mismo programa y el mismo app.py.
El proceso tampoco es solo «un programa funcionando». El sistema operativo guarda información sobre él: su PID o identificador, memoria, hilos, archivos y conexiones abiertos, variables de entorno, permisos y estado. Todo eso permite saber qué está haciendo, darle acceso a determinados recursos, detenerlo o esperar a que termine.
2. Cómo nace un proceso
Ya sabemos qué es un proceso, pero nos falta ver de dónde sale. Supongamos que escribimos esto en la terminal:
sleep 10
sleep no hace ningún trabajo visible, solo espera el número de segundos que le indiquemos y termina. En este caso, espera diez segundos. Mientras tanto, no está utilizando la CPU de forma continua; el sistema operativo lo mantiene a la espera de que venza el temporizador.
Bash tiene que poner en marcha sleep y esperar a que termine para poder continuar. Pero Bash no desaparece mientras sleep se ejecuta: sigue ahí y, cuando termina, recoge el resultado y acepta nuestro siguiente comando. ¿Cómo consigue el sistema operativo que existan esos dos procesos?
En los sistemas de la familia Unix, la explicación clásica combina dos operaciones:
fork()crea un proceso hijo a partir del proceso actual.exec()carga otro programa en ese proceso hijo y sustituye el que estaba ejecutando.
Aplicado al ejemplo: Bash crea un hijo, ese hijo pasa a ejecutar sleep y Bash espera a que acabe para recuperar su resultado. exec() no crea un segundo hijo: cambia el programa que ejecuta el proceso recién creado, que conserva su PID.
Bueno, es un modelo para entender lo que ocurre, no significa que Bash invoque siempre fork() y exec() de esa forma. Un comando interno como echo puede ejecutarse en la propia shell, y hay otras estrategias y optimizaciones para lanzar programas. En Windows, la creación de procesos sigue otro mecanismo, como CreateProcess(); Git Bash nos ofrece una capa de compatibilidad para usar muchos comandos y comportamientos habituales de Unix.
Veamos ahora qué diantres es ese PID que ya ha salido varias veces.
2.1. PID y PPID
Cada proceso tiene un PID (Process ID). También podemos hablar de su PPID (Parent Process ID), el identificador del proceso que lo creó. En una sesión de Bash podemos consultar los dos:
echo "Mi PID: $$"
echo "PID de mi padre: $PPID"
$$ representa el PID de Bash y $PPID el de su proceso padre. Hay matices cuando entran en juego las subshells, pero de momento podemos quedarnos con esa idea. Si Bash lanza sleep, tendremos una relación parecida a esta:
Bash PID 2000
└─ sleep PID 2001 · PPID 2000
Los números son inventados; en cada equipo serán diferentes. Lo importante ahora es que nos fijeos en la relación, ya que habrá escenarios en los que necesitemos que el padre pueda esperar al hijo para recoger su código de salida o enviarle una señal. Un script que lanza varios programas tiene que saber cuándo puede continuar y qué hacer si alguno falla.
3. Procesos e hilos
Un proceso dispone de recursos propios, entre ellos un espacio de memoria virtual. Dentro pueden ejecutarse varios hilos. Los hilos de un mismo proceso comparten habitualmente esa memoria y otros recursos, pero cada uno conserva su propia pila y su propio punto de ejecución.
Proceso A Proceso B
├─ Hilo 1 ├─ Hilo 1
├─ Hilo 2 └─ Hilo 2
└─ Memoria A └─ Memoria B
Por defecto, los procesos A y B tienen espacios de direcciones separados; los hilos de A comparten el suyo. Por ejemplo, Node.js puede ejecutar JavaScript en un hilo principal y utilizar otros hilos para parte del trabajo del runtime. Una aplicación Python también puede crear hilos o procesos adicionales, según lo que necesite hacer.
Tener muchos hilos no significa que todos estén ejecutándose a la vez. El sistema operativo decide cuándo ejecutarlos, y el paralelismo real depende, entre otras cosas, de los núcleos disponibles y del propio programa.
4. El ciclo de vida de un proceso
Un proceso no pasa todo el tiempo utilizando la CPU. Los nombres exactos de sus estados varían entre sistemas, pero este esquema nos sirve para orientarnos:
Preparado → Ejecutándose el planificador le da CPU
Ejecutándose → Preparado se le acaba su turno
Ejecutándose → Esperando necesita disco, red, un temporizador…
Esperando → Preparado llega lo que esperaba
Ejecutándose → Terminado acaba su trabajo o se le obliga a salir
Preparado significa que puede trabajar, pero espera su turno de CPU. Ejecutándose indica que alguno de sus hilos está realizando instrucciones. Esperando significa que necesita algo antes de seguir, como datos de red, una lectura de disco o el vencimiento de un temporizador. También puede quedar detenido hasta que se le permita continuar y, finalmente, terminado.
Prueba a ejecutar sleep 30. El proceso existe durante treinta segundos, pero no ocupa un núcleo de CPU durante todo ese tiempo: casi siempre está esperando al temporizador. Estar vivo no equivale a estar trabajando activamente.
Cuando sleep termina, el sistema conserva su estado de salida para que Bash pueda recogerlo. En Unix, mientras un padre no recoge ese resultado, el hijo permanece en la tabla de procesos como zombi. Bash se ocupa normalmente de hacerlo por nosotros; no tenemos que añadir nada después de cada comando.
Cuando lanzamos un trabajo en segundo plano, Bash puede continuar el script aunque ese trabajo siga vivo. wait nos permite decidir en qué punto debe detenerse para esperarlo. Vamos a verlo con un ejemplo.
5. wait: esperar a los trabajos en segundo plano
5.1. Para qué sirve
Imaginemos este script:
echo "Inicio"
sleep 5 &
sleep 10 &
echo "Fin"
El & hace que Bash lance cada sleep en segundo plano y pase a la siguiente línea. Por eso imprime «Fin» casi inmediatamente, aunque uno de los procesos siga esperando cinco segundos y el otro diez. Los dos tiempos se solapan; no estamos esperando primero cinco segundos y luego otros diez.
Ahora añadimos wait antes del último mensaje:
echo "Inicio"
sleep 5 &
sleep 10 &
wait
echo "Fin"
Sin argumentos, wait espera a que terminen todos los trabajos en segundo plano que Bash sigue gestionando. Como ambos sleep se ejecutan a la vez, «Fin» aparece después de unos diez segundos, cuando acaba el más largo, y no después de quince.
La idea nos sirve para tareas reales. Podemos lanzar tres programas independientes y esperar antes de anunciar que han terminado:
#!/usr/bin/env bash
python procesar_clientes.py &
python procesar_productos.py &
python procesar_facturas.py &
wait
echo "Todos los procesos han terminado"
Ojo: terminar no significa terminar bien. En esta forma, wait sin argumentos devuelve 0 cuando acaba de esperar, aunque alguno de los programas haya fallado. Si necesitamos conocer el resultado de cada uno, tenemos que esperarlos por separado.
5.2. Esperar a un proceso concreto
Como hemos visto, $! contiene el PID del último trabajo enviado a segundo plano, así que podemos guardarlo antes de lanzar el siguiente:
sleep 5 &
pid1=$!
sleep 10 &
pid2=$!
wait "$pid1" # <--- Ojo piojo
estado1=$?
echo "Primero: $estado1"
wait "$pid2"
estado2=$?
echo "Segundo: $estado2"
El primer wait se detiene hasta que termina el proceso de cinco segundos; el segundo espera al de diez. Guardamos $? inmediatamente después de cada wait porque cualquier comando posterior, incluido echo, cambia ese valor. Así sabemos no solo que ambos han terminado, sino también qué código de salida ha devuelto cada uno.
En resumen:
| Instrucción | Qué hace Bash |
|---|---|
comando | Espera normalmente a que termine antes de continuar. |
comando & | Lo lanza en segundo plano y continúa. |
wait | Espera a todos los trabajos pendientes de esa shell. |
wait "$pid" | Espera al trabajo identificado por ese PID y devuelve su código de salida. |
& permite que varios trabajos avancen a la vez; wait nos permite sincronizarlos antes de seguir. Es un comando interno de Bash, igual que echo, así que no necesita lanzar otro programa para realizar la espera.
6. Señales: hablar con un proceso
En Unix, una señal comunica a un proceso que ha ocurrido algo. Estas son las más habituales:
| Señal | Para qué se utiliza habitualmente |
|---|---|
SIGINT | Solicitar una interrupción, normalmente con Ctrl+C. |
SIGTERM | Pedir que el proceso termine. Puede gestionarla. |
SIGKILL | Forzar la terminación. El proceso no puede capturarla. |
SIGSTOP | Suspender el proceso. Tampoco puede capturarla. |
SIGTSTP | Suspender desde el terminal, normalmente con Ctrl+Z. Puede gestionarla. |
SIGCONT | Reanudar un proceso suspendido. |
SIGHUP | Señalar una desconexión; algunas aplicaciones la usan para recargar su configuración. |
El nombre de kill despista un poco, porque no sirve únicamente para «matar» procesos: permite enviarles señales. Por ejemplo:
sleep 60 &
pid=$!
kill -TERM "$pid"
wait "$pid"
echo $? # <-- 143
Aquí pedimos a sleep que termine y luego esperamos a recoger su resultado. que es 143. Cuando un proceso muere por una señal, Bash informa de 128 más el número de la señal, y el de SIGTERM es el 15. (Del mismo modo, Ctrl+C acaba con 130, es decir, 128 + 2.)
Un programa puede capturar SIGTERM para cerrar archivos o liberar recursos antes de salir. SIGKILL no le da esa oportunidad, de modo que no es la primera opción si podemos terminarlo de forma ordenada.
Bash también puede reaccionar a algunas señales mediante trap. Por ejemplo, trap 'echo "Interrumpido"; exit 130' INT define qué hacer cuando el script reciba SIGINT. No necesitamos dominar trap todavía; basta con saber que existe para limpiar recursos o dejar un mensaje cuando interrumpimos un script.
La forma de enviar y recibir señales no es idéntica en todos los sistemas. En particular, Git Bash puede comportarse de manera diferente con programas nativos de Windows.
7. Entrada, salida y errores
Muchos programas utilizan tres flujos estándar para comunicarse:
| Flujo | Descriptor | Uso |
|---|---|---|
stdin | 0 | Entrada que recibe el programa. |
stdout | 1 | Resultados normales. |
stderr | 2 | Mensajes de error y diagnóstico. |
Podemos enviar stdout a un archivo con >, que es un operador que permite enviar la salida de un comando a un archivo, en lugar de mostrarla en la terminal.
echo "Hola" > salida.txt
Y enviar stderr a otro con 2>:
ls /directorio-que-no-existe 2> errores.txt
Una tubería, o pipe, conecta la salida estándar de un programa con la entrada estándar del siguiente:
printf 'manzana\nplátano\n' | grep plátano
Y por consola vemos:
plátano
printf genera dos líneas y grep deja pasar la que contiene «plátano». Ojo: la tubería solo conecta stdout; los errores del primer programa siguen yendo al terminal, salvo que los redirijamos. Y si consultamos después $?, por defecto Bash devuelve el código de salida del último comando de la tubería, no necesariamente el de todos los anteriores. Cuando construyamos scripts más exigentes veremos cómo controlar ese comportamiento.
Para un agente que ejecuta herramientas, estos tres datos cuentan historias diferentes: stdout puede contener el resultado, stderr puede explicar un problema y el código de salida indica si el programa considera que terminó bien. Un programa también puede escribir avisos en stderr y salir con 0, así que hay que leer el conjunto.
8. ¿Cambia algo entre Linux, macOS y Windows?
La sintaxis de Bash nos deja reutilizar muchos scripts, pero los procesos los gestiona el sistema operativo, y ahí aparecen diferencias:
| Entorno | Qué procesos ejecuta Bash | Qué recordar |
|---|---|---|
| Linux y WSL 2 | Procesos Linux. | Disponen de las señales y herramientas habituales de Linux. |
| macOS | Procesos del sistema Unix de Apple. | Algunas opciones de herramientas como ps difieren de las de Linux, y el Bash que trae de serie es una versión antigua (la 3.2). |
| Git Bash | Programas de Windows a través de una capa de compatibilidad. | La gestión de procesos y señales puede diferir, sobre todo con programas nativos de Windows. |
En Git Bash, ps puede mostrar principalmente procesos de su propio entorno. Para consultar los procesos de Windows también podemos usar tasklist.exe. WSL 2 nos ofrece un entorno Linux más parecido al de muchos servidores; aun así, puede lanzar ejecutables de Windows mediante interoperabilidad, por ejemplo notepad.exe. Conviene saber en qué lado se está ejecutando cada programa.
9. Por qué le importa esto a un agente de IA
Imagina que un agente tiene que comprobar si una aplicación pasa sus pruebas. Solicita ejecutar npm test, pero el trabajo no termina al lanzar el comando. El ejecutor de herramientas tiene que crear el proceso, establecer un tiempo máximo, recoger stdout y stderr, esperar su finalización y entregar el código de salida al agente.
Agente → solicita ejecutar npm test
↓
Ejecutor → inicia y supervisa el proceso
↓
Resultado → salida, errores y código de salida
↓
Agente → decide si continúa, informa o intenta corregir algo
¿Y si las pruebas se quedan bloqueadas? El ejecutor tiene que poder interrumpirlas. ¿Y si npm test lanza otros procesos? Terminar el proceso inicial puede no ser suficiente para detener todo el trabajo. ¿Y si aparece texto en stderr pero el código es 0? El agente no debería concluir automáticamente que las pruebas han fallado.
En Python, subprocess.run() puede resolver ejecuciones sencillas, e incluso admite un timeout, pero ese tiempo máximo actúa sobre el proceso que hemos lanzado, no sobre todos sus descendientes. Tampoco decide qué comandos están permitidos, con qué permisos se ejecutan ni cuánto acceso tienen al sistema. Esas decisiones forman parte del diseño del ejecutor. Y para tomarlas con criterio, primero había que entender qué es un proceso, cómo se comunica y cómo termina.
10. Subshells y $BASHPID
Antes vimos que $$ nos da el PID de Bash, pero hay un matiz que dejamos pendiente. A veces Bash ejecuta una parte del script en una subshell: una copia de la shell actual que tiene su propio entorno de ejecución. Una forma sencilla de crearla es encerrar varios comandos entre paréntesis:
nombre="fuera"
(
nombre="dentro"
echo "En la subshell: $nombre"
)
echo "En la shell original: $nombre"
La subshell empieza con una copia de las variables, pero los cambios que hagamos dentro no vuelven a la shell original. Por eso veremos primero dentro y después fuera. Los paréntesis sirven para ejecutar ese bloque en otro entorno; las llaves { ...; }, en cambio, agrupan comandos en la shell actual.
Y aquí aparece la diferencia entre $$ y $BASHPID. Probemos este script:
echo "Shell original: \$\$=$$ BASHPID=$BASHPID"
(
echo "Subshell: \$\$=$$ BASHPID=$BASHPID"
)
La salida podría ser algo así:
Shell original: $$=2589 BASHPID=2589
Subshell: $$=2589 BASHPID=2590
En la shell original coinciden. Dentro de los paréntesis, $$ sigue mostrando el PID de la shell que invocó la subshell, mientras que $BASHPID muestra el PID del proceso Bash que está ejecutando ese bloque. Los números concretos cambiarán en tu equipo.
También aparecen entornos de subshell al ejecutar comandos en segundo plano o al usar sustituciones como $(comando). De momento, quédate con la idea principal: una subshell puede leer los valores que heredó, pero sus cambios no modifican la shell que la creó. Y si necesitas identificar el proceso Bash que está ejecutando un bloque, $BASHPID expresa eso mejor que $$.
11. Script orquestador
Para terminar podemos hacer una pequeña práctica, que sugiero intentar por uno mismo antes de ver la solución. La idea es preparar un script orquestador que lance dos scripts, el segundo una vez que haya terminado el primero.
Primero preparamos los workers. El primero, que podemos llamar worker-1.sh, con algo así.
#!/usr/bin/env bash
echo "Holi, soy worker 1. Mi PID es: $BASHPID"
sleep 5
echo "Adiós, soy worker 1 y he terminado mi misión en la vida"
El segundo, worker-2.sh, muy parecido.
echo "Hey, qué pasa, soy worker 2, con PID: $BASHPID"
sleep 3
echo "Y hasta aquí ha llegado worker 2"
Y ahora ya nuestro coordinador, coordinator.sh, con un script parecido a este.
#!/usr/bin/env bash
# Muestra el identificador del proceso coordinador.
echo "Aquí Coordinator] con PID: $BASHPID"
# Inicia el primer worker en segundo plano y guarda su PID.
echo "Coordinator lanzando worker 1..."
bash worker-1.sh &
pid=$!
# Espera a que termine el worker antes de continuar con el siguiente.
echo "Coordinator Worker 1 PID: $pid"
echo "Coordinator esperando como un campeón..."
# Conserva el código de salida para detectar y propagar errores.
wait "$pid"
status=$?
# Si falla el primer worker, no se inicia el segundo.
if [[ $status -ne 0 ]]; then
echo "Worker 1 failed"
exit "$status"
fi
# El segundo worker solo se inicia cuando el primero termina correctamente.
echo "Coordinator lanzando worker 2..."
bash worker-2.sh &
pid=$!
# Espera también al segundo worker y registra su resultado.
echo "Coordinator Worker 2 PID: $pid"
echo "Esperando de nuevo..."
wait "$pid"
status=$?
echo "Coordinator ha terminado su trabajo con status: $status"
exit "$status"
Y si todo ha ido bien, al ejecutar ./coordinator.sh deberíamos ver algo así:
Aquí Coordinator] con PID: 2613
Coordinator lanzando worker 1...
Coordinator Worker 1 PID: 2614
Coordinator esperando como un campeón...
Holi, soy worker 1. Mi PID es: 2614
Adiós, soy worker 1 y he terminado mi misión en la vida
Coordinator lanzando worker 2...
Coordinator Worker 2 PID: 2616
Esperando de nuevo...
Hey, qué pasa, soy worker 2, con PID: 2616
Y hasta aquí ha llegado worker 2
Coordinator ha terminado su trabajo con status: 0