Durant tot el mòdul 1 hem fet servir la paraula procés com si fos evident: ingestor, agregador i meteo-api apareixien a la sortida de ps, creuaven la frontera cap al mode nucli i consumien recursos. Però mai no vam obrir aquella caixa. En aquesta lliçó l'obrim del tot. Veuràs què és exactament un procés en memòria, quina estructura de dades el representa dins del nucli de Linux, per què travessa uns estats i no uns altres, com neix —amb el mecanisme més estrany i elegant d'UNIX, fork()— i què costa realment que la CPU deixi d'executar-ne un per executar-ne un altre.

En acabar sabràs llegir /proc/<pid>/ com qui llegeix una fitxa mèdica, entendràs per què un procés zombi no es mata amb kill -9 i podràs escriure en C un supervisor que llanci i vigili l'agregador. És la lliçó més fonamental del curs: gairebé tot el que ve després —planificació, memòria, concurrència, contenidors— es recolza en el que vegis aquí.

Contingut

  1. Programa i procés: la diferència que ho canvia tot
  2. La imatge de memòria d'un procés
  3. El bloc de control de procés: task_struct camp a camp
  4. Estats d'un procés i les seves transicions
  5. Els codis d'estat reals de ps
  6. Creació de processos: fork() i copy-on-write
  7. execve(): substituir el programa sense canviar de procés
  8. wait(), codis de sortida, zombis i orfes
  9. Exemple complet: un supervisor per a l'agregador
  10. La jerarquia de processos i init/systemd
  11. El canvi de context: què es desa i què costa
  12. Inspecció real: /proc, ps -eo, pstree

Programa i procés: la diferència que ho canvia tot

Un programa és un fitxer al disc. És passiu, no fa res, és una seqüència de bytes amb un format concret (a Linux, ELF) que descriu quin codi cal carregar i on.

Un procés és un programa en execució: és actiu, té estat, té recursos assignats i té una vida.

A meteo-01 ho pots veure literalment:

$ 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 únic fitxer de 87 KB, dos processos. Cadascun té el seu propi PID, la seva pròpia memòria de treball, els seus propis fitxers oberts i el seu propi punt d'execució. Comparteixen el codi (el nucli el mapeja una sola vegada a la RAM i el referencia des de tots dos), però res més.

La relació programa→procés és 1 a N. I no només això: un mateix procés pot executar successivament diversos programes al llarg de la seva vida, com veurem amb execve().

Programa Procés
Naturalesa Passiu Actiu
On viu Disc Memòria + estructures del nucli
Durada Permanent Des de la seva creació fins a la seva terminació
Identitat Ruta del fitxer PID
Quants Un Molts del mateix programa alhora

La imatge de memòria d'un procés

Quan el nucli posa en marxa un programa, construeix a l'espai d'adreces del procés una imatge de memòria amb diverses regions ben diferenciades. D'adreces baixes a altes:

Adreces altes (0x7fff...)
┌──────────────────────────────┐
│  Arguments i entorn          │  argv[], environ
├──────────────────────────────┤
│  Pila (stack)                │  creix cap avall ↓
│         ↓                    │  marcs de crida, variables locals
│                              │
│         (forat)              │
│                              │
│         ↑                    │
│  Biblioteques compartides    │  libc.so, libm.so (mapejades)
│                              │
│  Monticle (heap)             │  creix cap amunt ↑
├──────────────────────────────┤  malloc(), brk/mmap
│  BSS                         │  variables globals sense inicialitzar (a 0)
├──────────────────────────────┤
│  Dades (.data)               │  variables globals inicialitzades
├──────────────────────────────┤
│  Codi (.text)                │  instruccions, només lectura + execució
└──────────────────────────────┘
Adreces baixes (0x400000)

Cada regió té un propòsit i uns permisos diferents, i això no és decoració: és protecció real aplicada per la MMU.

Regió Contingut Permisos Mida Qui la gestiona
.text Codi màquina r-x Fix, de l'ELF El carregador
.data Globals inicialitzades (int n = 5;) rw- Fix, de l'ELF El carregador
.bss Globals a zero (static char buf[4096];) rw- Fix, sense ocupar disc El carregador
Heap Memòria dinàmica (malloc) rw- Variable El programa via libc
Pila Marcs de crida, locals rw- Variable, amb límit Automàtic (CPU)
Mapatges Biblioteques, fitxers amb mmap varia Variable mmap()

Dos matisos que gairebé sempre es passen per alt:

  • .bss no ocupa espai al fitxer executable. Si declares static struct Lectura cache[100000]; (2,4 MB), l'ELF no creix 2,4 MB: només hi desa "reserva 2.400.000 bytes a zero". El nucli els materialitza en arrencar el procés. Per això .bss significa històricament block started by symbol, i per això hi ha executables de 90 KB que ocupen 50 MB de RAM.
  • .text és de només lectura i compartit. Els dos agregador de dalt tenen les seves pàgines de codi apuntant als mateixos marcs físics de RAM. Només hi ha una còpia del codi en memòria per molts processos que l'executin. Això estalvia memòria i és la raó per la qual executar 200 instàncies d'un servei no costa 200 × la mida del binari.

El bloc de control de procés: task_struct camp a camp

Tota aquesta imatge de memòria és el procés vist des de fora. Dins del nucli, cada procés està representat per una estructura de dades: el bloc de control de procés (PCB). A Linux s'anomena task_struct i viu a include/linux/sched.h. És una de les estructures més grans del nucli: al voltant de 7 KB en un x86-64 típic, amb més de 200 camps.

No cal que els coneguis tots, però sí els grups i el perquè de cadascun:

Grup Camps representatius Per a què serveix
Identitat pid, tgid, real_parent, parent, children, sibling Qui és i el seu lloc a la jerarquia
Estat __state, exit_state, exit_code En quina situació està i com va acabar
Planificació prio, static_prio, normal_prio, se (entitat CFS), policy, cpus_mask Quanta CPU mereix i on es pot executar (lliçó 02-02)
Context de CPU thread (registres desats), stack (pila de nucli) Què cal restaurar en tornar-lo a executar
Memòria mm (descriptor de l'espai d'adreces), active_mm Quina memòria veu (lliçons 02-03 i 02-04)
Fitxers files (taula de descriptors), fs (directori actual, arrel) Què té obert i des d'on
Senyals signal, sighand, blocked, pending Quins senyals pot rebre i com els tracta
Credencials cred (uid, gid, euid, capacitats) Què té permès fer (mòdul 5)
Comptabilitat utime, stime, start_time, nvcsw, nivcsw Temps consumit i canvis de context
Espais de noms nsproxy Quina "vista" del sistema té (mòdul 6)

Un detall important que connecta amb el que ja saps: el camp mm és un punter. Això vol dir que dos task_struct diferents poden apuntar al mateix espai d'adreces. Quan això passa, el que tens no són dos processos, sinó dos fils del mateix procés. Ho veurem a fons a Fils i Processos, però ja pots intuir la idea central de Linux: no hi ha dues estructures, processos i fils; n'hi ha una de sola, task_struct, i el que canvia és quant comparteixen.

Pots veure molts d'aquests camps des de l'espai d'usuari:

$ 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

Traduït camp a camp:

  • State: S: dormint, esperant alguna cosa (ho veurem a l'apartat següent).
  • Tgid: 1877 igual que Pid: 1877: és el fil principal del grup. Si fos un fil secundari, Pid seria diferent de Tgid.
  • PPid: 1: el seu pare és el PID 1, systemd. És un servei del sistema, no el va llançar cap intèrpret d'ordres.
  • Uid: 998: s'executa com l'usuari meteora, no com a root. Exactament el que volem.
  • VmSize 408 MB davant de VmRSS 31 MB: reserva molt espai d'adreces però només té 31 MB realment a la RAM. Aquesta diferència és l'essència de la memòria virtual i la desenvoluparem a Memòria Virtual i Paginació.
  • voluntary_ctxt_switches: 18422: s'ha apartat voluntàriament de la CPU 18.422 vegades (perquè es va bloquejar esperant E/S). Davant de només 291 d'involuntaris (li van treure la CPU). Aquest procés està clarament limitat per E/S, una dada que reutilitzarem a la lliçó vinent.

Estats d'un procés i les seves transicions

Un procés no s'està executant sempre. En una màquina amb 4 nuclis, com a molt hi ha 4 processos a la CPU en un instant donat; els altres 300 són en algun altre estat. El model clàssic en té cinc:

stateDiagram-v2
    [*] --> Nou: fork()
    Nou --> Preparat: admès
    Preparat --> Executant: el planificador l'escull
    Executant --> Preparat: s'exhaureix el quantum (apropiació)
    Executant --> Bloquejat: espera E/S o esdeveniment
    Bloquejat --> Preparat: arriba la dada / passa l'esdeveniment
    Executant --> Acabat: exit()
    Acabat --> [*]: el pare fa wait()
    Executant --> Aturat: SIGSTOP
    Aturat --> Preparat: SIGCONT

L'important és entendre per què existeix cada transició:

  • Nou → Preparat: el nucli ha acabat de construir el task_struct i de muntar l'espai d'adreces. Ja és un candidat vàlid per a la CPU.
  • Preparat → Executant: la decideix el planificador, i és el tema complet de la lliçó vinent.
  • Executant → Preparat (apropiació): el procés no ha demanat res; simplement se li ha acabat el torn o n'ha arribat un altre de més prioritari. Aquesta fletxa és la que distingeix un sistema multitasca apropiatiu d'un de cooperatiu, com vam veure a 01-03.
  • Executant → Bloquejat: el procés demana alguna cosa que no està disponible ara mateix —un read() de socket sense dades, per exemple—. Seria absurd deixar-lo ocupant la CPU esperant. El nucli l'aparta i n'escull un altre.
  • Bloquejat → Preparat: arriben les dades, típicament via interrupció del dispositiu (lliçó 02-07). Compte: no passa a Executant directament. Passa a Preparat i competeix de nou.
  • Executant → Acabat: crida exit() o rep un senyal letal.
  • Acabat → fora: només quan el seu pare recull el codi de sortida. Aquí és on apareixen els zombis.

La transició que més confon qui comença és Bloquejat → Preparat en lloc de Bloquejat → Executant. La raó és simple: quan arriben les dades que esperava l'ingestor, pot haver-hi altres deu processos preparats i una sola CPU lliure. Que un procés deixi d'estar bloquejat no li dona dret a executar-se immediatament; només li retorna el dret a competir.

Els codis d'estat reals de ps

Linux refina el model teòric. Aquests són els codis que veuràs de debò:

Codi Nom al nucli Significat És interrompible?
R TASK_RUNNING Executant-se o preparat per executar-se —
S TASK_INTERRUPTIBLE Dormint, esperant un esdeveniment Sí, els senyals el desperten
D TASK_UNINTERRUPTIBLE Dormint en E/S de disc No, ni amb kill -9
T TASK_STOPPED Aturat per SIGSTOP o pel depurador Amb SIGCONT
Z EXIT_ZOMBIE Acabat, esperant que el pare el reculli No és executable
I TASK_IDLE Fil de nucli ociós —

Dues coses mereixen atenció especial:

R significa "executable", no "executant-se". Linux no distingeix entre Preparat i Executant en la seva representació d'estat; tots dos són TASK_RUNNING. Si ps et mostra 30 processos en R en una màquina de 4 nuclis, no hi ha contradicció: 4 són a la CPU i 26 són a les cues d'execució esperant el seu torn. Quan això passa de manera sostinguda, tens una CPU saturada, i així és com es detecta.

D és l'estat que produeix les pitjors incidències de producció. Un procés en D és dins del nucli, enmig d'una operació de disc, en un punt on el codi no es pot avortar sense corrompre estructures. Per això no admet senyals: kill -9 queda pendent fins que el procés surti de D. Si l'agregador es queda en D perquè el disc de /var/lib/meteora té errors o un NFS no respon, no el podràs matar, i veuràs la càrrega mitjana disparar-se encara que la CPU estigui al 0 % (a Linux la càrrega mitjana compta també els processos en D, no només els R).

Modificadors que veuràs al costat de l'estat:

$ 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: és líder de sessió.
  • l: és multifil (té diversos task_struct amb el mateix mm).
  • +: és en primer pla en un terminal.
  • < / N: prioritat alta / baixa (ho veuràs a 02-02).

Creació de processos: fork() i copy-on-write

Aquí ve la part que a tothom li sembla estranya la primera vegada. A UNIX, l'única manera de crear un procés és duplicar-ne un d'existent amb fork().

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

int main(void) {
    printf("Abans: PID = %d\n", getpid());

    pid_t pid = fork();

    if (pid < 0) {
        perror("fork");
        return 1;
    } else if (pid == 0) {
        printf("FILL:  PID = %d, PPID = %d\n", getpid(), getppid());
    } else {
        printf("PARE:  PID = %d, el fill és %d\n", getpid(), pid);
    }
    return 0;
}
$ ./exemple_fork
Abans: PID = 4102
PARE:  PID = 4102, el fill és 4103
FILL:  PID = 4103, PPID = 4102

El que passa i per què desconcerta:

  • fork() es crida una vegada i retorna dues vegades. Retorna al pare (amb el PID del fill) i retorna al fill (amb 0). No hi ha màgia: el nucli crea un segon task_struct que és una còpia del primer, amb el registre de retorn posat a 0. Quan el planificador executi aquest nou procés, continuarà just després del fork, exactament on el pare.
  • El fill hereta gairebé tot: l'espai d'adreces (una còpia lògica), els descriptors de fitxer oberts, el directori de treball, la umask, les credencials, els límits de recursos.
  • No hereta: el PID (és nou), el PPID (ara apunta al pare), els temps de CPU acumulats (arrenquen a zero), les alarmes pendents i els senyals pendents.
  • L'ordre de les línies no està garantit. A la sortida de dalt el pare va imprimir abans, però hauria pogut ser al revés. Qui s'executa primer ho decideix el planificador. Qualsevol codi que depengui d'aquest ordre està malament.

Copy-on-write: per què fork() no és car

La pregunta òbvia: si meteo-api té 400 MB d'espai d'adreces, fork() copia 400 MB? Seria un desastre, sobretot perquè en el 95 % dels casos el següent que fa el fill és cridar execve() i llençar tota aquesta còpia a les escombraries.

Els UNIX moderns fan servir copy-on-write (COW):

  1. fork() copia només el task_struct i les taules de pàgines, no les pàgines de dades.
  2. Totes les pàgines de dades es marquen com a només lectura en tots dos processos, i s'anota que són compartides.
  3. Mentre tots dos només llegeixin, comparteixen físicament la mateixa RAM. Cost: zero còpies.
  4. Quan un dels dos escriu en una pàgina, la MMU genera una fallada de protecció. El nucli la intercepta, copia aquella única pàgina de 4 KB, la dona en exclusiva a qui hi ha escrit i la marca de lectura/escriptura.

El resultat: fork() d'un procés de 400 MB copia uns pocs centenars de KB de taules i costa de l'ordre de 0,5 ms en lloc de centenars de mil·lisegons. I si el fill fa execve() immediatament, gairebé cap pàgina no arriba a copiar-se mai.

Aquest mecanisme es recolza directament en la MMU i en la fallada de pàgina, que desenvoluparem a Memòria Virtual i Paginació. De moment queda't amb la idea: compartir fins que algú escrigui és un dels patrons més rendibles de tot el disseny de sistemes operatius.

execve(): substituir el programa sense canviar de procés

fork() duplica. execve() reemplaça: manté el task_struct (mateix PID, mateix pare, mateixos descriptors oberts) però llença tota la imatge de memòria i carrega un programa nou al seu lloc.

#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("Soc el PID %d i em convertiré en agregador\n", getpid());

    execve(argv[0], argv, envp);

    /* Si arribem aquí, execve ha fallat */
    perror("execve");
    return 1;
}

Punts clau:

  • execve() no retorna si té èxit. No hi ha "després". El codi que l'ha cridat ja no existeix en memòria: ha estat substituït. Per això el perror de sota només s'executa en cas d'error, i per això sempre hi ha de ser.
  • El PID no canvia. És el mateix procés executant un altre programa. Això té conseqüències pràctiques enormes: systemd pot llançar un procés, aplicar-li límits i després deixar que es converteixi en el servei real sense perdre-li la pista.
  • Els descriptors de fitxer sobreviuen per defecte. És exactament el que permet les redireccions de l'intèrpret d'ordres: l'intèrpret fa fork, al fill redirigeix el descriptor 1 a un fitxer i després fa exec. El programa nou es troba la sortida ja redirigida sense saber-ne res.
  • envp substitueix l'entorn complet. A l'exemple, l'agregador només veurà METEORA_CONF. Si vols heretar l'entorn actual, es fa servir la variant execv amb la variable global environ.

El patró fork + exec és la base de tot a UNIX. Quan escrius ls a l'intèrpret d'ordres, passa això:

sequenceDiagram
    participant U as Usuari
    participant S as bash (PID 3401)
    participant H as fill (PID 3488)
    participant K as Nucli
    U->>S: ls -l
    S->>K: fork()
    K-->>S: retorna 3488
    K-->>H: retorna 0
    S->>K: wait(3488) — es bloqueja
    H->>K: execve("/bin/ls", ...)
    K-->>H: imatge de memòria substituïda
    H->>H: executa ls
    H->>K: exit(0)
    K-->>S: desperta wait() amb estat 0
    S->>U: mostra l'indicador

wait(), codis de sortida, zombis i orfes

Quan un procés acaba, crida _exit(codi) (directament o a través de return a main). El nucli aleshores:

  1. Allibera el seu espai d'adreces, els seus descriptors de fitxer i gairebé tots els seus recursos.
  2. Conserva el task_struct amb el codi de sortida i les estadístiques d'ús.
  3. Envia el senyal SIGCHLD al pare.
  4. Marca el procés com a EXIT_ZOMBIE.

El procés és mort però la seva fitxa continua allà. Això és un zombi: no consumeix CPU ni memòria (més enllà d'uns KB d'estructura), però ocupa una entrada a la taula de processos i un PID.

Per què existeixen els zombis? Perquè el codi de sortida és informació que pertany al pare. Si el nucli esborrés la fitxa immediatament, un pare que arribi tard a preguntar "com li ha anat, al meu fill?" no tindria a qui preguntar-ho. El zombi és la nota que el fill deixa enganxada a la nevera. La recull el pare amb wait() o waitpid(), i aleshores —i només aleshores— desapareix.

int estat;
pid_t fill = waitpid(pid, &estat, 0);

if (WIFEXITED(estat)) {
    printf("Ha acabat normalment amb codi %d\n", WEXITSTATUS(estat));
} else if (WIFSIGNALED(estat)) {
    printf("L'ha mort el senyal %d\n", WTERMSIG(estat));
}

Les macros són necessàries perquè estat és un enter amb els camps empaquetats: no és directament el codi de sortida.

Situació Què desa estat Macro per llegir-ho
exit(0) Sortida neta WIFEXITED → WEXITSTATUS = 0
exit(1) Error de l'aplicació WEXITSTATUS = 1
Mort per SIGKILL Senyal 9 WIFSIGNALED → WTERMSIG = 9
Mort per SIGSEGV Senyal 11 WTERMSIG = 11
Aturat amb SIGSTOP Parat, no mort WIFSTOPPED

Per convenció universal, 0 significa èxit i qualsevol altre valor entre 1 i 255 significa un error concret. L'intèrpret d'ordres exposa l'últim a $?, i això és el que fa que ordre_a && ordre_b funcioni.

Zombis i orfes

Zombi Orfe
Qui ha mort El fill El pare
Qui continua viu El pare (però no crida wait) El fill
Problema Fuita de PIDs a la taula de processos Ningú no recollirà el seu codi de sortida
Solució El pare ha de cridar wait; si el pare mor, es netegen sols init/systemd l'adopta automàticament
Es resol amb kill -9 No (ja és mort) No aplica

Detectar-los:

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

Aquest Z amb PPID 1877 diu exactament què cal arreglar: l'agregador (1877) està creant fills i no els recull. I fixa't en el matís clau: matar el zombi no serveix de res, cal arreglar el pare. Si reinicies el procés 1877, tots els seus zombis queden orfes, systemd els adopta, systemd sí que crida wait() i desapareixen a l'instant.

Un procés orfe és, en canvi, inofensiu: el nucli li reassigna com a pare el PID 1 (o el subreaper més proper, en el cas de serveis sota systemd), i aquest pare adoptiu té un bucle permanent cridant wait(). Aquesta és, de fet, la funció primordial d'init des de 1970.

Exemple complet: un supervisor per a l'agregador

Ho ajuntarem tot en una cosa que es podria executar de debò a meteo-01: un supervisor que llança l'agregador, espera que acabi i el reinicia si cau, amb un límit de reintents.

/* 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_REINTENTS 5
#define RUTA_AGREGADOR "/opt/meteora/bin/agregador"

static void marca_temps(void) {
    time_t ara = time(NULL);
    char buf[32];
    strftime(buf, sizeof buf, "%Y-%m-%d %H:%M:%S", localtime(&ara));
    printf("[%s] ", buf);
}

static pid_t arrencar_agregador(void) {
    pid_t pid = fork();

    if (pid < 0) {
        perror("fork");
        return -1;
    }

    if (pid == 0) {
        /* --- FILL --- */
        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ó: 127 = no s'ha pogut executar */
    }

    /* --- PARE --- */
    return pid;
}

int main(void) {
    int reintents = 0;

    while (reintents < MAX_REINTENTS) {
        pid_t pid = arrencar_agregador();
        if (pid == -1) return 1;

        marca_temps();
        printf("agregador arrencat amb PID %d\n", pid);

        int estat;
        if (waitpid(pid, &estat, 0) == -1) {
            perror("waitpid");
            return 1;
        }

        marca_temps();
        if (WIFEXITED(estat)) {
            int codi = WEXITSTATUS(estat);
            if (codi == 0) {
                printf("agregador ha acabat correctament. Fi.\n");
                return 0;
            }
            printf("agregador ha sortit amb codi %d\n", codi);
        } else if (WIFSIGNALED(estat)) {
            printf("agregador mort pel senyal %d\n", WTERMSIG(estat));
        }

        reintents++;
        marca_temps();
        printf("reintent %d de %d d'aquí a 5 segons\n", reintents, MAX_REINTENTS);
        sleep(5);
    }

    marca_temps();
    printf("reintents exhaurits. El supervisor es rendeix.\n");
    return 1;
}

Sortida d'una execució en què l'agregador es va quedar sense memòria i va ser eliminat:

[2026-08-31 04:00:01] agregador arrencat amb PID 1877
[2026-08-31 04:12:33] agregador mort pel senyal 9
[2026-08-31 04:12:33] reintent 1 de 5 d'aquí a 5 segons
[2026-08-31 04:12:38] agregador arrencat amb PID 1993
[2026-08-31 05:00:04] agregador ha acabat correctament. Fi.

Anàlisi de les decisions de disseny, que són les que separen aquest codi d'un exemple de joguina:

  • _exit(127) en lloc d'exit(127) després d'un execve fallit. exit() executa els gestors registrats amb atexit i buida les memòries intermèdies de stdio, que el fill va heretar del pare per COW. Això duplicaria sortida ja impresa pel pare. _exit() acaba sense més cerimònia, que és el correcte en un fill que no ha arribat a convertir-se en un altre programa.
  • El codi 127 no és arbitrari: és la convenció de l'intèrpret d'ordres per a "ordre no trobada". Un supervisor real distingiria aquest cas (no reintentar: el binari no existeix, reintentar és inútil) d'una fallada d'execució (reintentar té sentit).
  • waitpid(pid, ...) en lloc de wait(NULL). wait() recull qualsevol fill; waitpid() espera exactament el que ens importa. En un supervisor amb diversos fills, wait() donaria lloc a errors subtils.
  • L'espera de 5 segons evita el bucle de reinici frenètic: si el binari falla en arrencar, sense aquesta pausa faries milers de fork per segon i saturaries la màquina. systemd té el mateix mecanisme (RestartSec), i per la mateixa raó.
  • El límit de reintents evita reiniciar eternament alguna cosa trencada. També el té systemd (StartLimitBurst), i el veuràs a Serveis, Arrencada i systemd.

Aquest programa és, en miniatura, el que systemd fa per tu amb cada servei. Escriure'l una vegada t'estalvia anys de tractar els gestors de serveis com a caixes negres.

La jerarquia de processos i init/systemd

Com que cada procés neix d'un altre, tots els processos d'un sistema formen un arbre l'arrel del qual és el PID 1. A Linux el PID 1 el crea el nucli durant l'arrencada i executa /sbin/init, que a les distribucions modernes és 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)

Com es llegeix:

  • Els noms entre claus {agregador}(1878) són fils, no processos: comparteixen el mm del 1877. pstree els distingeix així.
  • meteo-api té tres fills que no són entre claus: són processos de debò, un model de preforking clàssic (un procés mestre i N treballadors).
  • La cadena sshd → sshd → bash → pstree és la traça completa de la teva sessió: el dimoni SSH, el procés de la teva connexió, el teu intèrpret d'ordres i l'ordre que acabes de llançar.

El PID 1 és especial en tres sentits:

  1. És el pare adoptiu universal: hereta tots els orfes i els recull amb wait().
  2. No pot morir. Si el PID 1 acaba, el nucli entra en pànic: no queda ningú que gestioni el sistema.
  3. Els senyals per defecte no l'afecten. El nucli ignora els senyals sense gestor explícit dirigits al PID 1, precisament perquè un kill -9 1 accidental no tombi la màquina.

El canvi de context: què es desa i què costa

Quan el nucli decideix que la CPU deixi d'executar ingestor i passi a executar agregador, es produeix un canvi de context. És l'operació que fa possible la multitasca, i també una de les que més es paguen.

El que cal desar i restaurar:

Què On va Cost aproximat
Registres de propòsit general (16 a x86-64) task_struct->thread ~50 ns
Punter d'instrucció i de pila (rip, rsp) Pila de nucli inclòs
Registres de coma flotant i SIMD (fins a 2,5 KB amb AVX-512) Àrea FPU ~100-300 ns, i de forma mandrosa
Punter a la taula de pàgines (cr3) Només si canvia el mm ~100 ns + cost indirecte
Punter a la pila de nucli (TSS) Estructura per CPU ~20 ns

Sumat, el cost directe ronda 1-3 microsegons. Però aquest no és el cost real. El gruix és indirecte:

  • Buidatge de la TLB. En canviar cr3, les traduccions d'adreça desades a la memòria cau deixen de servir. El procés nou comença fallant a cada accés fins a repoblar-la. (Els identificadors de context de procés, PCID, mitiguen això a les CPU modernes.)
  • Contaminació de la memòria cau de dades. Les línies de memòria cau L1 i L2 són plenes de dades del procés anterior. El nou comença en fred, i una fallada a memòria principal costa ~100 ns, com vam veure a la jerarquia de memòria de 01-01.

Amb tot, el cost efectiu d'un canvi de context és entre 3 i 10 microsegons. Fem el número que importa:

Quantum tipic de Linux (CFS, carrega mitjana): ~4 ms = 4.000 µs
Cost del canvi de context:                     ~5 µs
Sobrecost: 5 / 4.005 = 0,12 %

Si el quantum fos de 100 µs:
Sobrecost: 5 / 105 = 4,8 %

D'aquí surt una regla que reapareixerà a la lliçó vinent: el quantum ha de ser molt més gran que el cost del canvi de context, o el sistema passa més temps canviant que treballant. Un factor de 100 a 1000 és el raonable.

Un matís que convé fixar: canvi de context no és el mateix que crida al sistema. A 01-06 vam veure que una crida al sistema canvia de mode (usuari→nucli) però continua sent el mateix procés: no es toca cr3 ni es canvia de task_struct. Costa 50-500 ns. Un canvi de context canvia de procés, i costa un ordre de magnitud més.

Pots mesurar els canvis de context reals del teu 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 són canvis de context per segon. Entre 8.000 i 14.000 en una màquina amb càrrega d'E/S és perfectament normal. Si en veiessis 300.000, tindries un problema seriós de contenció per investigar. La columna in són interrupcions per segon, i hi tornarem a 02-07.

Inspecció real: /proc, ps -eo, pstree

/proc és un sistema de fitxers virtual: no ocupa disc, els seus fitxers es generen al vol llegint estructures del nucli. Cada procés té el seu directori /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

Els més útils en el dia a dia:

Fitxer Què conté Quan el fas servir
status Estat, PPID, memòria, fils, canvis de context Primera parada sempre
cmdline Línia d'ordres exacta (separada per \0) Saber amb quins paràmetres va arrencar
exe Enllaç al binari real Detectar si el binari s'ha reemplaçat en calent
cwd Directori de treball Entendre rutes relatives
fd/ Descriptors oberts Veure quins fitxers i sockets té
maps Mapa de memòria complet Lliçó 02-03
limits Límits de recursos Diagnosticar "too many open files"
stack Pila del nucli del procés Esbrinar per què és en estat D

Un cas pràctic complet: l'ingestor no està desant lectures i vols saber què fa.

$ 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/lectures/2026-08-30.dat

Aquí hi ha el problema, i salta a la vista: el descriptor 4 apunta a 2026-08-30.dat, el fitxer d'ahir. El procés fa més d'un dia que està en marxa i no rota el fitxer de dades en canviar el dia. Les lectures d'avui s'estan escrivint al fitxer d'ahir. No ha calgut ni llegir el codi font.

El cmdline fa servir tr '\0' ' ' perquè els arguments van separats per bytes nuls, no per espais; sense aquesta conversió ho veuries tot enganxat.

I ps -eo et deixa construir exactament la vista que necessites:

$ 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 és el valor d'amabilitat (nice) i PRI la prioritat resultant —totes dues són el tema de la lliçó vinent—; RSS és la memòria física realment ocupada en KB; ELAPSED el temps des de l'arrencada (l'sshd fa 6 dies que hi és); NLWP el nombre de fils. Ja s'hi llegeix una decisió operativa: l'ingestor té NI -5 (prioritat elevada, perquè perdre lectures de xarxa és irreversible) i l'agregador NI 5 (rebaixat, perquè pot esperar).

Errors Habituals i Consells

Creure que fork() copia tota la memòria. No la copia: comparteix amb copy-on-write i només duplica les pàgines que s'escriuen. Conseqüència pràctica: fork() d'un procés de 8 GB és ràpid, però si el fill escriu per tot arreu acabaràs pagant la còpia igualment. I hi ha un cas traïdor: si el sistema no permet overcommit, fork() pot fallar amb ENOMEM encara que no hagi de copiar res, perquè el nucli reserva per si de cas.

Oblidar wait() en un procés que crea fills. És la causa número u de zombis. La solució mínima en un dimoni és instal·lar un gestor de SIGCHLD que cridi waitpid(-1, NULL, WNOHANG) en bucle fins que retorni 0, o directament signal(SIGCHLD, SIG_IGN) si no t'importen els codis de sortida (això li diu al nucli que no generi zombis).

Intentar matar un zombi amb kill -9. No funciona i no funcionarà mai: ja és mort. El que cal arreglar és el pare. I si tens pressa, reiniciar el pare converteix els seus zombis en orfes, i systemd els neteja a l'instant.

Confondre l'estat R amb "consumint CPU". R inclou els que esperen torn. Per saber qui consumeix CPU de debò, mira la columna %CPU de top o ps, no l'estat.

Alarmar-se davant d'un VSZ enorme. Un procés amb VSZ de 4 GB i RSS de 50 MB és completament normal: reserva molt espai d'adreces (que és gratis) i fa servir poca memòria física. La mètrica que importa per a la RAM és RSS, i encara així amb matisos que veurem a 02-04.

Fer servir exit() en lloc de _exit() en un fill després de fork. El fill va heretar les memòries intermèdies de stdio del pare; exit() les buida i dupliques sortida ja escrita. Al fill, sempre _exit().

Consell de diagnòstic: quan un procés "s'ha penjat", l'ordre que funciona és: ps -o stat per veure l'estat, cat /proc/<pid>/stack si és en D (et diu en quina funció del nucli està encallat), strace -p <pid> si és en S (et diu quina crida al sistema espera), i perf top -p <pid> si és en R al 100 % (et diu quin codi crema la CPU). Cada estat s'investiga amb una eina diferent.

Exercicis

Exercici 1: predir la sortida d'un fork niat

Donat aquest programa, quants processos es creen en total (comptant l'original) i quantes vegades s'imprimeix la lletra? Explica per què.

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

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

Després, respon: si substitueixes printf("M\n") per printf("M") (sense salt de línia) i redirigeixes la sortida a un fitxer, canvia el nombre de lletres impreses? Per què?

Exercici 2: diagnosticar processos zombi

A meteo-01 observes això:

$ 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à passant exactament?
  2. Cada quant es produeix el problema i què et diu això sobre el disseny de l'agregador?
  3. Què passaria si deixessis el sistema així una setmana? Calcula-ho.
  4. Dona dues solucions: una d'immediata com a operador i una altra de correcta com a desenvolupador.

Exercici 3: llegir l'estat real d'un procés

Escriu una ordre que mostri, per a tots els processos de l'usuari meteora, el PID, l'estat, el nombre de fils, els canvis de context voluntaris i involuntaris, i ordena'ls per canvis de context involuntaris de major a menor. Després interpreta què significaria que l'agregador tingués 200.000 canvis involuntaris i només 300 de voluntaris.

Solucions

Solució 1

Es creen 4 processos en total i s'imprimeixen 4 lletres.

El raonament pas a pas:

                    P (original)
                    │
        fork() 1r   ├──────────────┐
                    P              A (fill 1)
                    │              │
        fork() 2n   ├──────┐       ├──────┐
                    P      B       A      C
  • Després del primer fork() hi ha 2 processos: P i A.
  • Tots dos executen el segon fork(), perquè el fill continua just després del fork que el va crear. P crea B, A crea C.
  • Els 4 arriben al printf i cadascun imprimeix una vegada. Total: 4 línies.

La fórmula general és 2^n processos per a n fork() consecutius sense condicionals.

Sobre la segona part: sí, canvia, i pot arribar a imprimir-se més de 4 vegades. Aquest és el detall subtil:

  • Amb \n i sortida a terminal, stdio fa servir emmagatzematge intermedi per línies: cada printf buida immediatament. 4 lletres.
  • Sense \n i sortida a fitxer, stdio fa servir emmagatzematge intermedi complet de 4096 bytes. La "M" es queda a la memòria intermèdia d'usuari i només s'escriu en acabar el programa.
  • El problema: la memòria intermèdia de stdio és a l'espai d'adreces del procés, així que s'hereta en el fork. Si l'ordre fos printf("M"); fork();, el fill heretaria una memòria intermèdia que ja conté "M" i l'escriuria també ell: veuries més lletres de les esperades.

En el codi tal com està (els fork van abans del printf) continuen sent 4 lletres, però l'experiment revela la raó de fons de per què en un fill es fa servir _exit() i no exit(), i per què convé cridar fflush(NULL) abans d'un fork si hi ha sortida pendent. És un error real i desconcertant quan apareix en producció.

Solució 2

1. Què passa. L'agregador (PID 1877) llança processos fill anomenats rotador —segurament per rotar el fitxer diari de /var/lib/meteora/lectures/— i mai no crida wait(). Els fills acaben la seva feina correctament, però els seus task_struct queden en estat Z perquè ningú no recull el seu codi de sortida. Els fills no estan penjats: són morts i sense enterrar.

2. Cada quant. Mirant la columna ELAPSED: 03:12, 02:11, 01:10, 00:09. Les diferències són d'aproximadament 61 segons. És a dir, un per minut. Això indica que l'agregador té un bucle o un temporitzador que llança el rotador cada minut, cosa que ja és sospitosa de per si: rotar un fitxer diari no requereix comprovar-ho cada 60 segons, i suggereix un disseny amb un fork dins del bucle principal.

3. Una setmana així. El càlcul:

1 zombi per minut × 60 min × 24 h × 7 dies = 60.480 zombis

El límit de PIDs per defecte a Linux (/proc/sys/kernel/pid_max) sol ser 32.768 en configuracions conservadores. Amb aquest valor:

Zombis per hora:       60
PIDs disponibles:      32.768
Temps fins a exhaurir: 32.768 / 60 ≈ 546 hores ≈ 22 dies

És a dir, en una setmana tindries 60.480 zombis... llevat que el límit de 32.768 s'assoleix abans, cap al dia 22 si no hi hagués res més consumint PIDs. Però el desastre no espera aquest moment: els PIDs s'assignen de manera circular, així que molt abans d'exhaurir-los començaràs a veure fork: Cannot allocate memory en qualsevol ordre nova, inclòs l'ssh amb què intentaries entrar a arreglar-ho. Aquest és l'escenari realment lleig: el sistema no ha caigut, però no hi pots executar res.

4. Les dues solucions.

Immediata, com a operador: reiniciar el procés pare.

$ sudo systemctl restart meteora-agregador

En morir el 1877, els seus zombis queden orfes, systemd els adopta i el seu bucle de wait() els neteja instantàniament. Matar els zombis directament amb kill -9 4021 no faria res.

Correcta, com a desenvolupador: que l'agregador reculli els seus fills. El més robust és un gestor de SIGCHLD:

#include <signal.h>
#include <sys/wait.h>

static void recollir_fills(int sig) {
    (void)sig;
    int desat = errno;                       /* preservar errno */
    while (waitpid(-1, NULL, WNOHANG) > 0)   /* bucle: en poden arribar diversos junts */
        ;
    errno = desat;
}

/* a la inicialització */
struct sigaction sa = { 0 };
sa.sa_handler = recollir_fills;
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
sigemptyset(&sa.sa_mask);
sigaction(SIGCHLD, &sa, NULL);

Tres detalls que fan que això funcioni bé: el bucle és imprescindible perquè els senyals no s'encuen (si moren tres fills gairebé alhora pot arribar un sol SIGCHLD); WNOHANG evita que el gestor es bloquegi si no queda res per recollir; i preservar errno evita corrompre el valor que estigués gestionant el codi interromput.

Si el codi de sortida no interessa gens, l'alternativa d'una línia és signal(SIGCHLD, SIG_IGN), que indica al nucli que descarti els fills sense crear zombis.

Solució 3

L'ordre:

$ ps -u meteora -o pid,stat,nlwp,comm --no-headers | while read pid resta; do
    vol=$(awk '/voluntary_ctxt/  {print $2}' /proc/$pid/status | head -1)
    inv=$(awk '/nonvoluntary_ctxt/{print $2}' /proc/$pid/status)
    echo "$inv $pid $resta vol=$vol"
  done | sort -rn

Sortida 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 curta si el teu ps la suporta, fent servir el format estès:

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

encara que per als comptadors de canvis de context no hi ha més remei que anar a /proc/<pid>/status, perquè ps no els exposa.

Interpretació del cas plantejat (200.000 d'involuntaris, 300 de voluntaris).

Els dos tipus signifiquen coses oposades:

Tipus Quan passa Què indica
Voluntari El procés es bloqueja esperant E/S Limitat per E/S
Involuntari Se li exhaureix el quantum o arriba algú més prioritari Limitat per CPU i amb competència

Un agregador amb 200.000 d'involuntaris i només 300 de voluntaris diu tres coses amb claredat:

  1. Gairebé mai no espera E/S. Amb 300 bloquejos voluntaris en hores d'execució, amb prou feines toca el disc o la xarxa: ha carregat les dades i calcula.
  2. És un procés limitat per CPU que vol executar-se contínuament.
  3. Hi ha competència real per la CPU. 200.000 apropiacions signifiquen que el planificador li treu la CPU constantment perquè hi ha altres processos executables. Si l'agregador fos l'únic procés actiu, els seus involuntaris serien pocs.

La conclusió operativa: l'agregador està competint amb ingestor i meteo-api per la CPU, i com que les seves tasques són diferides (mitjanes horàries) mentre que les dels altres dos són sensibles a la latència, la decisió correcta és abaixar-li la prioritat, no apujar-la:

$ sudo renice -n 10 -p 1877

Això no li treu CPU quan la màquina està ociosa —la continuarà fent servir tota—, però garanteix que cedeix el pas quan l'ingestor té lectures per atendre. Per què funciona exactament així, i què fa el planificador amb aquest número, és justament el tema de la lliçó vinent.

Conclusió

Un programa és un fitxer passiu; un procés és aquest programa viu, amb una imatge de memòria en quatre regions —codi compartit i de només lectura, dades, monticle que creix cap amunt i pila que creix cap avall— i una fitxa al nucli. Aquesta fitxa és el PCB, que a Linux és task_struct: uns 7 KB amb la identitat, l'estat, les dades de planificació, el context de CPU, el punter a l'espai d'adreces, la taula de descriptors i les credencials. Que mm sigui un punter és el que permetrà que existeixin els fils.

Els estats no són un caprici teòric: reflecteixen que la CPU és un recurs escàs i que bloquejar-se esperant E/S l'ha d'alliberar. Els codis reals de ps afinen el model, i dos són especialment reveladors: R significa executable, no executant-se, i D és un son ininterrompible en disc del qual ni kill -9 et treu.

La creació per fork() + execve() sembla estranya fins que veus què permet: entre la duplicació i la substitució hi ha una finestra en què el fill pot canviar d'usuari, redirigir descriptors o aplicar límits abans de convertir-se en un altre programa. I copy-on-write fa que duplicar un procés de gigabytes costi mig mil·lisegon. Els zombis existeixen perquè el codi de sortida pertany al pare, i s'arreglen arreglant el pare; els orfes els adopta el PID 1, que és la funció original d'init. El canvi de context costa entre 3 i 10 µs comptant TLB i memòries cau fredes, la qual cosa fixa d'una vegada l'ordre de magnitud del quantum.

Ara ja saps què és un procés, com neix, en quins estats viu i què costa canviar d'un a un altre. Falta la pregunta que hem anat ajornant a cada apartat: quan hi ha deu processos en estat R i quatre nuclis, a quin se li dona la CPU, durant quant de temps i amb quin criteri? Aquesta decisió, que es pren milers de vegades per segon, és el que veurem a Planificació de la CPU, on per fi entendrem què fa realment aquest renice -n 10 amb què hem tancat l'últim exercici.

Fonaments de Sistemes Operatius

Mòdul 1: Introducció als Sistemes Operatius

Mòdul 2: Gestió de Recursos

Mòdul 3: Concurrència

Mòdul 4: Estructures de Fitxers

Mòdul 5: Protecció i Seguretat del Sistema

Mòdul 6: Virtualització i Contenidors

Mòdul 7: Administració i Diagnòstic a la Pràctica

© Copyright 2026. Tots els drets reservats