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:
forkcrea un proceso sin cargar un programa nuevo.execcarga un programa nuevo sin crear un proceso nuevo.waites 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ó demaino usó_exit.WEXITSTATUS(status)extrae el exit code de 8 bits cuandoWIFEXITEDes true.WIFSIGNALED(status)yWTERMSIG(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:
forkprodujo un PID nuevo (69397) mientras el padre siguió corriendo.- El hijo cambió
shared_countera777, pero el contador del padre siguió en100; misma memoria inicial, address spaces separados después de escribir. - La imagen de
execconservó el mismo PID (69397) peroshared_countervolvió a100; 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 eninherited_fd=4. waitpidrecolectó exactamente ese hijo y decodificó el exit status normal42.
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.
cloneyclone3pueden crear procesos o threads según flags;forkes el caso con forma POSIX. El código de usuario no debería usarclonecrudo salvo que esté implementando un runtime, una herramienta de contenedores o una threading library. - El wrapper
forkde glibc hace más que una syscall cruda. En programas con threads corre handlers depthread_atforky 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_spawnsuele ser el camino preferido en macOS porque evita peligros deforken procesos grandes y con muchos threads. - El loader de ejecutables difiere. Linux suele cargar ELF y luego
ld-linuxpara programas dinámicamente linkeados; macOS carga Mach-O ydyld.execes la operación de proceso; ELF/Mach-O es el detalle de formato de archivo.
Modos de falla y trade-offs
- Olvidar que
execsolo vuelve si falla. El código después deexecvpes el camino de error. Manejalo, reportalo y terminá el hijo con_exit, no conexit. - Flushear dos veces I/O bufferizado. Después de
fork, padre e hijo pueden tener copias de buffers de stdio. Si el hijo llamaexittras una falla deexec, puede flushear datos que el padre también va a flushear. Usá_exiten 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 da300al 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
forken un proceso con threads. POSIX solo garantiza funciones async-signal-safe en el hijo antes deexec. Ahíposix_spawnsuele 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.forkpuede 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á
forkmásexeccuando el hijo deba customizar su estado de proceso. Shells, supervisors, test runners y constructores de pipelines necesitan esa ventana pre-exec. - Usá
posix_spawncuando 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á
waitpiden loops que manejenEINTR. Las señales pueden interrumpir waits bloqueantes; el código robusto reintenta o pasa por un loop central de reaping. - Usá
_exiten el hijo después deforksiexecfalla. El hijo no debería correr la pila de cleanup del padre. - Recordá la versión del proyecto OS.
forksignifica duplicar una vista de address space,execsignifica cargar una imagen ejecutable fresca, ywaitsignifica 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.htmlexecve(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.htmlwaitpid(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.htmlclone(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/