conceptProgramación de Sistemas~10 min de lecturaActualizado 2026-07-02#systems#processes#fork#exec#wait#posix

Procesos: fork, exec y wait

Un programa son bytes en disco. Un proceso es el contenedor de ejecución que posee el OS alrededor de esos bytes: PID, address space virtual, tabla de file descriptors, credenciales, directorio actual, estado de señales, límites de recursos y al menos un thread de ejecución. La creación de procesos en Unix está separada en tres movimientos: fork crea otro proceso, exec cambia qué programa corre ese proceso, y wait deja que el padre observe y recolecte la terminación del hijo. Esa separación es la razón por la que una shell puede crear un hijo, cablear redirecciones y pipes en el hijo, y después reemplazarlo por grep.

El reset: fork crea un proceso sin cargar un programa nuevo. exec carga un programa nuevo sin crear un proceso nuevo. wait es cómo el padre recolecta el estado final para que el kernel pueda olvidarse del hijo muerto.

La separación Unix: crear, transformar, recolectar

La mayoría de los runtimes de alto nivel exponen una operación tipo "lanzar un programa". Unix expone las piezas. La forma clásica de una shell es:

pid_t pid = fork();
if (pid == 0) {
    /* child: adjust file descriptors, environment, signal state, cwd... */
    execvp(argv[0], argv);
    _exit(127); /* exec returns only on failure */
}
waitpid(pid, &status, 0);

Esa forma parece rara hasta que ves qué compra: el hijo tiene una ventana corta entre fork y exec donde todavía es tu programa, pero ya tiene su propia identidad de proceso. Ese es el momento perfecto para preparar el mundo del futuro programa: dup2 para stdin/stdout, cerrar extremos de pipe que sobran, setear variables de entorno, cambiar de directorio, resetear signal handlers, bajar privilegios y recién ahí hacer exec.

También es el puente mental hacia el proyecto del OS. Para implementar este trío, un kernel necesita process table, asignación de PIDs, address spaces por proceso, tabla de file descriptors, loader ELF, almacenamiento de exit status y una forma de dormir al padre hasta que un hijo cambie de estado. fork/exec/wait no es trivia de API: es la cara user-space de process management.

Qué duplica realmente fork

fork() devuelve dos veces: una en el padre con el PID del hijo, y otra en el hijo con valor de retorno 0. Los dos procesos continúan en la misma instrucción después de la llamada. Tienen PIDs distintos, registros de tarea distintos en el kernel y address spaces virtuales separados, pero la memoria del hijo empieza con los mismos bytes que tenía el padre.

Los kernels modernos evitan copiar todas las páginas inmediatamente. La implementación típica es copy-on-write: padre e hijo inicialmente mapean las mismas páginas físicas como solo lectura; cuando cualquiera escribe una página, el kernel recibe un page fault, copia esa página y reanuda al escritor con una copia privada. Eso hace que fork sea lo suficientemente barato para el camino común fork y después exec, donde el hijo está por tirar casi toda la memoria heredada.

Parte del estado se duplica por valor, y parte se duplica por referencia:

Estado Después de fork
PID el padre conserva su PID; el hijo recibe uno nuevo
memoria virtual mismos contenidos iniciales, address spaces separados, normalmente copy-on-write
variables C y heap mismos valores iniciales, luego independientes al escribir
tabla de file descriptors la tabla se copia, pero las entradas refieren a las mismas open file descriptions
offsets de archivo compartidos a través de esas open file descriptions
directorio actual heredado
signal dispositions heredadas
threads el hijo contiene solo el thread que llamó a fork

La fila de file descriptors carga mucho peso. Si un padre tiene el fd 3 apuntando a un archivo y después hace fork, el hijo también tiene fd 3, y ambas entradas apuntan al mismo objeto de archivo abierto en el kernel. Reads y writes pueden avanzar el mismo file offset. Los pipes se apoyan en la misma regla: el hijo puede heredar un extremo del pipe, hacer exec de otro programa, y ese programa todavía puede escribir en el fd heredado.

Qué reemplaza realmente exec

exec es una familia de funciones de libc (execl, execv, execvp, execve, ...). El primitivo del kernel es execve(path, argv, envp): cargar una imagen de programa nueva en el proceso actual. Si tiene éxito, no devuelve, porque el código viejo, stack, heap, globals y mappings desaparecieron. El mismo PID continúa, pero el address space se reconstruye alrededor del ejecutable nuevo.

En sistemas ELF, execve hace que el kernel inspeccione los headers del ejecutable, mapee segmentos cargables, cree un stack de usuario fresco con argv, envp y entradas del auxiliary vector, y después transfiera control al entry point del programa o al dynamic loader nombrado por PT_INTERP. En macOS el formato es Mach-O y el loader cambia, pero la idea a nivel proceso es la misma: misma identidad de proceso, imagen nueva.

Lo que sobrevive a exec importa tanto como lo que muere:

Atributo Tras un exec exitoso
PID y parent PID preservados
file descriptors abiertos preservados salvo que tengan close-on-exec
directorio actual preservado
environment reemplazado por el envp que pasás, o heredado según el wrapper
memory mappings, heap, stack reemplazados
signal handlers capturados reseteados al default
señales ignoradas generalmente preservadas
otros threads desaparecen; la imagen nueva arranca con un thread

El flag close-on-exec (FD_CLOEXEC) es la defensa contra leaks accidentales de fds. Si un server abre un socket privado, hace fork y ejecuta un helper, cualquier fd sin close-on-exec queda visible dentro del helper. Eso es un bug de correctitud y muchas veces de seguridad. Preferí APIs que setean el flag atómicamente (O_CLOEXEC, pipe2 en Linux, o fcntl(fd, F_SETFD, FD_CLOEXEC) cuando tenés que reparar un fd existente).

Qué observa wait y por qué existen los zombies

Cuando un proceso termina, el kernel no puede borrar inmediatamente todo rastro. El padre todavía puede necesitar saber: ¿salió normalmente, con qué status, o lo mató una señal? Entonces el kernel conserva un registro mínimo del proceso muerto con PID, contabilidad de recursos y estado de terminación. Ese registro es un zombie: no corre, no tiene memoria de usuario, pero ocupa una entrada hasta que el padre hace wait.

waitpid(pid, &status, options) es la herramienta precisa:

  • waitpid(child, &status, 0) espera un hijo específico.
  • waitpid(-1, &status, 0) espera cualquier hijo.
  • WIFEXITED(status) pregunta si el hijo llamó exit, retornó de main o usó _exit.
  • WEXITSTATUS(status) extrae el exit code de 8 bits cuando WIFEXITED es true.
  • WIFSIGNALED(status) y WTERMSIG(status) reportan muerte por señal.

Si el padre termina antes que el hijo, el hijo es reparentado a un proceso ancestro que lo va a recolectar. En Linux suele ser systemd o un subreaper; históricamente era PID 1 init. Los programas de larga vida que crean hijos necesitan una estrategia de reaping: waitpid bloqueante, waitpid(..., WNOHANG) no bloqueante, o un camino vía SIGCHLD.

Artefacto ejecutable: un hijo, un exec, un wait

El ejemplo vive en examples/systems-programming/processes-fork-exec-and-wait/. Crea un pipe, hace fork, deja que el hijo escriba un mensaje antes de exec, y después hace que el hijo ejecute el mismo binario en un modo especial y escriba un segundo mensaje a través del pipe heredado. El padre lee el pipe y espera el exit status 42.

El corazón del demo:

pid_t child = fork();
if (child == 0) {
    close(pipefd[0]);
    shared_counter = 777;
    write_all(pipefd[1], "fork child ... counter=777\n", 27);

    char *const child_argv[] = {argv[0], "--exec-child", fd_arg, NULL};
    execvp(child_argv[0], child_argv);
    _exit(127);
}

close(pipefd[1]);
read(pipefd[0], buf, sizeof buf);
waitpid(child, &status, 0);

Compilá y corré:

cd examples/systems-programming/processes-fork-exec-and-wait
./run.sh

Salida real de esta máquina:

== build ==
== run ==
parent: pid=69368 counter=100
parent: fork returned child pid=69397; counter is still 100
parent: messages from the child-side pipe
fork child: pid=69397 ppid=69368 inherited_fd=4 counter=777
exec image: pid=69397 ppid=69368 inherited_fd=4 counter=100
parent: waitpid reaped pid=69397 exit_status=42
parent: after wait, counter=100

Leé la salida como un trace de procesos:

  • fork produjo un PID nuevo (69397) mientras el padre siguió corriendo.
  • El hijo cambió shared_counter a 777, pero el contador del padre siguió en 100; misma memoria inicial, address spaces separados después de escribir.
  • La imagen de exec conservó el mismo PID (69397) pero shared_counter volvió a 100; la imagen vieja fue reemplazada por una ejecución fresca del programa.
  • El fd del pipe sobrevivió a exec, así que la imagen nueva pudo escribir en inherited_fd=4.
  • waitpid recolectó exactamente ese hijo y decodificó el exit status normal 42.

macOS/BSD vs Linux: misma forma POSIX, otra capa baja

El contrato portable es POSIX: llamá fork, llamá una función exec*, llamá waitpid, y escribí contra los valores de retorno y errnos documentados. Ese código funciona en Linux, macOS y BSDs cuando te quedás en la capa libc/POSIX.

Por debajo, los kernels difieren:

  • Linux expone primitivos de proceso específicos. clone y clone3 pueden crear procesos o threads según flags; fork es el caso con forma POSIX. El código de usuario no debería usar clone crudo salvo que esté implementando un runtime, una herramienta de contenedores o una threading library.
  • El wrapper fork de glibc hace más que una syscall cruda. En programas con threads corre handlers de pthread_atfork y preserva expectativas de libc. Bypassearlo puede dejar locks y estado del runtime inconsistentes.
  • macOS prefiere fuerte libc/libSystem como interfaz estable. Los números de syscall crudos no son un contrato estable. Para lanzar programas, posix_spawn suele ser el camino preferido en macOS porque evita peligros de fork en procesos grandes y con muchos threads.
  • El loader de ejecutables difiere. Linux suele cargar ELF y luego ld-linux para programas dinámicamente linkeados; macOS carga Mach-O y dyld. exec es la operación de proceso; ELF/Mach-O es el detalle de formato de archivo.

Modos de falla y trade-offs

  • Olvidar que exec solo vuelve si falla. El código después de execvp es el camino de error. Manejalo, reportalo y terminá el hijo con _exit, no con exit.
  • Flushear dos veces I/O bufferizado. Después de fork, padre e hijo pueden tener copias de buffers de stdio. Si el hijo llama exit tras una falla de exec, puede flushear datos que el padre también va a flushear. Usá _exit en el camino de error del hijo.
  • Filtrar file descriptors a programas ejecutados. Cualquier fd sin close-on-exec puede sobrevivir. Eso puede mantener sockets abiertos, impedir EOF en pipes, exponer secretos y crear bugs de lifetime confusos.
  • Crear zombies. Un padre que nunca espera deja hijos muertos como registros en la process table. Una herramienta corta quizá zafa; un daemon no.
  • Leer mal el exit status. exit(300) no le da 300 al padre; el status tradicional expone solo los 8 bits bajos para salidas normales. Muerte por señal es otro caso.
  • Llamar código inseguro tras fork en un proceso con threads. POSIX solo garantiza funciones async-signal-safe en el hijo antes de exec. Ahí posix_spawn suele ganar.
  • Asumir que la memoria se comparte después de fork. No se comparte como en threads. Si padre e hijo necesitan comunicarse, usá pipes, sockets, shared memory, archivos u otro mecanismo IPC.
  • Ignorar fallas de fork. fork puede fallar por límites de procesos o presión de memoria. Tratala como cualquier otra frontera syscall: chequeá el retorno.

En la práctica

  • Usá fork más exec cuando el hijo deba customizar su estado de proceso. Shells, supervisors, test runners y constructores de pipelines necesitan esa ventana pre-exec.
  • Usá posix_spawn cuando solo querés lanzar un programa. Suele ser más simple y seguro en programas con threads, especialmente en macOS.
  • Seteá close-on-exec por defecto. Que la herencia de fds sea explícita, no accidental.
  • Usá waitpid en loops que manejen EINTR. Las señales pueden interrumpir waits bloqueantes; el código robusto reintenta o pasa por un loop central de reaping.
  • Usá _exit en el hijo después de fork si exec falla. El hijo no debería correr la pila de cleanup del padre.
  • Recordá la versión del proyecto OS. fork significa duplicar una vista de address space, exec significa cargar una imagen ejecutable fresca, y wait significa guardar estado del hijo hasta que el padre lo consuma.

Apéndice ARM64

El modelo de procesos no es específico de la arquitectura, pero la capa de entrada de syscalls sí. En ARM64 Linux, libc entra al kernel con svc #0, y los números/registros de syscall crudos difieren de x86-64. En Apple Silicon, libSystem sigue siendo la frontera soportada y la interfaz cruda de syscalls de XNU no es el contrato contra el que conviene programar. El código C del demo no cambia porque usa wrappers POSIX; solo libc y el port del kernel necesitan conocer la instrucción de trap y la syscall table.

Para un OS desde cero, esta distinción importa. La semántica visible de procesos puede ser Unix-like en x86-64 o ARM64, pero el entry path de bajo nivel, el frame de context switch y el ABI del ejecutable son trabajo específico de arquitectura.

Conecta con: Systems Programming · La syscall: cruzar la frontera hacia el kernel · El address space del proceso y memoria virtual · El formato ELF · El dynamic loader, relocation, PLT y GOT · La biblioteca estándar mínima · OS desde Cero

Fuentes

  • Michael Kerrisk - The Linux Programming Interface, caps. 24-27 - tratamiento detallado de creación de procesos, terminación, monitoreo de hijos y ejecución de programas. https://man7.org/tlpi/
  • fork(2) - man7.org - semántica Linux/POSIX, notas de copy-on-write, reglas de herencia y detalles del wrapper de glibc. https://man7.org/linux/man-pages/man2/fork.2.html
  • execve(2) - man7.org - contrato kernel-level de exec: argv/envp, atributos preservados, atributos reseteados, interpreter scripts y manejo del intérprete ELF. https://man7.org/linux/man-pages/man2/execve.2.html
  • waitpid(2) - man7.org - macros de status, zombies, variantes de wait y transiciones de estado de hijos. https://man7.org/linux/man-pages/man2/waitpid.2.html
  • clone(2) - man7.org - primitivo específico de Linux para creación de procesos/threads debajo de muchos diseños de runtime. https://man7.org/linux/man-pages/man2/clone.2.html
  • POSIX fork() - The Open Group Base Specifications - definición portable del lado de creación de procesos del trío y sus reglas de herencia. https://pubs.opengroup.org/onlinepubs/9699919799/functions/fork.html
  • Stevens & Rago - Advanced Programming in the UNIX Environment (APUE) - explicaciones clásicas de control de procesos Unix y ejemplos con forma de shell. http://www.apuebook.com/