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

  1. Programa y proceso: la diferencia que lo cambia todo
  2. La imagen de memoria de un proceso
  3. El bloque de control de proceso: task_struct campo a campo
  4. Estados de un proceso y sus transiciones
  5. Los códigos de estado reales de ps
  6. Creación de procesos: fork() y copy-on-write
  7. execve(): sustituir el programa sin cambiar de proceso
  8. wait(), códigos de salida, zombis y huérfanos
  9. Ejemplo completo: un supervisor para el agregador
  10. La jerarquía de procesos y init/systemd
  11. El cambio de contexto: qué se guarda y qué cuesta
  12. 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:

  • .bss no ocupa espacio en el fichero ejecutable. Si declaras static 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 .bss significa históricamente block started by symbol, y por eso hay ejecutables de 90 KB que ocupan 50 MB de RAM.
  • .text es de solo lectura y compartido. Los dos agregador de 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: 1877 igual a Pid: 1877: es el hilo principal del grupo. Si fuera un hilo secundario, Pid sería distinto de Tgid.
  • PPid: 1: su padre es el PID 1, systemd. Es un servicio del sistema, no lo lanzó una shell.
  • Uid: 998: corre como el usuario meteora, no como root. Exactamente lo que queremos.
  • VmSize 408 MB frente a VmRSS 31 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_struct y 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+   ps
  • s: es líder de sesión.
  • l: es multihilo (tiene varios task_struct con el mismo mm).
  • +: 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;
}
$ ./ejemplo_fork
Antes: PID = 4102
PADRE: PID = 4102, el hijo es 4103
HIJO:  PID = 4103, PPID = 4102

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 (con 0). No hay magia: el núcleo crea un segundo task_struct que 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 del fork, 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):

  1. fork() copia solo el task_struct y las tablas de páginas, no las páginas de datos.
  2. Todas las páginas de datos se marcan como solo lectura en ambos procesos, y se anota que son compartidas.
  3. Mientras ambos solo lean, comparten físicamente la misma RAM. Coste: cero copias.
  4. 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 el perror de 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 hace exec. El programa nuevo se encuentra la salida ya redirigida sin saber nada.
  • envp sustituye el entorno completo. En el ejemplo, el agregador solo verá METEORA_CONF. Si quieres heredar el entorno actual, se usa la variante execv con la variable global environ.

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:

  1. Libera su espacio de direcciones, sus descriptores de fichero y casi todos sus recursos.
  2. Conserva el task_struct con el código de salida y las estadísticas de uso.
  3. Envía la señal SIGCHLD al padre.
  4. 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:

$ ps -eo pid,ppid,stat,comm | awk '$3 ~ /Z/'
   2377   1877 Z    agregador-tmp

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 de exit(127) tras un execve fallido. exit() ejecuta los manejadores registrados con atexit y vacía los búferes de stdio, 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 de wait(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 fork por 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 el mm del 1877. pstree los distingue así.
  • meteo-api tiene 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 → pstree es 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:

  1. Es el padre adoptivo universal: hereda todos los huérfanos y los recoge con wait().
  2. No puede morir. Si el PID 1 termina, el núcleo entra en pánico: no queda nadie que gestione el sistema.
  3. 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 1 accidental 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 sshd

Columna 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é.

#include <stdio.h>
#include <unistd.h>

int main(void) {
    fork();
    fork();
    printf("M\n");
    return 0;
}

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
  1. ¿Qué está ocurriendo exactamente?
  2. ¿Cada cuánto se produce el problema y qué te dice eso sobre el diseño del agregador?
  3. ¿Qué pasaría si dejaras el sistema así una semana? Calcúlalo.
  4. 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:

                    P (original)
                    │
        fork() nº1  ├──────────────┐
                    P              A (hijo 1)
                    │              │
        fork() nº2  ├──────┐       ├──────┐
                    P      B       A      C
  • Tras el primer fork() hay 2 procesos: P y A.
  • Ambos ejecutan el segundo fork(), porque el hijo continúa justo después del fork que lo creó. P crea a B, A crea a C.
  • Los 4 llegan al printf y 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 \n y salida a terminal, stdio usa buffering por líneas: cada printf vacía inmediatamente. 4 letras.
  • Sin \n y salida a fichero, stdio usa 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 stdio está en el espacio de direcciones del proceso, así que se hereda en el fork. Si el orden fuera printf("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:

1 zombi por minuto × 60 min × 24 h × 7 días = 60.480 zombis

El límite de PIDs por defecto en Linux (/proc/sys/kernel/pid_max) suele ser 32.768 en configuraciones conservadoras. Con ese valor:

Zombis por hora:      60
PIDs disponibles:     32.768
Tiempo hasta agotar:  32.768 / 60 ≈ 546 horas ≈ 22 días

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.

$ sudo systemctl restart meteora-agregador

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 -rn

Salida 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:

$ ps -u meteora -o pid,stat,nlwp,comm,cmd -L | head

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:

  1. 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.
  2. Es un proceso limitado por CPU que quiere ejecutarse continuamente.
  3. Hay competencia real por la CPU. 200.000 apropiaciones significan que el planificador se lo quita constantemente porque hay otros procesos ejecutables. Si el agregador fuera 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:

$ sudo renice -n 10 -p 1877

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

Módulo 2: Gestión de Recursos

Módulo 3: Concurrencia

Módulo 4: Estructuras de Archivos

Módulo 5: Protección y Seguridad del Sistema

Módulo 6: Virtualización y Contenedores

Módulo 7: Administración y Diagnóstico en la Práctica

© Copyright 2026. Todos los derechos reservados