Durante todo el módulo 1 hemos usado la palabra proceso como si fuera evidente: ingestor, agregador y meteo-api aparecían en la salida de ps, cruzaban la frontera al modo núcleo y consumían recursos. Pero nunca abrimos esa caja. En esta lección la abrimos del todo. Vas a ver qué es exactamente un proceso en memoria, qué estructura de datos lo representa dentro del núcleo de Linux, por qué atraviesa unos estados y no otros, cómo nace —con el mecanismo más extraño y elegante de UNIX, fork()— y qué cuesta realmente que la CPU deje de ejecutar uno para ejecutar otro.
Al terminar sabrás leer /proc/<pid>/ como quien lee una ficha médica, entenderás por qué un proceso zombi no se mata con kill -9 y podrás escribir en C un supervisor que lance y vigile el agregador. Es la lección más fundamental del curso: casi todo lo que viene después —planificación, memoria, concurrencia, contenedores— se apoya en lo que veas aquí.
Contenido
- Programa y proceso: la diferencia que lo cambia todo
- La imagen de memoria de un proceso
- El bloque de control de proceso:
task_structcampo a campo - Estados de un proceso y sus transiciones
- Los códigos de estado reales de
ps - Creación de procesos:
fork()y copy-on-write execve(): sustituir el programa sin cambiar de procesowait(), códigos de salida, zombis y huérfanos- Ejemplo completo: un supervisor para el
agregador - La jerarquía de procesos y
init/systemd - El cambio de contexto: qué se guarda y qué cuesta
- Inspección real:
/proc,ps -eo,pstree
Programa y proceso: la diferencia que lo cambia todo
Un programa es un fichero en disco. Es pasivo, no hace nada, es una secuencia de bytes con un formato concreto (en Linux, ELF) que describe qué código cargar y dónde.
Un proceso es un programa en ejecución: es activo, tiene estado, tiene recursos asignados y tiene una vida.
En meteo-01 puedes verlo literalmente:
$ ls -l /opt/meteora/bin/agregador -rwxr-xr-x 1 root root 87264 ago 12 10:04 /opt/meteora/bin/agregador $ ps -eo pid,comm | grep agregador 1877 agregador 1902 agregador
Un único fichero de 87 KB, dos procesos. Cada uno tiene su propio PID, su propia memoria de trabajo, sus propios ficheros abiertos y su propio punto de ejecución. Comparten el código (el núcleo lo mapea una sola vez en RAM y lo referencia desde ambos), pero nada más.
La relación programa→proceso es 1 a N. Y no solo eso: un mismo proceso puede ejecutar sucesivamente varios programas a lo largo de su vida, como veremos con execve().
| Programa | Proceso | |
|---|---|---|
| Naturaleza | Pasivo | Activo |
| Dónde vive | Disco | Memoria + estructuras del núcleo |
| Duración | Permanente | Desde su creación hasta su terminación |
| Identidad | Ruta del fichero | PID |
| Cuántos | Uno | Muchos del mismo programa a la vez |
La imagen de memoria de un proceso
Cuando el núcleo pone en marcha un programa, construye en el espacio de direcciones del proceso una imagen de memoria con varias regiones bien diferenciadas. De direcciones bajas a altas:
Direcciones altas (0x7fff...) ┌──────────────────────────────┐ │ Argumentos y entorno │ argv[], environ ├──────────────────────────────┤ │ Pila (stack) │ crece hacia abajo ↓ │ ↓ │ marcos de llamada, variables locales │ │ │ (hueco) │ │ │ │ ↑ │ │ Bibliotecas compartidas │ libc.so, libm.so (mapeadas) │ │ │ Montículo (heap) │ crece hacia arriba ↑ ├──────────────────────────────┤ malloc(), brk/mmap │ BSS │ variables globales sin inicializar (a 0) ├──────────────────────────────┤ │ Datos (.data) │ variables globales inicializadas ├──────────────────────────────┤ │ Código (.text) │ instrucciones, solo lectura + ejecución └──────────────────────────────┘ Direcciones bajas (0x400000)
Cada región tiene un propósito y unos permisos distintos, y esto no es decoración: es protección real aplicada por la MMU.
| Región | Contenido | Permisos | Tamaño | Quién la gestiona |
|---|---|---|---|---|
.text |
Código máquina | r-x | Fijo, del ELF | El cargador |
.data |
Globales inicializadas (int n = 5;) |
rw- | Fijo, del ELF | El cargador |
.bss |
Globales a cero (static char buf[4096];) |
rw- | Fijo, sin ocupar disco | El cargador |
| Heap | Memoria dinámica (malloc) |
rw- | Variable | El programa vía libc |
| Pila | Marcos de llamada, locales | rw- | Variable, con límite | Automático (CPU) |
| Mapeos | Bibliotecas, ficheros con mmap |
varía | Variable | mmap() |
Dos matices que casi siempre se pasan por alto:
.bssno ocupa espacio en el fichero ejecutable. Si declarasstatic struct Lectura cache[100000];(2,4 MB), el ELF no crece 2,4 MB: solo guarda "reserva 2.400.000 bytes a cero". El núcleo los materializa en el arranque del proceso. Por eso.bsssignifica históricamente block started by symbol, y por eso hay ejecutables de 90 KB que ocupan 50 MB de RAM..textes de solo lectura y compartido. Los dosagregadorde arriba tienen sus páginas de código apuntando a los mismos marcos físicos de RAM. Solo hay una copia del código en memoria por muchos procesos que lo ejecuten. Esto ahorra memoria y es la razón por la que ejecutar 200 instancias de un servicio no cuesta 200 × el tamaño del binario.
El bloque de control de proceso: task_struct campo a campo
Toda esa imagen de memoria es el proceso visto desde fuera. Dentro del núcleo, cada proceso está representado por una estructura de datos: el bloque de control de proceso (PCB). En Linux se llama task_struct y vive en include/linux/sched.h. Es una de las estructuras más grandes del núcleo: alrededor de 7 KB en un x86-64 típico, con más de 200 campos.
No necesitas conocerlos todos, pero sí los grupos y el porqué de cada uno:
| Grupo | Campos representativos | Para qué sirve |
|---|---|---|
| Identidad | pid, tgid, real_parent, parent, children, sibling |
Quién es y su lugar en la jerarquía |
| Estado | __state, exit_state, exit_code |
En qué situación está y cómo terminó |
| Planificación | prio, static_prio, normal_prio, se (entidad CFS), policy, cpus_mask |
Cuánta CPU merece y dónde puede ejecutarse (lección 02-02) |
| Contexto de CPU | thread (registros guardados), stack (pila de núcleo) |
Qué restaurar al volver a ejecutarlo |
| Memoria | mm (descriptor del espacio de direcciones), active_mm |
Qué memoria ve (lecciones 02-03 y 02-04) |
| Ficheros | files (tabla de descriptores), fs (directorio actual, raíz) |
Qué tiene abierto y desde dónde |
| Señales | signal, sighand, blocked, pending |
Qué señales puede recibir y cómo las trata |
| Credenciales | cred (uid, gid, euid, capacidades) |
Qué tiene permitido hacer (módulo 5) |
| Contabilidad | utime, stime, start_time, nvcsw, nivcsw |
Tiempo consumido y cambios de contexto |
| Espacios de nombres | nsproxy |
Qué "vista" del sistema tiene (módulo 6) |
Un detalle importante que conecta con lo que ya sabes: el campo mm es un puntero. Eso significa que dos task_struct distintos pueden apuntar al mismo espacio de direcciones. Cuando eso ocurre, lo que tienes no son dos procesos, sino dos hilos del mismo proceso. Lo veremos a fondo en Hilos y Procesos, pero ya puedes intuir la idea central de Linux: no hay dos estructuras, procesos e hilos; hay una sola, task_struct, y lo que cambia es cuánto comparten.
Puedes ver muchos de estos campos desde el espacio de usuario:
$ sudo cat /proc/1877/status | head -20 Name: agregador Umask: 0022 State: S (sleeping) Tgid: 1877 Ngid: 0 Pid: 1877 PPid: 1 TracerPid: 0 Uid: 998 998 998 998 Gid: 998 998 998 998 FDSize: 64 Groups: 998 NStgid: 1877 NSpid: 1877 VmPeak: 412308 kB VmSize: 408212 kB VmRSS: 31456 kB Threads: 3 voluntary_ctxt_switches: 18422 nonvoluntary_ctxt_switches: 291
Traducido campo a campo:
State: S: durmiendo, esperando algo (lo veremos en el siguiente apartado).Tgid: 1877igual aPid: 1877: es el hilo principal del grupo. Si fuera un hilo secundario,Pidsería distinto deTgid.PPid: 1: su padre es el PID 1, systemd. Es un servicio del sistema, no lo lanzó una shell.Uid: 998: corre como el usuariometeora, no como root. Exactamente lo que queremos.VmSize408 MB frente aVmRSS31 MB: reserva mucho espacio de direcciones pero solo tiene 31 MB realmente en RAM. Esta diferencia es la esencia de la memoria virtual y la desarrollaremos en Memoria Virtual y Paginación.voluntary_ctxt_switches: 18422: se ha apartado voluntariamente de la CPU 18.422 veces (porque se bloqueó esperando E/S). Frente a solo 291 involuntarios (le quitaron la CPU). Este proceso está claramente limitado por E/S, un dato que reutilizaremos en la próxima lección.
Estados de un proceso y sus transiciones
Un proceso no está siempre ejecutándose. En una máquina con 4 núcleos, como mucho 4 procesos están en la CPU en un instante dado; los otros 300 están en algún otro estado. El modelo clásico tiene cinco:
stateDiagram-v2
[*] --> Nuevo: fork()
Nuevo --> Listo: admitido
Listo --> Ejecutando: el planificador lo elige
Ejecutando --> Listo: se agota el quantum (apropiación)
Ejecutando --> Bloqueado: espera E/S o evento
Bloqueado --> Listo: llega el dato / ocurre el evento
Ejecutando --> Terminado: exit()
Terminado --> [*]: el padre hace wait()
Ejecutando --> Detenido: SIGSTOP
Detenido --> Listo: SIGCONT
Lo importante es entender por qué existe cada transición:
- Nuevo → Listo: el núcleo ha terminado de construir el
task_structy de montar el espacio de direcciones. Ya es un candidato válido para la CPU. - Listo → Ejecutando: la decide el planificador, y es el tema completo de la próxima lección.
- Ejecutando → Listo (apropiación): el proceso no ha pedido nada; simplemente se le acabó su turno o llegó otro más prioritario. Esta flecha es la que distingue un sistema multitarea apropiativo de uno cooperativo, como vimos en 01-03.
- Ejecutando → Bloqueado: el proceso pide algo que no está disponible ya mismo —un
read()de socket sin datos, por ejemplo—. Sería absurdo dejarlo ocupando la CPU esperando. El núcleo lo aparta y elige a otro. - Bloqueado → Listo: llegan los datos, típicamente vía interrupción del dispositivo (lección 02-07). Ojo: no pasa a Ejecutando directamente. Pasa a Listo y compite de nuevo.
- Ejecutando → Terminado: llama a
exit()o recibe una señal letal. - Terminado → fuera: solo cuando su padre recoge el código de salida. Aquí es donde aparecen los zombis.
La transición que más confunde a quien empieza es Bloqueado → Listo en lugar de Bloqueado → Ejecutando. La razón es simple: cuando llegan los datos que esperaba ingestor, puede haber otros diez procesos listos y una sola CPU libre. Que un proceso deje de estar bloqueado no le da derecho a ejecutarse inmediatamente; solo le devuelve el derecho a competir.
Los códigos de estado reales de ps
Linux refina el modelo teórico. Estos son los códigos que verás de verdad:
| Código | Nombre en el núcleo | Significado | ¿Interrumpible? |
|---|---|---|---|
R |
TASK_RUNNING |
Ejecutando o listo para ejecutarse | — |
S |
TASK_INTERRUPTIBLE |
Durmiendo, esperando un evento | Sí, las señales lo despiertan |
D |
TASK_UNINTERRUPTIBLE |
Durmiendo en E/S de disco | No, ni con kill -9 |
T |
TASK_STOPPED |
Detenido por SIGSTOP o por el depurador |
Con SIGCONT |
Z |
EXIT_ZOMBIE |
Terminado, esperando que el padre lo recoja | No es ejecutable |
I |
TASK_IDLE |
Hilo de núcleo ocioso | — |
Dos cosas merecen atención especial:
R significa "ejecutable", no "ejecutándose". Linux no distingue entre Listo y Ejecutando en su representación de estado; ambos son TASK_RUNNING. Si ps te muestra 30 procesos en R en una máquina de 4 núcleos, no hay contradicción: 4 están en la CPU y 26 están en las colas de ejecución esperando su turno. Cuando esto ocurre de forma sostenida, tienes una CPU saturada, y así es como se detecta.
D es el estado que produce las peores incidencias de producción. Un proceso en D está dentro del núcleo, en medio de una operación de disco, en un punto donde el código no puede abortarse sin corromper estructuras. Por eso no admite señales: kill -9 queda pendiente hasta que el proceso salga de D. Si el agregador se queda en D porque el disco de /var/lib/meteora tiene errores o un NFS no responde, no podrás matarlo, y verás la carga media dispararse aunque la CPU esté al 0 % (en Linux la carga media cuenta también los procesos en D, no solo los R).
Modificadores que verás junto al estado:
$ ps -eo pid,ppid,stat,comm --sort=-pcpu | head -8
PID PPID STAT COMMAND
1877 1 Ssl agregador
1842 1 Ssl ingestor
1901 1 Ss meteo-api
2214 1901 S meteo-api
3487 3401 R+ pss: es líder de sesión.l: es multihilo (tiene variostask_structcon el mismomm).+: está en primer plano en un terminal.</N: prioridad alta / baja (lo verás en 02-02).
Creación de procesos: fork() y copy-on-write
Aquí viene la parte que a todo el mundo le parece rara la primera vez. En UNIX, la única forma de crear un proceso es duplicar uno existente con fork().
#include <unistd.h>
#include <stdio.h>
int main(void) {
printf("Antes: PID = %d\n", getpid());
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
} else if (pid == 0) {
printf("HIJO: PID = %d, PPID = %d\n", getpid(), getppid());
} else {
printf("PADRE: PID = %d, el hijo es %d\n", getpid(), pid);
}
return 0;
}Lo que ocurre y por qué desconcierta:
fork()se llama una vez y retorna dos veces. Retorna en el padre (con el PID del hijo) y retorna en el hijo (con0). No hay magia: el núcleo crea un segundotask_structque es una copia del primero, con el registro de retorno puesto a 0. Cuando el planificador ejecute ese nuevo proceso, este continuará justo después delfork, exactamente donde el padre.- El hijo hereda casi todo: el espacio de direcciones (una copia lógica), los descriptores de fichero abiertos, el directorio de trabajo, el
umask, las credenciales, los límites de recursos. - No hereda: el PID (es nuevo), el PPID (ahora apunta al padre), los tiempos de CPU acumulados (arrancan a cero), las alarmas pendientes y las señales pendientes.
- El orden de las líneas no está garantizado. En la salida de arriba el padre imprimió antes, pero podría haber sido al revés. Quién se ejecuta primero lo decide el planificador. Cualquier código que dependa de ese orden está mal.
Copy-on-write: por qué fork() no es caro
La pregunta obvia: si meteo-api tiene 400 MB de espacio de direcciones, ¿fork() copia 400 MB? Sería un desastre, sobre todo porque en el 95 % de los casos lo siguiente que hace el hijo es llamar a execve() y tirar toda esa copia a la basura.
Los UNIX modernos usan copy-on-write (COW):
fork()copia solo eltask_structy las tablas de páginas, no las páginas de datos.- Todas las páginas de datos se marcan como solo lectura en ambos procesos, y se anota que son compartidas.
- Mientras ambos solo lean, comparten físicamente la misma RAM. Coste: cero copias.
- Cuando uno de los dos escribe en una página, la MMU genera un fallo de protección. El núcleo lo intercepta, copia esa única página de 4 KB, la da en exclusiva al que escribió y la marca de lectura/escritura.
El resultado: fork() de un proceso de 400 MB copia unos pocos cientos de KB de tablas y cuesta del orden de 0,5 ms en lugar de cientos de milisegundos. Y si el hijo hace execve() inmediatamente, casi ninguna página llega a copiarse nunca.
Este mecanismo se apoya directamente en la MMU y en el fallo de página, que desarrollaremos en Memoria Virtual y Paginación. Por ahora quédate con la idea: compartir hasta que alguien escriba es uno de los patrones más rentables de todo el diseño de sistemas operativos.
execve(): sustituir el programa sin cambiar de proceso
fork() duplica. execve() reemplaza: mantiene el task_struct (mismo PID, mismo padre, mismos descriptores abiertos) pero tira toda la imagen de memoria y carga un programa nuevo en su lugar.
#include <unistd.h>
#include <stdio.h>
int main(void) {
char *argv[] = { "/opt/meteora/bin/agregador", "--intervalo", "3600", NULL };
char *envp[] = { "METEORA_CONF=/etc/meteora/meteora.conf", NULL };
printf("Soy el PID %d y voy a convertirme en agregador\n", getpid());
execve(argv[0], argv, envp);
/* Si llegamos aquí, execve ha fallado */
perror("execve");
return 1;
}Puntos clave:
execve()no retorna si tiene éxito. No hay "después". El código que lo llamó ya no existe en memoria: ha sido sustituido. Por eso elperrorde abajo solo se ejecuta en caso de error, y por eso siempre debe estar ahí.- El PID no cambia. Es el mismo proceso ejecutando otro programa. Esto tiene consecuencias prácticas enormes: systemd puede lanzar un proceso, aplicarle límites y luego dejar que se convierta en el servicio real sin perderle la pista.
- Los descriptores de fichero sobreviven por defecto. Es exactamente lo que permite las redirecciones de la shell: el shell hace
fork, en el hijo redirige el descriptor 1 a un fichero y luego haceexec. El programa nuevo se encuentra la salida ya redirigida sin saber nada. envpsustituye el entorno completo. En el ejemplo, elagregadorsolo veráMETEORA_CONF. Si quieres heredar el entorno actual, se usa la varianteexecvcon la variable globalenviron.
El patrón fork + exec es la base de todo en UNIX. Cuando escribes ls en la shell, ocurre esto:
sequenceDiagram
participant U as Usuario
participant S as bash (PID 3401)
participant H as hijo (PID 3488)
participant K as Núcleo
U->>S: ls -l
S->>K: fork()
K-->>S: devuelve 3488
K-->>H: devuelve 0
S->>K: wait(3488) — se bloquea
H->>K: execve("/bin/ls", ...)
K-->>H: imagen de memoria sustituida
H->>H: ejecuta ls
H->>K: exit(0)
K-->>S: despierta wait() con estado 0
S->>U: muestra el prompt
wait(), códigos de salida, zombis y huérfanos
Cuando un proceso termina, llama a _exit(codigo) (directamente o a través de return en main). El núcleo entonces:
- Libera su espacio de direcciones, sus descriptores de fichero y casi todos sus recursos.
- Conserva el
task_structcon el código de salida y las estadísticas de uso. - Envía la señal
SIGCHLDal padre. - Marca el proceso como
EXIT_ZOMBIE.
El proceso está muerto pero su ficha sigue ahí. Eso es un zombi: no consume CPU ni memoria (más allá de unos KB de estructura), pero ocupa una entrada en la tabla de procesos y un PID.
¿Por qué existen los zombis? Porque el código de salida es información que pertenece al padre. Si el núcleo borrara la ficha inmediatamente, un padre que llegue tarde a preguntar "¿cómo le fue a mi hijo?" no tendría a quién preguntar. El zombi es la nota que el hijo deja pegada en la nevera. La recoge el padre con wait() o waitpid(), y entonces —y solo entonces— desaparece.
int estado;
pid_t hijo = waitpid(pid, &estado, 0);
if (WIFEXITED(estado)) {
printf("Terminó normalmente con código %d\n", WEXITSTATUS(estado));
} else if (WIFSIGNALED(estado)) {
printf("Lo mató la señal %d\n", WTERMSIG(estado));
}Las macros son necesarias porque estado es un entero con los campos empaquetados: no es directamente el código de salida.
| Situación | Qué guarda estado |
Macro para leerlo |
|---|---|---|
exit(0) |
Salida limpia | WIFEXITED → WEXITSTATUS = 0 |
exit(1) |
Error de la aplicación | WEXITSTATUS = 1 |
Muerto por SIGKILL |
Señal 9 | WIFSIGNALED → WTERMSIG = 9 |
Muerto por SIGSEGV |
Señal 11 | WTERMSIG = 11 |
Detenido con SIGSTOP |
Parado, no muerto | WIFSTOPPED |
Por convención universal, 0 significa éxito y cualquier otro valor entre 1 y 255 significa un error concreto. La shell expone el último en $?, y esto es lo que hace que comando_a && comando_b funcione.
Zombis y huérfanos
| Zombi | Huérfano | |
|---|---|---|
| Quién ha muerto | El hijo | El padre |
| Quién sigue vivo | El padre (pero no llama a wait) |
El hijo |
| Problema | Fuga de PIDs en la tabla de procesos | Nadie recogerá su código de salida |
| Solución | El padre debe llamar a wait; si el padre muere, se limpian solos |
init/systemd lo adopta automáticamente |
Se arregla con kill -9 |
No (ya está muerto) | No aplica |
Detectarlos:
Ese Z con PPID 1877 dice exactamente lo que hay que arreglar: el agregador (1877) está creando hijos y no los recoge. Y fíjate en el matiz clave: matar al zombi no sirve de nada, hay que arreglar al padre. Si reinicias el proceso 1877, todos sus zombis quedan huérfanos, systemd los adopta, systemd sí llama a wait() y desaparecen al instante.
Un proceso huérfano es en cambio inofensivo: el núcleo le reasigna como padre el PID 1 (o el subreaper más cercano, en el caso de servicios bajo systemd), y ese padre adoptivo tiene un bucle permanente llamando a wait(). Esta es, de hecho, la función primordial de init desde 1970.
Ejemplo completo: un supervisor para el agregador
Vamos a juntarlo todo en algo que podría ejecutarse de verdad en meteo-01: un supervisor que lanza el agregador, espera a que termine y lo reinicia si se cae, con un límite de reintentos.
/* supervisor.c — compilar: gcc -Wall -o supervisor supervisor.c */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <time.h>
#define MAX_REINTENTOS 5
#define RUTA_AGREGADOR "/opt/meteora/bin/agregador"
static void marca_tiempo(void) {
time_t ahora = time(NULL);
char buf[32];
strftime(buf, sizeof buf, "%Y-%m-%d %H:%M:%S", localtime(&ahora));
printf("[%s] ", buf);
}
static pid_t lanzar_agregador(void) {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return -1;
}
if (pid == 0) {
/* --- HIJO --- */
char *argv[] = { RUTA_AGREGADOR, "--intervalo", "3600", NULL };
char *envp[] = { "METEORA_CONF=/etc/meteora/meteora.conf", NULL };
execve(RUTA_AGREGADOR, argv, envp);
perror("execve");
_exit(127); /* convención: 127 = no se pudo ejecutar */
}
/* --- PADRE --- */
return pid;
}
int main(void) {
int reintentos = 0;
while (reintentos < MAX_REINTENTOS) {
pid_t pid = lanzar_agregador();
if (pid == -1) return 1;
marca_tiempo();
printf("agregador lanzado con PID %d\n", pid);
int estado;
if (waitpid(pid, &estado, 0) == -1) {
perror("waitpid");
return 1;
}
marca_tiempo();
if (WIFEXITED(estado)) {
int codigo = WEXITSTATUS(estado);
if (codigo == 0) {
printf("agregador terminó correctamente. Fin.\n");
return 0;
}
printf("agregador salió con código %d\n", codigo);
} else if (WIFSIGNALED(estado)) {
printf("agregador muerto por la señal %d\n", WTERMSIG(estado));
}
reintentos++;
marca_tiempo();
printf("reintento %d de %d en 5 segundos\n", reintentos, MAX_REINTENTOS);
sleep(5);
}
marca_tiempo();
printf("agotados los reintentos. El supervisor se rinde.\n");
return 1;
}Salida de una ejecución en la que el agregador se quedó sin memoria y fue eliminado:
[2026-08-31 04:00:01] agregador lanzado con PID 1877 [2026-08-31 04:12:33] agregador muerto por la señal 9 [2026-08-31 04:12:33] reintento 1 de 5 en 5 segundos [2026-08-31 04:12:38] agregador lanzado con PID 1993 [2026-08-31 05:00:04] agregador terminó correctamente. Fin.
Análisis de las decisiones de diseño, que son las que separan este código de un ejemplo de juguete:
_exit(127)en lugar deexit(127)tras unexecvefallido.exit()ejecuta los manejadores registrados conatexity vacía los búferes destdio, que el hijo heredó del padre por COW. Eso duplicaría salida ya impresa por el padre._exit()termina sin más ceremonia, que es lo correcto en un hijo que no llegó a convertirse en otro programa.- El código 127 no es arbitrario: es la convención de la shell para "orden no encontrada". Un supervisor real distinguiría este caso (no reintentar: el binario no existe, reintentar es inútil) de un fallo de ejecución (reintentar tiene sentido).
waitpid(pid, ...)en lugar dewait(NULL).wait()recoge cualquier hijo;waitpid()espera exactamente al que nos importa. En un supervisor con varios hijos,wait()daría lugar a errores sutiles.- La espera de 5 segundos evita el bucle de reinicio frenético: si el binario falla al arrancar, sin esa pausa harías miles de
forkpor segundo y saturarías la máquina. systemd tiene el mismo mecanismo (RestartSec), y por la misma razón. - El límite de reintentos evita reiniciar eternamente algo roto. También lo tiene systemd (
StartLimitBurst), y lo verás en Servicios, Arranque y systemd.
Este programa es, en miniatura, lo que systemd hace por ti con cada servicio. Escribirlo una vez te ahorra años de tratar los gestores de servicios como cajas negras.
La jerarquía de procesos y init/systemd
Como cada proceso nace de otro, todos los procesos de un sistema forman un árbol cuya raíz es el PID 1. En Linux el PID 1 lo crea el núcleo durante el arranque y ejecuta /sbin/init, que en las distribuciones modernas es systemd.
$ pstree -p 1 | head -12
systemd(1)─┬─agregador(1877)─┬─{agregador}(1878)
│ └─{agregador}(1879)
├─ingestor(1842)───{ingestor}(1843)
├─meteo-api(1901)─┬─meteo-api(2214)
│ ├─meteo-api(2215)
│ └─meteo-api(2216)
├─sshd(892)───sshd(3399)───bash(3401)───pstree(3502)
└─systemd-journald(410)Cómo se lee:
- Los nombres entre llaves
{agregador}(1878)son hilos, no procesos: comparten elmmdel 1877.pstreelos distingue así. meteo-apitiene tres hijos que no están entre llaves: son procesos de verdad, un modelo de preforking clásico (un proceso maestro y N trabajadores).- La cadena
sshd → sshd → bash → pstreees la traza completa de tu sesión: el demonio SSH, el proceso de tu conexión, tu shell y el comando que acabas de lanzar.
El PID 1 es especial en tres sentidos:
- Es el padre adoptivo universal: hereda todos los huérfanos y los recoge con
wait(). - No puede morir. Si el PID 1 termina, el núcleo entra en pánico: no queda nadie que gestione el sistema.
- Las señales por defecto no le afectan. El núcleo ignora las señales sin manejador explícito dirigidas al PID 1, precisamente para que un
kill -9 1accidental no tumbe la máquina.
El cambio de contexto: qué se guarda y qué cuesta
Cuando el núcleo decide que la CPU deje de ejecutar ingestor y pase a ejecutar agregador, ocurre un cambio de contexto. Es la operación que hace posible la multitarea, y también una de las que más se paga.
Lo que hay que guardar y restaurar:
| Qué | Dónde va | Coste aproximado |
|---|---|---|
| Registros de propósito general (16 en x86-64) | task_struct->thread |
~50 ns |
Puntero de instrucción y de pila (rip, rsp) |
Pila de núcleo | incluido |
| Registros de coma flotante y SIMD (hasta 2,5 KB con AVX-512) | Área FPU | ~100-300 ns, y de forma perezosa |
Puntero a la tabla de páginas (cr3) |
Solo si cambia el mm |
~100 ns + coste indirecto |
| Puntero a la pila de núcleo (TSS) | Estructura por CPU | ~20 ns |
Sumado, el coste directo ronda 1-3 microsegundos. Pero ese no es el coste real. El grueso es indirecto:
- Vaciado de la TLB. Al cambiar
cr3, las traducciones de dirección cacheadas dejan de servir. El nuevo proceso empieza fallando en cada acceso hasta repoblarla. (Los identificadores de contexto de proceso, PCID, mitigan esto en CPU modernas.) - Contaminación de la caché de datos. Las líneas de caché L1 y L2 están llenas de datos del proceso anterior. El nuevo empieza en frío, y un fallo a memoria principal cuesta ~100 ns, como vimos en la jerarquía de memoria de 01-01.
Con todo, el coste efectivo de un cambio de contexto está entre 3 y 10 microsegundos. Hagamos el número que importa:
Quantum típico de Linux (CFS, carga media): ~4 ms = 4.000 µs Coste del cambio de contexto: ~5 µs Sobrecoste: 5 / 4.005 = 0,12 % Si el quantum fuera de 100 µs: Sobrecoste: 5 / 105 = 4,8 %
De aquí sale una regla que reaparecerá en la próxima lección: el quantum debe ser mucho mayor que el coste del cambio de contexto, o el sistema pasa más tiempo cambiando que trabajando. Un factor de 100 a 1000 es lo razonable.
Un matiz que conviene fijar: cambio de contexto no es lo mismo que llamada al sistema. En 01-06 vimos que una llamada al sistema cambia de modo (usuario→núcleo) pero sigue siendo el mismo proceso: no se toca cr3 ni se cambia de task_struct. Cuesta 50-500 ns. Un cambio de contexto cambia de proceso, y cuesta un orden de magnitud más.
Puedes medir los cambios de contexto reales de tu sistema:
$ vmstat 1 3 procs -----------memory---------- ---system-- ------cpu----- r b swpd free buff cache in cs us sy id wa st 2 0 0 1240132 91224 3810244 4211 8877 12 4 83 1 0 1 1 0 1239876 91224 3810988 6902 14203 18 7 71 4 0 3 0 0 1238004 91232 3811520 5108 10944 15 5 79 1 0
La columna cs son cambios de contexto por segundo. Entre 8.000 y 14.000 en una máquina con carga de E/S es perfectamente normal. Si vieras 300.000, tendrías un problema serio de contención que investigar. La columna in son interrupciones por segundo, y volveremos a ella en 02-07.
Inspección real: /proc, ps -eo, pstree
/proc es un sistema de archivos virtual: no ocupa disco, sus ficheros se generan al vuelo leyendo estructuras del núcleo. Cada proceso tiene su directorio /proc/<pid>/.
$ sudo ls -l /proc/1842/ dr-x------ 2 meteora meteora 0 ago 31 09:14 fd -r--r--r-- 1 meteora meteora 0 ago 31 09:14 cmdline -r--r--r-- 1 meteora meteora 0 ago 31 09:14 environ lrwxrwxrwx 1 meteora meteora 0 ago 31 09:14 exe -> /opt/meteora/bin/ingestor lrwxrwxrwx 1 meteora meteora 0 ago 31 09:14 cwd -> /var/lib/meteora -r--r--r-- 1 meteora meteora 0 ago 31 09:14 maps -r--r--r-- 1 meteora meteora 0 ago 31 09:14 stat -r--r--r-- 1 meteora meteora 0 ago 31 09:14 status -r--r--r-- 1 meteora meteora 0 ago 31 09:14 limits
Los más útiles en el día a día:
| Fichero | Qué contiene | Cuándo lo usas |
|---|---|---|
status |
Estado, PPID, memoria, hilos, cambios de contexto | Primera parada siempre |
cmdline |
Línea de órdenes exacta (separada por \0) |
Saber con qué parámetros arrancó |
exe |
Enlace al binario real | Detectar si el binario se ha reemplazado en caliente |
cwd |
Directorio de trabajo | Entender rutas relativas |
fd/ |
Descriptores abiertos | Ver qué ficheros y sockets tiene |
maps |
Mapa de memoria completo | Lección 02-03 |
limits |
Límites de recursos | Diagnosticar "too many open files" |
stack |
Pila del núcleo del proceso | Averiguar por qué está en estado D |
Un caso práctico completo: el ingestor no está guardando lecturas y quieres saber qué hace.
$ sudo tr '\0' ' ' < /proc/1842/cmdline; echo /opt/meteora/bin/ingestor --puerto 9010 --conf /etc/meteora/meteora.conf $ sudo ls -l /proc/1842/fd lr-x------ 1 meteora meteora 64 ago 31 09:16 0 -> /dev/null l-wx------ 1 meteora meteora 64 ago 31 09:16 1 -> /var/log/meteora/ingestor.log l-wx------ 1 meteora meteora 64 ago 31 09:16 2 -> /var/log/meteora/ingestor.log lrwx------ 1 meteora meteora 64 ago 31 09:16 3 -> socket:[28841] l-wx------ 1 meteora meteora 64 ago 31 09:16 4 -> /var/lib/meteora/lecturas/2026-08-30.dat
Ahí está el problema, y salta a la vista: el descriptor 4 apunta a 2026-08-30.dat, el fichero de ayer. El proceso lleva más de un día en marcha y no rota el fichero de datos al cambiar el día. Las lecturas de hoy se están escribiendo en el fichero de ayer. No ha hecho falta ni leer el código fuente.
El cmdline usa tr '\0' ' ' porque los argumentos van separados por bytes nulos, no por espacios; sin esa conversión verías todo pegado.
Y ps -eo te deja construir exactamente la vista que necesitas:
$ ps -eo pid,ppid,stat,ni,pri,rss,etime,nlwp,comm --sort=-rss | head -6
PID PPID STAT NI PRI RSS ELAPSED NLWP COMMAND
1901 1 Ss 0 19 148320 22:14:07 1 meteo-api
1877 1 Ssl 5 14 31456 22:14:09 3 agregador
1842 1 Ssl -5 24 18204 22:14:09 2 ingestor
892 1 Ss 0 19 9812 6-03:22:41 1 sshdColumna a columna: NI es el valor de amabilidad (nice) y PRI la prioridad resultante —ambas son el tema de la próxima lección—; RSS es la memoria física realmente ocupada en KB; ELAPSED el tiempo desde el arranque (el sshd lleva 6 días); NLWP el número de hilos. Ya se lee una decisión operativa: el ingestor tiene NI -5 (prioridad elevada, porque perder lecturas de red es irreversible) y el agregador NI 5 (rebajado, porque puede esperar).
Errores Comunes y Consejos
Creer que fork() copia toda la memoria. No la copia: comparte con copy-on-write y solo duplica las páginas que se escriben. Consecuencia práctica: fork() de un proceso de 8 GB es rápido, pero si el hijo escribe por todas partes acabarás pagando la copia igualmente. Y hay un caso traicionero: si el sistema no permite overcommit, fork() puede fallar con ENOMEM aunque no vaya a copiar nada, porque el núcleo reserva por si acaso.
Olvidar wait() en un proceso que crea hijos. Es la causa número uno de zombis. La solución mínima en un demonio es instalar un manejador de SIGCHLD que llame a waitpid(-1, NULL, WNOHANG) en bucle hasta que devuelva 0, o directamente signal(SIGCHLD, SIG_IGN) si no te importan los códigos de salida (esto le dice al núcleo que no genere zombis).
Intentar matar un zombi con kill -9. No funciona y nunca funcionará: ya está muerto. Lo que hay que arreglar es el padre. Y si tienes prisa, reiniciar el padre convierte a sus zombis en huérfanos, y systemd los limpia al instante.
Confundir el estado R con "consumiendo CPU". R incluye a los que esperan turno. Para saber quién consume CPU de verdad, mira la columna %CPU de top o ps, no el estado.
Alarmarse ante un VSZ enorme. Un proceso con VSZ de 4 GB y RSS de 50 MB es completamente normal: reserva mucho espacio de direcciones (que es gratis) y usa poca memoria física. La métrica que importa para la RAM es RSS, y aun así con matices que veremos en 02-04.
Usar exit() en lugar de _exit() en un hijo tras fork. El hijo heredó los búferes de stdio del padre; exit() los vacía y duplicas salida ya escrita. En el hijo, siempre _exit().
Consejo de diagnóstico: cuando un proceso "se ha colgado", el orden que funciona es: ps -o stat para ver el estado, cat /proc/<pid>/stack si está en D (te dice en qué función del núcleo está atascado), strace -p <pid> si está en S (te dice qué llamada al sistema espera), y perf top -p <pid> si está en R al 100 % (te dice qué código quema la CPU). Cada estado se investiga con una herramienta distinta.
Ejercicios
Ejercicio 1: predecir la salida de un fork anidado
Dado este programa, ¿cuántos procesos se crean en total (contando el original) y cuántas veces se imprime la letra? Explica por qué.
Después, responde: si sustituyes printf("M\n") por printf("M") (sin salto de línea) y rediriges la salida a un fichero, ¿cambia el número de letras impresas? ¿Por qué?
Ejercicio 2: diagnosticar procesos zombi
En meteo-01 observas esto:
$ ps -eo pid,ppid,stat,etime,comm
PID PPID STAT ELAPSED COMMAND
1 0 Ss 6-04:11:02 systemd
1877 1 Ssl 22:14:09 agregador
4021 1877 Z 03:12 rotador
4088 1877 Z 02:11 rotador
4155 1877 Z 01:10 rotador
4222 1877 Z 00:09 rotador- ¿Qué está ocurriendo exactamente?
- ¿Cada cuánto se produce el problema y qué te dice eso sobre el diseño del
agregador? - ¿Qué pasaría si dejaras el sistema así una semana? Calcúlalo.
- Da dos soluciones: una inmediata como operador y otra correcta como desarrollador.
Ejercicio 3: leer el estado real de un proceso
Escribe una orden que muestre, para todos los procesos del usuario meteora, el PID, el estado, el número de hilos, los cambios de contexto voluntarios e involuntarios, y ordénalos por cambios de contexto involuntarios de mayor a menor. Luego interpreta qué significaría que el agregador tuviera 200.000 cambios involuntarios y solo 300 voluntarios.
Soluciones
Solución 1
Se crean 4 procesos en total y se imprimen 4 letras.
El razonamiento paso a paso:
- Tras el primer
fork()hay 2 procesos: P y A. - Ambos ejecutan el segundo
fork(), porque el hijo continúa justo después delforkque lo creó. P crea a B, A crea a C. - Los 4 llegan al
printfy cada uno imprime una vez. Total: 4 líneas.
La fórmula general es 2^n procesos para n fork() consecutivos sin condicionales.
Sobre la segunda parte: sí, cambia, y puede llegar a imprimirse más de 4 veces. Este es el detalle sutil:
- Con
\ny salida a terminal,stdiousa buffering por líneas: cadaprintfvacía inmediatamente. 4 letras. - Sin
\ny salida a fichero,stdiousa buffering completo de 4096 bytes. La "M" se queda en el búfer de usuario y solo se escribe al terminar el programa. - El problema: el búfer de
stdioestá en el espacio de direcciones del proceso, así que se hereda en elfork. Si el orden fueraprintf("M"); fork();, el hijo heredaría un búfer que ya contiene "M" y la escribiría también él: verías más letras de las esperadas.
En el código tal como está (los fork van antes del printf) siguen siendo 4 letras, pero el experimento revela la razón de fondo de por qué en un hijo se usa _exit() y no exit(), y por qué conviene llamar a fflush(NULL) antes de un fork si hay salida pendiente. Es un error real y desconcertante cuando aparece en producción.
Solución 2
1. Qué ocurre. El agregador (PID 1877) lanza procesos hijo llamados rotador —seguramente para rotar el fichero diario de /var/lib/meteora/lecturas/— y nunca llama a wait(). Los hijos terminan su trabajo correctamente, pero sus task_struct quedan en estado Z porque nadie recoge su código de salida. Los hijos no están colgados: están muertos y sin enterrar.
2. Cada cuánto. Mirando la columna ELAPSED: 03:12, 02:11, 01:10, 00:09. Las diferencias son de aproximadamente 61 segundos. Es decir, uno por minuto. Eso indica que el agregador tiene un bucle o un temporizador que lanza el rotador cada minuto, lo cual ya es sospechoso de por sí: rotar un fichero diario no requiere comprobarlo cada 60 segundos, y sugiere un diseño con un fork dentro del bucle principal.
3. Una semana así. El cálculo:
El límite de PIDs por defecto en Linux (/proc/sys/kernel/pid_max) suele ser 32.768 en configuraciones conservadoras. Con ese valor:
Es decir, en una semana tendrías 60.480 zombis... salvo que el límite de 32.768 se alcanza antes, alrededor del día 22 si no hubiera nada más consumiendo PIDs. Pero el desastre no espera a ese momento: los PIDs se asignan de forma circular, así que mucho antes de agotarlos empezarás a ver fork: Cannot allocate memory en cualquier orden nueva, incluido el ssh con el que intentarías entrar a arreglarlo. Ese es el escenario realmente feo: el sistema no se ha caído, pero no puedes ejecutar nada en él.
4. Las dos soluciones.
Inmediata, como operador: reiniciar el proceso padre.
Al morir el 1877, sus zombis quedan huérfanos, systemd los adopta y su bucle de wait() los limpia instantáneamente. Matar los zombis directamente con kill -9 4021 no haría nada.
Correcta, como desarrollador: que el agregador recoja a sus hijos. Lo más robusto es un manejador de SIGCHLD:
#include <signal.h>
#include <sys/wait.h>
static void recoger_hijos(int sig) {
(void)sig;
int guardado = errno; /* preservar errno */
while (waitpid(-1, NULL, WNOHANG) > 0) /* bucle: pueden llegar varios juntos */
;
errno = guardado;
}
/* en la inicialización */
struct sigaction sa = { 0 };
sa.sa_handler = recoger_hijos;
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
sigemptyset(&sa.sa_mask);
sigaction(SIGCHLD, &sa, NULL);Tres detalles que hacen que esto funcione bien: el bucle es imprescindible porque las señales no se encolan (si mueren tres hijos casi a la vez puede llegar un solo SIGCHLD); WNOHANG evita que el manejador se bloquee si no queda nada que recoger; y preservar errno evita corromper el valor que estuviera manejando el código interrumpido.
Si el código de salida no interesa en absoluto, la alternativa de una línea es signal(SIGCHLD, SIG_IGN), que le indica al núcleo que descarte los hijos sin crear zombis.
Solución 3
La orden:
$ ps -u meteora -o pid,stat,nlwp,comm --no-headers | while read pid resto; do
vol=$(awk '/voluntary_ctxt/ {print $2}' /proc/$pid/status | head -1)
inv=$(awk '/nonvoluntary_ctxt/{print $2}' /proc/$pid/status)
echo "$inv $pid $resto vol=$vol"
done | sort -rnSalida típica:
291 1877 Ssl 3 agregador vol=18422 118 1842 Ssl 2 ingestor vol=94013 44 1901 Ss 1 meteo-api vol=210884
Alternativa más corta si tu ps la soporta, usando el formato extendido:
aunque para los contadores de cambios de contexto no hay más remedio que ir a /proc/<pid>/status, porque ps no los expone.
Interpretación del caso planteado (200.000 involuntarios, 300 voluntarios).
Los dos tipos significan cosas opuestas:
| Tipo | Cuándo ocurre | Qué indica |
|---|---|---|
| Voluntario | El proceso se bloquea esperando E/S | Limitado por E/S |
| Involuntario | Se le agota el quantum o llega alguien más prioritario | Limitado por CPU y con competencia |
Un agregador con 200.000 involuntarios y solo 300 voluntarios dice tres cosas con claridad:
- Casi nunca espera E/S. Con 300 bloqueos voluntarios en horas de ejecución, apenas toca el disco o la red: ha cargado los datos y calcula.
- Es un proceso limitado por CPU que quiere ejecutarse continuamente.
- Hay competencia real por la CPU. 200.000 apropiaciones significan que el planificador se lo quita constantemente porque hay otros procesos ejecutables. Si el
agregadorfuera el único proceso activo, sus involuntarios serían pocos.
La conclusión operativa: el agregador está compitiendo con ingestor y meteo-api por la CPU, y como sus tareas son diferidas (medias horarias) mientras que las de los otros dos son sensibles a la latencia, la decisión correcta es bajarle la prioridad, no subirla:
Eso no le quita CPU cuando la máquina está ociosa —seguirá usándola toda—, pero garantiza que cede el paso cuando el ingestor tiene lecturas que atender. Por qué funciona exactamente así, y qué hace el planificador con ese número, es justo el tema de la próxima lección.
Conclusión
Un programa es un fichero pasivo; un proceso es ese programa vivo, con una imagen de memoria en cuatro regiones —código compartido y de solo lectura, datos, montículo que crece hacia arriba y pila que crece hacia abajo— y una ficha en el núcleo. Esa ficha es el PCB, que en Linux es task_struct: unos 7 KB con la identidad, el estado, los datos de planificación, el contexto de CPU, el puntero al espacio de direcciones, la tabla de descriptores y las credenciales. Que mm sea un puntero es lo que permitirá que existan los hilos.
Los estados no son un capricho teórico: reflejan que la CPU es un recurso escaso y que bloquearse esperando E/S debe liberarla. Los códigos reales de ps afinan el modelo, y dos son especialmente reveladores: R significa ejecutable, no ejecutándose, y D es un sueño ininterrumpible en disco del que ni kill -9 te saca.
La creación por fork() + execve() parece rara hasta que ves lo que permite: entre la duplicación y la sustitución hay una ventana en la que el hijo puede cambiar de usuario, redirigir descriptores o aplicar límites antes de convertirse en otro programa. Y copy-on-write hace que duplicar un proceso de gigabytes cueste medio milisegundo. Los zombis existen porque el código de salida pertenece al padre, y se arreglan arreglando al padre; los huérfanos los adopta el PID 1, que es la función original de init. El cambio de contexto cuesta entre 3 y 10 µs contando TLB y cachés frías, lo que fija de una vez el orden de magnitud del quantum.
Ahora sabes qué es un proceso, cómo nace, en qué estados vive y qué cuesta cambiar de uno a otro. Falta la pregunta que hemos ido aplazando en cada apartado: cuando hay diez procesos en estado R y cuatro núcleos, ¿a cuál se le da la CPU, durante cuánto tiempo y con qué criterio? Esa decisión, que se toma miles de veces por segundo, es lo que veremos en Planificación de la CPU, donde por fin entenderemos qué hace realmente ese renice -n 10 con el que hemos cerrado el último ejercicio.
Fundamentos de Sistemas Operativos
Módulo 1: Introducción a los Sistemas Operativos
- Conceptos Básicos de Sistemas Operativos
- Historia y Evolución de los Sistemas Operativos
- Tipos de Sistemas Operativos
- Funciones Principales de un Sistema Operativo
- Arquitectura del Núcleo: Monolítico, Microkernel e Híbrido
- Modo Usuario, Modo Núcleo y Llamadas al Sistema
Módulo 2: Gestión de Recursos
- Gestión de Procesos
- Planificación de la CPU
- Gestión de Memoria
- Memoria Virtual y Paginación
- Gestión de Almacenamiento
- Gestión de Dispositivos
- Controladores, Interrupciones y Operaciones de E/S
Módulo 3: Concurrencia
- Conceptos de Concurrencia
- Hilos y Procesos
- Comunicación entre Procesos (IPC)
- Sincronización y Exclusión Mutua
- Problemas Clásicos de Concurrencia
- Interbloqueos: Prevención, Detección y Recuperación
Módulo 4: Estructuras de Archivos
- Sistemas de Archivos
- Estructuras de Directorios
- Particiones, Montaje y Sistema de Archivos Virtual
- Gestión de Archivos
- Asignación de Espacio, Journaling e Integridad
- Seguridad y Permisos de Archivos
Módulo 5: Protección y Seguridad del Sistema
- Principios de Protección y Control de Acceso
- Usuarios, Autenticación y Escalada de Privilegios
- Amenazas Comunes y Endurecimiento del Sistema
- Auditoría, Registros y Respuesta a Incidentes
Módulo 6: Virtualización y Contenedores
- Virtualización: Hipervisores y Máquinas Virtuales
- Contenedores: Namespaces y cgroups
- El Sistema Operativo en la Nube
- Sistemas Operativos Móviles y de Tiempo Real
