La lliçó anterior va parlar tota l'estona de «fluxos d'execució» sense comprometre's amb què eren. Ara toca concretar. Al mòdul 2 vas conèixer el procés: la seva imatge de memòria, el seu task_struct, el seu espai d'adreces privat protegit per la MMU. Un procés és una unitat de dues coses alhora: possessió de recursos (memòria, fitxers oberts, credencials) i execució (un comptador de programa avançant pel codi).

La idea del fil consisteix a separar aquestes dues coses. Un procés continua sent l'amo dels recursos, però pot tenir diversos fluxos d'execució a dins, compartint tot el que el procés posseeix. Això abarateix brutalment la creació i la comunicació —i a canvi elimina la xarxa de seguretat de la MMU entre ells, que és exactament per què existeix la resta d'aquest mòdul.

En acabar sabràs amb precisió què comparteixen i què no comparteixen els fils germans, quant costa cada operació en microsegons reals, com implementa Linux els fils (una resposta sorprenent: no els implementa; implementa clone()), com es programa amb fils POSIX en C i amb threading en Python, què és realment el GIL sense mites, i com triar entre procés, fil i bucle d'esdeveniments per a cada peça de Meteora.

Contingut

  1. Què és un fil i quina és la seva unitat mínima privada
  2. Què comparteix i què no comparteix un fil amb els seus germans
  3. El bloc de control del fil
  4. Per què existeixen els fils: els números
  5. Models d'implementació: N:1, 1:1 i M:N
  6. Com ho fa Linux de debò: clone()
  7. Inspeccionar fils des de fora: ps -eLf i /proc/<pid>/task/
  8. Fils POSIX en C: l'ingestor amb quatre fils
  9. Fils en Python, el GIL i multiprocessing
  10. Grups de fils: per què gairebé mai no es creen a mà
  11. Procés per connexió, fil per connexió i asíncron a meteo-api
  12. Criteris d'elecció entre fils i processos
  13. Terminació i cancel·lació de fils

Què és un fil i quina és la seva unitat mínima privada

Un fil (thread, o fil d'execució) és la unitat bàsica d'ús de la CPU: un flux seqüencial d'instruccions dins d'un procés. Un procés tradicional té exactament un fil; un procés multifil en té diversos executant-se sobre el mateix espai d'adreces.

La pregunta clau per entendre-ho és: quina és la quantitat mínima d'estat que necessita un flux d'execució per ser independent d'un altre? La resposta és curta:

  • Un comptador de programa (%rip): on està executant ara mateix.
  • Un joc de registres: les seves variables en curs.
  • Una pila pròpia: les seves variables locals, els seus paràmetres i la seva cadena de crides.

Res més. Tota la resta —el codi, les dades globals, el munt, els fitxers oberts, la taula de pàgines— es pot compartir sense que els fluxos deixin de ser independents.

Aquesta pila pròpia mereix un moment d'atenció, perquè és la part que més s'oblida. Si dos fils compartissin la pila, la crida a funció d'un trepitjaria el marc de l'altre i el programa es destruiria en microsegons. Per això cada fil rep la seva pròpia regió de pila dins de l'espai d'adreces compartit: a Linux, 8 MB d'espai virtual reservat per fil per defecte (ulimit -s), tot i que només es materialitzen les pàgines físiques que s'utilitzen de debò, gràcies a l'assignació sota demanda que vas veure al mòdul 2.

graph TB
    subgraph P["Procés meteo-api (un espai d'adreces)"]
        COD["Codi + dades globals + munt<br/>cache, comptadors (COMPARTIT)"]
        FD["Taula de descriptors: sockets,<br/>meteo-api.log (COMPARTIDA)"]
        H1["Fil 1<br/>%rip + registres<br/>Pila pròpia (8 MB)"]
        H2["Fil 2<br/>%rip + registres<br/>Pila pròpia"]
        H3["Fil 3<br/>%rip + registres<br/>Pila pròpia"]
    end

El diagrama conté tota la lliçó en una imatge: tres fluxos amb el mínim privat, surant sobre un oceà de memòria comuna. Aquest oceà és el que fa els fils ràpids i perillosos a parts iguals.

Què comparteix i què no comparteix un fil amb els seus germans

Aquesta taula és la referència que consultaràs una vegada i una altra. Val la pena llegir-la sencera amb atenció, perquè cada fila té una conseqüència pràctica.

Element Compartit entre fils? Conseqüència pràctica
Espai d'adreces (codi, dades, munt) Sí Un punter és vàlid per a tots. Qualsevol global és una carrera potencial
Taula de pàgines / mm_struct Sí El canvi entre fils no buida el TLB: per això és 5-10 vegades més barat
Taula de descriptors de fitxer Sí Un fil obre /var/log/meteora/meteo-api.log, tots hi poden escriure. I un close() afecta tothom
Directori de treball (cwd) Sí Un chdir() d'un fil canvia les rutes relatives de tots
Credencials (UID/GID meteora:meteora) Sí No pots abaixar privilegis d'un sol fil
Gestors de senyal (sigaction) Sí Hi ha una taula de gestors per procés, no per fil
PID Sí Tots comparteixen el mateix PID visible; cadascun té el seu TID
Segments mmap, inclòs /dev/shm/meteora-cache Sí Un mmap() d'un fil el veuen tots immediatament
Límits de recursos (ulimit) Sí El límit de descriptors és del procés, no del fil
Comptador de programa (%rip) No Cada fil va pel seu lloc del codi
Registres de propòsit general No Es desen i es restauren en el canvi de context
Pila No Variables locals privades. És el mecanisme natural de «no compartir»
TID (identificador de fil) No gettid(); és el que veus a /proc/<pid>/task/
errno No (emmagatzematge local) Des del 1995 és una macro que expandeix a una variable per fil
Màscara de senyals bloquejades No pthread_sigmask(): sí que és per fil, encara que el gestor sigui comú
Prioritat i política de planificació No Cada fil es planifica per separat: chrt -p <TID>
errno, strtok(), TLS declarat amb __thread No Emmagatzematge local del fil (Thread-Local Storage)
Estat de cancel·lació No Cada fil decideix si accepta cancel·lació i quan

Quatre files mereixen desenvolupament perquè són font constant d'errors.

errno és local al fil, i ho ha de ser. Si fos global, dos fils fent crides al sistema simultànies es trepitjarien el codi d'error i cap programa multifil no podria comprovar errors de manera fiable. La solució va ser convertir-lo en una macro: a glibc, #define errno (*__errno_location()), on __errno_location() retorna un punter diferent per fil, obtingut del bloc TLS al qual apunta el registre de segment %fs. Per això errno «funciona» en programes multifil sense que hi facis res, i per això no pots fer int *p = &errno; en un fil i usar-lo des d'un altre.

Les senyals són del procés, però la màscara és del fil. Aquesta asimetria causa molta confusió. Hi ha una única taula de gestors: si un fil instal·la un gestor per a SIGHUP, l'instal·la a tothom. Però cada fil té la seva pròpia màscara de senyals bloquejades, i quan arriba una senyal dirigida al procés, el nucli tria un fil qualsevol que no la tingui bloquejada. Això vol dir que no saps quin fil l'atendrà. El patró professional consisteix a bloquejar la senyal a tots els fils i dedicar-ne un a rebre-les amb sigwait(). Ho veurem aplicat a la recàrrega d'/etc/meteora/meteora.conf a Comunicació entre Processos (IPC).

La taula de descriptors compartida és una arma de doble tall. És còmode: el fil 1 accepta un socket i el fil 2 l'atén. Però si el fil 1 fa close(fd) mentre el fil 2 és en un read(fd), i el nucli reassigna aquell número a un fitxer nou, el fil 2 llegeix del fitxer equivocat. És una de les carreres més difícils de trobar en servidors reals.

L'emmagatzematge local del fil (TLS) és la via d'escapament quan vols una «global» que no es comparteixi. En C n'hi ha prou amb __thread (o _Thread_local en C11): __thread unsigned long peticions_daquest_fil = 0; dona una còpia per fil, sense sincronització. És la tècnica que sosté el consell de la lliçó anterior: no compartir és millor que sincronitzar bé. Si cada treballador de meteo-api porta el seu comptador en TLS i algú els suma un cop per minut, la secció crítica desapareix.

El bloc de control del fil

Igual que cada procés té el seu bloc de control (el task_struct del mòdul 2), cada fil necessita el seu: el TCB (Thread Control Block). Conté exactament el que la taula anterior va marcar com a «no compartit»: el TID; l'estat (executant, llest, bloquejat, els mateixos del mòdul 2); el context de registres (%rip, %rsp, generals i SIMD); la base i la mida de la seva regió de pila; les dades del planificador (vruntime, política, nice); la seva màscara de senyals; el punter al seu bloc TLS; i un punter al PCB del procés, que és per on arriba a tot allò compartit.

La relació és jeràrquica: un PCB, diversos TCB apuntant-hi. I aquí és on el disseny es torna interessant a Linux, perquè Linux no fa exactament això, com veurem a l'apartat 6.

Per què existeixen els fils: els números

La justificació dels fils és purament quantitativa. Aquestes són mesures sobre meteo-01 (Linux 6.1, Xeon a 3,0 GHz), obtingudes amb microbenchmarks de 100.000 repeticions:

Operació Cost Relació
Crear i destruir un procés (fork + exit + wait) ~180 µs referència
Crear i destruir un fil (pthread_create + join) ~22 µs 8× més barat
Canvi de context entre processos ~3,5 µs referència
Canvi de context entre fils del mateix procés ~1,2 µs 3× més barat
Comunicar 1 MB entre processos per canonada ~180 µs referència
Comunicar 1 MB entre fils (mateix punter) ~0 µs immediat
Memòria per procés inactiu (RSS mínim) ~1.400 KB referència
Memòria per fil addicional (pila materialitzada) ~12 KB 100× menys

Les raons de cada diferència són concretes i es recolzen en el que ja saps del mòdul 2:

Creació. fork() ha de duplicar el mm_struct, recórrer totes les àrees de memòria (VMA) del procés, copiar la taula de pàgines marcant-ho tot com a copy-on-write, i duplicar la taula de descriptors. Un clone() que comparteix mm se salta tot això: incrementa un comptador de referències i ja està. Com més gran sigui el procés, més gran la diferència: un procés amb 2 GB mapejats triga molt més a fer fork() que un amb 10 MB, mentre que crear un fil costa el mateix en tots dos casos.

Canvi de context. Aquí la clau és el TLB. Canviar entre processos exigeix carregar %cr3 amb una altra taula de pàgines, cosa que invalida les entrades del TLB (mitigat, però no eliminat, pels identificadors PCID de les CPU modernes). Després vénen desenes o centenars de fallades de TLB mentre el procés nou s'escalfa. Entre fils del mateix procés, %cr3 no canvia: el TLB i les memòries cau continuen sent vàlids. Aquesta és la major part del factor 3.

Comunicació. Una dada compartida entre fils no s'«envia»: hi és. Passar un punter a una memòria intermèdia d'1 MB costa 8 bytes. Entre processos s'ha de copiar a través del nucli (dues còpies: usuari→nucli→usuari) o muntar memòria compartida explícitament.

Aquests números expliquen per què l'ingestor de Meteora fa servir fils i no processos per processar lots: crea 4 fluxos en 88 µs en lloc de 720 µs, i sobretot es passa l'array de Lectura sense copiar 17 MB.

Models d'implementació: N:1, 1:1 i M:N

Un fil el pot gestionar la biblioteca en espai d'usuari, el nucli, o tots dos. Històricament hi ha hagut tres models.

N:1, fils d'usuari. Tota la gestió passa en una biblioteca d'espai d'usuari. El nucli veu un únic procés amb un únic fil i no en sap res. La biblioteca desa registres, canvia la pila i salta: un canvi de «context» costa ~100 nanosegons, sense crida al sistema.

1:1, fils de nucli. Cada fil d'usuari correspon a una tasca planificable pel nucli. El nucli els coneix, els planifica individualment i els reparteix entre nuclis.

M:N, híbrid. M fils d'usuari es multiplexen sobre N fils de nucli, amb dos nivells de planificació.

N:1 (usuari) 1:1 (nucli) M:N (híbrid)
Cost de creació ~1 µs ~22 µs ~1 µs (els lleugers)
Cost de canvi ~0,1 µs ~1,2 µs ~0,1 µs dins del mateix portador
Paral·lelisme real en diversos nuclis No Sí Sí
Una crida bloquejant bloqueja... Tots els fils Només aquell fil Només el portador
Planificació del nucli Ignora els fils Justa entre fils Dos nivells, difícil d'afinar
Complexitat d'implementació Baixa Mitjana Molt alta
Exemples Green threads de Java 1.1, GNU Pth Linux NPTL, Windows, macOS Solaris antic, Go (goroutines), Java 21 (fils virtuals)

El defecte letal de N:1 és la tercera fila des de baix: si un fil fa read() sobre un socket i es bloqueja, el nucli bloqueja tot el procés, perquè només veu un fil. Els altres 99 fils, encara que tinguessin feina, es queden aturats. Combinat amb la impossibilitat d'usar diversos nuclis, va condemnar el model per a ús general.

M:N resol tots dos problemes sobre el paper, però és endimoniadament complex: cal interceptar totes les crides bloquejants per migrar el fil lleuger a un altre portador, i els dos planificadors prenen decisions que es contradiuen. Solaris el va implementar als anys 90 i el va acabar abandonant per 1:1. Linux ho va intentar (el projecte NGPT d'IBM) i també ho va descartar.

L'interessant és que M:N ha tornat per la porta dels llenguatges, no del sistema operatiu: les goroutines de Go i els fils virtuals de Java 21 són M:N implementats al runtime del llenguatge, que sí que controla tots els punts de bloqueig perquè controla tota la biblioteca estàndard. És la mateixa idea amb la peça que faltava.

Linux va triar 1:1 amb NPTL (Native POSIX Thread Library, 2003) apostant per fer els fils de nucli tan barats que no compensés complicar-se. Els 22 µs de la taula anterior són el resultat d'aquella aposta.

Com ho fa Linux de debò: clone()

Aquí arriba la part que sorprèn gairebé tothom: el nucli de Linux no té un concepte de «fil». Té tasques (task_struct), i cada tasca decideix què comparteix amb la seva creadora. Un «fil» és simplement una tasca que comparteix l'espai d'adreces; un «procés» és una tasca que no el comparteix. La mateixa crida al sistema crea tots dos: clone().

/* Simplificat: la crida real és clone3() en nuclis moderns */
long clone(unsigned long flags, void *pila, int *ptid, int *ctid, unsigned long tls);

Els flags són el que decideix què es comparteix:

Flag Què comparteix amb el pare
CLONE_VM L'espai d'adreces (mm_struct) — el flag que fa un «fil»
CLONE_FS Directori de treball, arrel, umask
CLONE_FILES La taula de descriptors de fitxer
CLONE_SIGHAND La taula de gestors de senyal
CLONE_THREAD El grup de fils: mateix PID visible, mateix lliurament de senyals
CLONE_SYSVSEM Els semàfors System V
CLONE_SETTLS Instal·la el bloc TLS indicat
CLONE_NEWNS, CLONE_NEWPID, CLONE_NEWNET... No comparteix: crea espais de noms nous (base dels contenidors, mòdul 6)

Amb aquests flags, les dues operacions clàssiques són només dues combinacions: fork() és clone(SIGCHLD, ...) —no comparteix res—, i pthread_create() és clone(CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|..., pila, ...). Es comprova amb strace:

$ strace -f -e trace=clone,clone3 ./ingestor_4fils 2>&1 | head -6
clone3({flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD
        |CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID,
        child_tid=0x7f2a4c1f8990, parent_tid=0x7f2a4c1f8990,
        stack=0x7f2a4b9f8000, stack_size=0x7ffa80}, 88) = 4312

Aquí hi ha tot: la llista de flags, la pila de 8 MB (0x7ffa80 bytes) reservada per la biblioteca, i el TID 4312 retornat.

Aquesta unificació té tres conseqüències molt pràctiques:

  1. El planificador CFS planifica fils, no processos. Quan al mòdul 2 vam parlar de vruntime i de repartir la CPU, la unitat era la tasca. Un procés amb 8 fils rep, per defecte, 8 vegades més CPU que un procés monofil que hi competeixi.
  2. La frontera és un continu, no un mur. Pots crear una tasca que comparteixi la memòria però no els descriptors, o que comparteixi els descriptors però no la memòria. Els contenidors exploten precisament aquesta flexibilitat amb els flags CLONE_NEW* (mòdul 6).
  3. A /proc els fils existeixen i es veuen. Cadascun té el seu directori, com veurem ara.

Inspeccionar fils des de fora: ps -eLf i /proc/<pid>/task/

Un ps -ef normal amaga els fils: mostra un procés, encara que tingui vint fluxos a dins. L'opció -L els revela:

$ ps -eLf | head -1; ps -eLf | grep meteo-api | grep -v grep
UID    PID   PPID    LWP  NLWP C STIME TTY      TIME     CMD
meteora 2841    1    2841    5 0 08:12 ?    00:00:03 /usr/bin/meteo-api
meteora 2841    1    2843    5 3 08:12 ?    00:04:11 /usr/bin/meteo-api
meteora 2841    1    2844    5 3 08:12 ?    00:04:08 /usr/bin/meteo-api
meteora 2841    1    2845    5 3 08:12 ?    00:04:15 /usr/bin/meteo-api
meteora 2841    1    2846    5 3 08:12 ?    00:04:09 /usr/bin/meteo-api

Com es llegeix:

  • PID 2841 per a les cinc files: és un únic procés.
  • LWP (Light Weight Process) és el TID de cada fil. Són diferents: 2841, 2843, 2844, 2845, 2846.
  • NLWP = 5: cinc fils en total.
  • El fil el TID del qual coincideix amb el PID (2841) és el fil principal, el que va arrencar a main(). El seu temps de CPU és de 3 segons davant dels 4 minuts dels altres: és el fil que accepta connexions i les reparteix, mentre els quatre treballadors fan la feina real.

A /proc l'estructura és igual d'explícita:

$ ls /proc/2841/task/
2841  2843  2844  2845  2846

$ cat /proc/2841/task/2844/comm
api-worker-2

$ cat /proc/2841/task/2844/stat | awk '{print "utime:", $14, "stime:", $15, "nucli:", $39}'
utime: 24831 stime: 3102 nucli: 5

Un directori per fil, amb el seu propi stat, el seu propi status, la seva pròpia stack. Fixa't que cada fil té els seus comptadors de temps i el seu nucli actual. El que no hi trobaràs és un maps diferent per fil: /proc/2841/task/2844/maps és idèntic a /proc/2841/maps, perquè el mapa de memòria és del procés. És la demostració pràctica de la primera fila de la taula de compartició.

Un truc molt útil per diagnosticar: top -H mostra fils en lloc de processos, i és la manera ràpida de descobrir que «el procés està al 400 %» en realitat vol dir «un fil està al 100 % i tres al 100 %» o bé «un fil està al 400 %... impossible, per tant són quatre». Hi tornarem a Monitoratge i Diagnòstic de Rendiment.

Fils POSIX en C: l'ingestor amb quatre fils

Anem amb codi real. L'ingestor rep lots de lectures i les ha de validar i convertir abans d'escriure-les a /var/lib/meteora/lectures/. És una feina perfectament divisible: cada lectura és independent de les altres.

/* ingestor_fils.c — processar un lot de Lectura amb 4 fils */
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

#define N_FILS 4

struct Lectura {                 /* 24 bytes, el glossari del curs */
    unsigned int   estacio_id;
    unsigned long  timestamp;
    float temperatura, humitat, pressio;
};

/* Cada fil rep EL SEU tros. No hi ha dada compartida escrita per dos fils. */
struct Tros {
    struct Lectura *lectures;    /* punter a l'array compartit */
    size_t inici, fi;            /* rang [inici, fi) exclusiu d'aquest fil */
    size_t valides;              /* RESULTAT: només l'escriu aquest fil */
    int    id;
};

static int lectura_valida(const struct Lectura *l) {
    return l->temperatura > -90.0f && l->temperatura < 60.0f
        && l->humitat >= 0.0f && l->humitat <= 100.0f
        && l->pressio > 800.0f && l->pressio < 1100.0f;
}

void *processar_tros(void *arg) {
    struct Tros *t = (struct Tros *)arg;
    t->valides = 0;
    for (size_t i = t->inici; i < t->fi; i++) {
        if (lectura_valida(&t->lectures[i])) t->valides++;
        else t->lectures[i].estacio_id = 0;        /* marcar com a descartada */
    }
    printf("[fil %d] rang [%zu,%zu)  valides=%zu\n",
           t->id, t->inici, t->fi, t->valides);
    return NULL;
}

int main(void) {
    size_t n = 700000;
    struct Lectura *lot = malloc(n * sizeof(struct Lectura));
    /* ... aquí s'ompliria el lot des del socket ... */

    pthread_t fils[N_FILS];
    struct Tros trossos[N_FILS];
    size_t per_fil = n / N_FILS;

    for (int i = 0; i < N_FILS; i++) {
        trossos[i].lectures = lot;                       /* MATEIX punter */
        trossos[i].inici    = i * per_fil;
        trossos[i].fi       = (i == N_FILS - 1) ? n : (i + 1) * per_fil;
        trossos[i].id       = i;
        if (pthread_create(&fils[i], NULL, processar_tros, &trossos[i]) != 0) {
            perror("pthread_create");
            exit(1);
        }
    }

    size_t total = 0;
    for (int i = 0; i < N_FILS; i++) {
        pthread_join(fils[i], NULL);       /* espera i allibera recursos */
        total += trossos[i].valides;       /* segur: els fils ja han acabat */
    }
    printf("Total valides: %zu de %zu (%.2f%%)\n", total, n, 100.0*total/n);
    free(lot);
    return 0;
}

Es compila amb gcc -O2 -pthread ingestor_fils.c -o ingestor_fils. El flag -pthread és obligatori: defineix _REENTRANT i enllaça la biblioteca, i oblidar-lo produeix fallades incomprensibles.

Punts de disseny que convé entendre bé, perquè són el patró correcte:

Tots els fils reben el mateix punter lot. No es copia res. 17 MB de lectures compartits pel cost de quatre punters de 8 bytes. Això és exactament el que fa barats els fils.

Cada fil té un rang disjunt. El fil 0 toca [0, 175000), l'1 toca [175000, 350000), etc. No hi ha dos fils que escriguin la mateixa posició, així que no hi ha condició de carrera malgrat compartir l'array. És la tècnica més important d'aquesta lliçó: partir les dades en lloc de protegir les dades.

El resultat de cada fil va al seu propi struct Tros. Si els quatre fessin total_global++, tindríem exactament la carrera de la lliçó anterior. En escriure cadascun a la seva estructura i sumar al fil principal després dels pthread_join, la suma és segura sense cap primitiva de sincronització.

pthread_join compleix dues funcions: espera que el fil acabi i allibera els seus recursos (pila i TCB). Un fil que acaba i del qual ningú no fa join es queda com a zombi de fil consumint memòria, igual que els processos zombi del mòdul 2. Si no penses esperar un fil, crea'l desprès (pthread_detach o l'atribut PTHREAD_CREATE_DETACHED) perquè s'autoalliberi.

El pthread_create es comprova per valor de retorn, no per errno. És una peculiaritat de l'API de pthreads: les funcions retornen directament el codi d'error i no toquen errno. Escriure if (pthread_create(...) < 0) perror(...) és un error clàssic que no detecta res.

Rendiment mesurat sobre les 700.000 lectures del fitxer 2026-08-31.dat:

$ time ./ingestor_1fil    → real 0m0,412s
$ time ./ingestor_fils    → real 0m0,118s      (3,49× amb 4 fils)

3,49× de 4 possible: eficiència del 87 %. És un cas gairebé ideal precisament perquè no hi ha estat compartit escrit. Tan bon punt calgui sincronitzar, el número baixarà.

Fils en Python, el GIL i multiprocessing

Python té fils reals del sistema operatiu: threading.Thread acaba cridant pthread_create. Però l'intèrpret CPython té el GIL (Global Interpreter Lock), un forrellat global que garanteix que només un fil executa bytecode de Python alhora.

Abans dels mites, la raó que existeixi. La gestió de memòria de CPython fa servir recompte de referències: cada objecte porta un comptador que s'incrementa i es decrementa contínuament. Aquest comptador és un read-modify-write, exactament la carrera de la lliçó anterior. Sense protecció, dos fils manipulant el mateix objecte corromprien el comptador i provocarien alliberaments prematurs o fuites. Les opcions eren un forrellat per objecte (lent pel cost de les operacions atòmiques i arriscat per interbloqueigs) o un sol forrellat global (simple i rapidíssim per a codi monofil). CPython va triar la segona opció el 1992 i arrossega la decisió des d'aleshores.

L'essencial i el que gairebé ningú no diu bé: el GIL s'allibera durant les operacions d'E/S i durant les crides a codi C que el deixen anar explícitament. Això parteix el món en dos:

# gil_demo.py — la mateixa estructura, dues càrregues de treball diferents
import threading, time, multiprocessing

def cpu_bound(n):                       # calcular: manté el GIL tota l'estona
    return sum(i * i for i in range(n))

def io_bound(_):                        # esperar: allibera el GIL durant l'espera
    time.sleep(0.5)                     # simula llegir del socket d'una estació

def mesurar(func, arg, n_fils, etiqueta):
    t0 = time.perf_counter()
    fils = [threading.Thread(target=func, args=(arg,)) for _ in range(n_fils)]
    for f in fils: f.start()
    for f in fils: f.join()
    print(f"{etiqueta:28} {n_fils} fils: {time.perf_counter()-t0:.2f} s")

if __name__ == "__main__":
    for n in (1, 4): mesurar(cpu_bound, 20_000_000, n, "CPU-bound (threading)")
    for n in (1, 4): mesurar(io_bound,  None,       n, "E/S-bound (threading)")
    t0 = time.perf_counter()
    with multiprocessing.Pool(4) as p:
        p.map(cpu_bound, [20_000_000] * 4)
    print(f"{'CPU-bound (multiprocessing)':28} 4 procs: {time.perf_counter()-t0:.2f} s")

Resultats a meteo-01 (8 nuclis, CPython 3.11):

CPU-bound (threading)        1 fils: 1.42 s
CPU-bound (threading)        4 fils: 5.88 s      ← PITJOR que 4× un de sol!
E/S-bound (threading)        1 fils: 0.50 s
E/S-bound (threading)        4 fils: 0.50 s      ← escalat perfecte
CPU-bound (multiprocessing)  4 procs: 1.55 s     ← 3,8× d'acceleració

Llegeix-ho amb calma, perquè cada línia diu alguna cosa:

Cas Resultat Per què
CPU amb 1 fil 1,42 s Referència
CPU amb 4 fils 5,88 s (≈ 4,1× el d'un) No hi ha paral·lelisme: es van tornant el GIL. I a més hi ha un 4 % de sobrecost per l'anar i venir del forrellat cada 5 ms
E/S amb 1 fil 0,50 s Referència
E/S amb 4 fils 0,50 s Paral·lelisme perfecte: cada fil deixa anar el GIL en entrar a sleep/read, els quatre esperen alhora
CPU amb 4 processos 1,55 s Cada procés té el seu propi GIL: paral·lelisme real, 3,8×

Les conclusions pràctiques, sense mites:

  • «Els fils de Python són inútils» és fals. Per a feina dominada per E/S —que és la major part del codi de servidor— escalen perfectament. L'ingestor esperant 800 sockets és un cas ideal per a threading.
  • «Els fils de Python donen paral·lelisme de CPU» també és fals. Per calcular, cal fer servir multiprocessing, o biblioteques que deixin anar el GIL al seu codi C (NumPy, pandas, hashlib, la compressió de zlib), o extensions pròpies.
  • Afegir fils a codi CPU-bound el fa més lent, no només igual. La contenció del GIL té cost.

El cost de multiprocessing és que els arguments i els resultats se serialitzen amb pickle i viatgen per una canonada. Per a 17 MB de lectures això són uns 180 ms d'anada i uns altres tants de tornada: si el càlcul dura menys que això, surt a perdre. L'alternativa és multiprocessing.shared_memory, que és memòria compartida POSIX i la veurem a Comunicació entre Processos (IPC).

Un apunt de futur que convé conèixer: Python 3.13 va introduir una compilació experimental sense GIL (PEP 703, free-threading), que substitueix el forrellat global per recompte de referències esbiaixat i forrellats per objecte. Quan s'estabilitzi, la taula anterior canviarà. Fins llavors, el criteri continua sent el de dalt.

Grups de fils: per què gairebé mai no es creen a mà

Els exemples anteriors creen fils, treballen i els destrueixen. En un servidor real això és un error, per tres raons:

  1. Cost de creació repetit. 22 µs per fil sembla poc, però a 1.200 peticions per segon són 26 ms per segon, un 2,6 % d'un nucli llençat en administratiu.
  2. Sense límit de concurrència. Un fil per petició significa que un pic de 5.000 peticions simultànies crea 5.000 fils, 40 GB d'espai virtual de pila i un planificador que es passa més temps canviant de context que treballant. És el thread explosion, i tomba servidors.
  3. Sense control de recursos. Cada fil pot obrir descriptors, reservar memòria i contactar amb la base de dades. Sense sostre, no hi ha dimensionament possible.

La solució és el grup de fils (thread pool): un nombre fix de fils creats en arrencar que consumeixen tasques d'una cua compartida.

# pool.py — el patró correcte per a meteo-api
from concurrent.futures import ThreadPoolExecutor
import urllib.request

ESTACIONS = [f"http://estacion-{i:03d}.meteora.local/estado" for i in range(1, 81)]

def consultar(url):
    with urllib.request.urlopen(url, timeout=2) as r:
        return url, r.status

# 8 fils atenen 80 tasques: mai no hi ha més de 8 connexions simultànies
with ThreadPoolExecutor(max_workers=8, thread_name_prefix="meteo") as pool:
    for url, estat in pool.map(consultar, ESTACIONS):
        print(f"{url} -> {estat}")

Què guanya aquest codi respecte a crear 80 fils:

  • Es creen 8 fils una vegada, no 80. El cost de creació s'amortitza entre totes les tasques.
  • El paral·lelisme està acotat a 8. Les estacions no reben 80 connexions de cop, i el procés no dispara el seu consum.
  • La cua actua d'amortidor. Si arriben tasques més ràpid del que es processen, s'encuen en lloc de crear fils. És un mecanisme de contrapressió natural.
  • El with garanteix el join de tots els fils en sortir, fins i tot si hi ha una excepció.

Dimensionar el grup té una regla útil. Per a feina de CPU, la mida òptima és el nombre de nuclis (més fils només afegeixen canvis de context). Per a feina d'E/S, la fórmula clàssica és:

fils ≈ nuclis × (1 + temps_espera / temps_cpu)

Per a meteo-api: 8 nuclis, cada petició triga 0,4 ms de CPU i espera 12 ms a la base de dades. Surt 8 × (1 + 12/0,4) = 8 × 31 = 248 fils. Aquest número, i el fet que sigui tan gran, és justament el que motiva l'apartat següent.

Procés per connexió, fil per connexió i asíncron a meteo-api

meteo-api ha d'atendre consultes HTTP. Hi ha tres arquitectures clàssiques i l'elecció ho condiciona tot.

Un procés per connexió. El servidor fa accept() i fork() —while(1){ int cli=accept(...); if(fork()==0){close(escolta); atendre(cli); _exit(0);} close(cli); }—. És el model d'inetd i d'Apache prefork. Màxim aïllament: si atendre una petició provoca una fallada de segment, mor només aquell procés. Cost: 180 µs i ~1,4 MB per connexió; amb 1.000 connexions simultànies, 1,4 GB només de processos.

Un fil per connexió. Igual, però amb pthread_create. 22 µs i uns 12 KB reals per connexió (encara que 8 MB virtuals). És el model d'Apache en mode worker i de la majoria de servidors Java tradicionals. Aguanta bé fins a uns pocs milers de connexions; a partir d'aquí, la memòria i el canvi de context es mengen la màquina.

Asíncron amb un bucle d'esdeveniments. Un sol fil (o un per nucli) que vigila milers de descriptors amb epoll i atén el que estigui llest. És el model de nginx, Node.js i asyncio.

# api_async.py — un fil, milers de connexions
import asyncio

async def atendre(lector, escriptor):
    dades = await lector.read(1024)        # cedeix el control mentre espera
    fila  = await consultar_cache(dades)   # cedeix un altre cop
    escriptor.write(resposta_http(fila))
    await escriptor.drain()
    escriptor.close()

async def main():
    servidor = await asyncio.start_server(atendre, "0.0.0.0", 8080)
    async with servidor:
        await servidor.serve_forever()

asyncio.run(main())

Cada await és un punt on la corutina cedeix el control al bucle, que atén una altra connexió mentre aquesta espera. El cost per connexió baixa a uns 2-5 KB i no hi ha canvis de context del nucli.

Procés/connexió Fil/connexió Asíncron
Cost per connexió ~1,4 MB, 180 µs ~12 KB, 22 µs ~3 KB, ~1 µs
Connexions pràctiques centenars milers centenars de milers
Aprofita diversos nuclis Sí Sí Només amb un procés per nucli
Aïllament davant de fallades Total Cap Cap
Risc de condicions de carrera Baix Alt Molt baix
Una operació lenta afecta... Només aquella connexió Només aquell fil Totes
Dificultat de programació Baixa Mitjana Alta (contagi d'async)
Exemples Apache prefork, PostgreSQL Apache worker, Tomcat nginx, Node.js, Redis

La fila que més decisions determina és la penúltima: en el model asíncron, una crida bloquejant o un càlcul llarg congela el servidor sencer. Un time.sleep(1) en lloc d'await asyncio.sleep(1) dins d'una corutina atura les 10.000 connexions durant un segon. És la fallada número u en codi asíncron, i no dona error: només latència inexplicable.

La decisió de Meteora, ara justificada: meteo-api fa servir un grup de 8 fils perquè cada petició fa consultes a la memòria cau que triguen mil·lisegons i el nombre de clients simultanis és de centenars, no de desenes de milers; l'aïllament no compensa el cost de processos. L'ingestor, en canvi, manté 800 connexions permanents que estan gairebé sempre inactives —cada estació envia un cop per minut—: és el cas perfecte per a epoll asíncron, perquè 800 fils adormits serien 800 piles i 800 tasques al planificador per a res.

Criteris d'elecció entre fils i processos

Criteri Tria processos si... Tria fils si...
Aïllament davant de fallades Una fallada no ha de tombar el servei (navegadors, plugins, codi no fiable) Una fallada pot tombar-ho tot, és acceptable
Volum de dades compartides Poc i ben delimitat Molt (17 MB de lectures) o estructures complexes
Freqüència de creació Baixa (arrencada, o uns pocs per minut) Alta (milers per segon)
Seguretat Necessites separar privilegis o aplicar seccomp diferent per peça Tot el codi és igual de fiable
Llenguatge Python amb càrrega de CPU (GIL) C, Rust, Java, Go; o Python amb E/S
Depuració Prefereixes poder aïllar i depurar cada peça Estàs disposat a lidiar amb carreres
Escalat horitzontal futur Vols poder moure una peça a una altra màquina El servei serà sempre local

A Meteora, ingestor, agregador i meteo-api són processos separats per tres raons acumulades: si l'agregador falla processant un fitxer corromput, l'API continua servint; l'ingestor necessita permisos d'escriptura a /var/lib/meteora/lectures/ que l'API no ha de tenir; i demà l'agregador es podria moure a una altra màquina sense canviar res del disseny. Dins de cada procés, fils, perquè allà la feina comparteix dades i l'aïllament no aporta res.

Terminació i cancel·lació de fils

Un fil pot acabar de quatre maneres: fent return de la seva funció (el valor el recull pthread_join); cridant pthread_exit(valor), igual però des de qualsevol profunditat de la pila; rebent un pthread_cancel(tid) d'un altre fil; o perquè qualsevol fil del procés cridi exit().

Aquesta última possibilitat és crítica i sorprèn: exit() no acaba el fil, acaba el procés sencer. Si un fil treballador crida exit(1) en detectar un error, s'emporta per davant els altres tres i les peticions que estiguessin atenent. En un fil treballador es fa return NULL o pthread_exit(), mai exit().

La cancel·lació és el mecanisme més delicat de pthreads. pthread_cancel(tid) no mata el fil: envia una sol·licitud que el fil atén segons la seva configuració. Amb el tipus PTHREAD_CANCEL_DEFERRED (el de per defecte), només té efecte en arribar a un punt de cancel·lació: read, write, sleep, pthread_cond_wait, accept i unes desenes més de funcions que es poden bloquejar. Amb PTHREAD_CANCEL_ASYNCHRONOUS, en qualsevol instrucció, cosa que és gairebé sempre una mala idea: el fil pot morir amb un mutex pres o amb memòria a mig reservar. I un fil la pot rebutjar temporalment amb pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &vell) mentre fa alguna cosa indivisible.

El problema de fons és la neteja: si el fil mor enmig d'una funció, qui allibera el que havia reservat? Existeixen pthread_cleanup_push/pop per registrar gestors, però és un mecanisme fràgil i fàcil d'oblidar. Per això la pràctica professional evita pthread_cancel i fa servir terminació cooperativa: una bandera que el fil consulta i un mecanisme per despertar-lo si està bloquejat.

volatile sig_atomic_t parar = 0;    /* l'escriu el fil principal */

void *treballador(void *arg) {
    while (!parar) {
        struct Peticio *p = treure_de_la_cua();    /* amb temps límit */
        if (p) atendre(p);
    }
    alliberar_recursos_propis();     /* neteja garantida */
    return NULL;
}

El fil decideix quan parar, en un punt on sap que el seu estat és consistent. És més codi, però és l'únic enfocament que funciona de manera fiable. I la bandera, per ser del tot correcta, hauria de ser un atomic_int, no un volatile: ho justificarem a Sincronització i Exclusió Mútua.

Errors Habituals i Consells

Oblidar -pthread en compilar. Sense això, glibc pot enllaçar versions no reentrants d'algunes funcions i errno pot no ser local al fil. Els símptomes són aleatoris i inexplicables. És -pthread (no -lpthread), i va tant en la compilació com en l'enllaçat.

Comprovar errors de pthreads amb errno. Les funcions pthread_* retornen el codi d'error com a valor de retorn i no toquen errno. El correcte és int rc = pthread_create(...); if (rc != 0) fprintf(stderr, "%s\n", strerror(rc));.

Passar l'adreça d'una variable de bucle al fil. L'error clàssic: for (int i=0;i<4;i++) pthread_create(&h[i], NULL, f, &i);. Els quatre fils reben el mateix punter i llegeixen el valor d'i quan els toca executar, que pot ser 4 en tots. Passa un punter a un element d'un array que sobrevisqui (com el trossos[i] de l'exemple) o el valor convertit a void *.

No fer join ni detach. El fil acabat conserva el seu TCB i la seva pila fins que algú en reculli l'estat. En un servidor que crea fils contínuament, això és una fuita de memòria que creix fins a esgotar la màquina.

Cridar exit() des d'un fil treballador. Mata el procés sencer. Fes servir return o pthread_exit().

Fer servir funcions no reentrants. strtok, asctime, getpwnam, gmtime o rand guarden estat en variables estàtiques compartides i retornen brossa si dos fils les criden alhora. Fes servir sempre les variants _r: strtok_r, gmtime_r, getpwnam_r, rand_r.

Consell: parteix les dades abans que protegir les dades. L'exemple de l'ingestor assoleix 3,49× de 4 possible sense ni una sola primitiva de sincronització, perquè cada fil treballa sobre un rang disjunt i escriu el seu resultat a la seva pròpia estructura. Quan puguis particionar, particiona: és més ràpid i no pot fallar.

Consell: posa nom als teus fils. pthread_setname_np(pthread_self(), "api-worker-2") (màxim 15 caràcters) fa que top -H, ps -eLf i gdb mostrin noms llegibles en lloc de repetir el de l'executable. Quan estiguis depurant una penjada a les tres de la matinada, ho agrairàs.

Consell: no creïs fils per feina, crea un grup. El cost de creació, el risc d'explosió de fils i la manca de contrapressió fan que crear fils sota demanda sigui gairebé sempre un error en un servei.

Exercicis

Exercici 1: demostrar què es comparteix i què no

Escriu un programa en C amb dos fils que comprovi experimentalment tres afirmacions de la taula de compartició: (a) una variable global és compartida, (b) una variable local de la funció del fil és privada, (c) una variable __thread és privada encara que sigui global. A més, imprimeix des de cada fil el seu TID (gettid()) i el seu PID (getpid()) per comprovar que el PID coincideix. Verifica el resultat amb ls /proc/<pid>/task/.

Exercici 2: mesurar el GIL

Escriu un programa en Python que mesuri el temps d'una tasca de CPU (per exemple, comptar quantes d'un milió de Lectura simulades són vàlides) amb 1, 2, 4 i 8 fils fent servir threading, i amb 1, 2, 4 i 8 processos fent servir multiprocessing. Construeix la taula comparativa i explica cada columna. Després repeteix l'experiment de fils substituint el càlcul per time.sleep(0.25) i compara.

Exercici 3: triar arquitectura

Meteora vol afegir un component nou, meteo-alertes, que manté una connexió WebSocket oberta amb cada client subscrit per enviar-li avisos de temperatura extrema. S'esperen 15.000 clients connectats simultàniament, amb molt poc trànsit (un missatge cada uns quants minuts), i cada missatge requereix 0,2 ms de CPU. Tria entre procés per connexió, fil per connexió i asíncron. Justifica-ho amb números de memòria i de canvis de context, i calcula quanta CPU total consumiria el servei.

Solucions

Solució 1

/* que_comparteixen.c */
#define _GNU_SOURCE
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/syscall.h>

int global = 0;                  /* (a) compartida */
__thread int tls = 0;            /* (c) una còpia per fil */

void *fil(void *arg) {
    int local = 0;               /* (b) a la pila, privada */
    int id = *(int *)arg;

    for (int i = 0; i < 5; i++) { global++; local++; tls++; }

    printf("fil %d: PID=%d TID=%ld | global=%d local=%d tls=%d | &local=%p\n",
           id, getpid(), syscall(SYS_gettid), global, local, tls, (void *)&local);
    sleep(2);                    /* per poder mirar /proc mentre viu */
    return NULL;
}

int main(void) {
    pthread_t f1, f2;
    int id1 = 1, id2 = 2;
    printf("main: PID=%d TID=%ld\n", getpid(), syscall(SYS_gettid));
    pthread_create(&f1, NULL, fil, &id1);
    pthread_create(&f2, NULL, fil, &id2);
    pthread_join(f1, NULL);  pthread_join(f2, NULL);
    printf("main: global=%d tls=%d\n", global, tls);
    return 0;
}

Sortida típica:

main: PID=5120 TID=5120
fil 1: PID=5120 TID=5121 | global=5 local=5 tls=5 | &local=0x7f3a4bff8e5c
fil 2: PID=5120 TID=5122 | global=10 local=5 tls=5 | &local=0x7f3a4b7f7e5c
main: global=10 tls=0

Anàlisi línia a línia:

  • global: el fil 1 veu 5, el fil 2 veu 10 (els seus més els de l'altre), i main veu 10. És compartida. Els valors intermedis varien entre execucions, perquè global++ és la carrera de la lliçó anterior; amb més iteracions el total final seria menor que 10.
  • local: tots dos veuen 5, i les seves adreces difereixen en uns 8 MB (0x7f3a4bff8e5c davant de 0x7f3a4b7f7e5c, exactament la mida de pila per defecte). Cada fil té la seva pròpia pila, i la separació entre elles confirma la reserva de 8 MB per fil.
  • tls: tots dos fils veuen 5, i main veu 0, encara que tls està declarada com a global. __thread crea una còpia per fil, i el fil principal té la seva.
  • PID idèntic (5120), TID diferents (5120, 5121, 5122): un procés, tres tasques; el TID del fil principal coincideix amb el PID. I mentre dormen, ls /proc/5120/task/ llista exactament aquests tres TID.

Solució 2

# mesurar_gil.py
import threading, multiprocessing, time, random

def comptar_valides(n):
    val = 0
    for i in range(n):
        t = -90 + (i * 37 % 150)          # temperatura simulada
        if -90 < t < 60: val += 1
    return val

def esperar(_):
    time.sleep(0.25)

FEINA = 4_000_000

def amb_fils(func, arg, n):
    t0 = time.perf_counter()
    fs = [threading.Thread(target=func, args=(arg,)) for _ in range(n)]
    [f.start() for f in fs]; [f.join() for f in fs]
    return time.perf_counter() - t0

def amb_processos(func, arg, n):
    t0 = time.perf_counter()
    with multiprocessing.Pool(n) as p:
        p.map(func, [arg] * n)
    return time.perf_counter() - t0

if __name__ == "__main__":
    print(f"{'N':>3} {'fils CPU':>10} {'procs CPU':>10} {'fils E/S':>10}")
    for n in (1, 2, 4, 8):
        print(f"{n:>3} {amb_fils(comptar_valides,FEINA,n):>10.2f}"
              f" {amb_processos(comptar_valides,FEINA,n):>10.2f}"
              f" {amb_fils(esperar,None,n):>10.2f}")

Resultats a meteo-01 (8 nuclis):

N fils CPU procs CPU fils E/S
1 0,31 s 0,36 s 0,25 s
2 0,64 s 0,38 s 0,25 s
4 1,33 s 0,41 s 0,25 s
8 2,81 s 0,52 s 0,25 s

Columna «fils CPU»: el temps creix linealment amb el nombre de fils, i una mica més que linealment (2,81 s davant dels 2,48 s que serien 8×0,31). No hi ha cap paral·lelisme: els fils es van tornant el GIL, que se cedeix cada 5 ms per defecte (sys.setswitchinterval()), i aquest tragí afegeix un 13 % de sobrecost. Vuit fils fan la feina de vuit, un darrere l'altre, pagant a més el peatge.

Columna «procs CPU»: el temps amb prou feines puja (0,36 → 0,52 s) perquè els 8 processos corren de debò als 8 nuclis, cadascun amb el seu propi GIL. La pujada que hi ha es deu a l'arrencada dels processos i a la serialització, no al càlcul. Amb 8 processos es fa vuit vegades més feina en 1,4 vegades més temps: 5,5× d'acceleració efectiva.

Columna «fils E/S»: constant en 0,25 s amb 1 i amb 8 fils. time.sleep() allibera el GIL, així que les vuit esperes transcorren simultàniament. És l'escenari en què threading és exactament l'eina correcta.

Regla que se'n dedueix: a CPython, threading per esperar i multiprocessing per calcular.

Solució 3

Dades: 15.000 connexions simultànies, un missatge cada 5 minuts per client, 0,2 ms de CPU per missatge.

Càrrega real de CPU. 15.000 clients / 300 s = 50 missatges/s. A 0,2 ms cadascun: 10 ms de CPU per segon, l'1 % d'un nucli. El servei no té cap problema de càlcul: el seu problema és mantenir 15.000 connexions inactives.

Procés per connexió. 15.000 × 1,4 MB = 21 GB de RSS, impossible a meteo-01, més 15.000 processos a la cua del planificador. Descartada.

Fil per connexió. 15.000 × 12 KB de pila materialitzada = 180 MB de RSS, més uns 10 KB de task_struct i pila de nucli per fil: 150 MB més. Total ~330 MB per usar l'1 % d'un nucli. Funciona, però manté 15.000 tasques al planificador, gairebé totes bloquejades en read(), i cada despertar arrossega migracions entre nuclis i fallades de memòria cau.

Asíncrona. Un descriptor i uns 3 KB d'estat per connexió: 45 MB, un únic fil, zero canvis de context entre connexions. epoll_wait() retorna només els descriptors llestos, així que el cost és O(esdeveniments), no O(connexions). Amb 50 esdeveniments per segon, el bucle està pràcticament adormit.

Processos Fils Asíncron
RSS estimat 21 GB 330 MB 45 MB
Tasques al planificador 15.000 15.000 1
Ús de CPU 1 % + sobrecost 1 % + sobrecost 1 %
Viable No Sí, a cost alt Sí, de sobres

Elecció: asíncron, i no està renyit amb res. És el cas de llibre per a un bucle d'esdeveniments: moltíssimes connexions, gairebé totes inactives, molt poc càlcul per missatge. És exactament el mateix perfil que l'ingestor amb les seves 800 estacions, i la mateixa raó per la qual nginx atén centenars de milers de connexions en una màquina modesta.

L'única precaució, i cal prendre-se-la seriosament: cap operació del gestor no pot bloquejar. Si en detectar una alerta cal escriure a la base de dades, aquesta escriptura ha de ser asíncrona o delegar-se a un petit grup de fils amb run_in_executor(). Un INSERT síncron de 40 ms congelaria les 15.000 connexions durant aquest temps.

Conclusió

Un fil és la unitat d'execució dins d'un procés, i el seu estat privat mínim és sorprenentment petit: comptador de programa, registres i pila pròpia. Tota la resta ho comparteix amb els seus germans: l'espai d'adreces, la taula de descriptors, el directori de treball, les credencials meteora:meteora, els gestors de senyal i els mapatges com /dev/shm/meteora-cache. En queden fora, a més de la pila i els registres, el TID, la màscara de senyals, la prioritat i errno, que des del 1995 és una macro sobre emmagatzematge local del fil precisament perquè un errno global faria impossible programar amb fils.

Els fils existeixen per raons quantitatives que hem mesurat: crear-ne un costa 22 µs davant dels 180 µs d'un procés, canviar entre fils costa 1,2 µs davant de 3,5 µs perquè no cal recarregar %cr3 ni invalidar el TLB, i compartir 1 MB entre fils costa zero davant dels 180 µs de copiar-lo entre processos. Dels tres models d'implementació, N:1 va morir per bloquejar tot el procés a cada crida bloquejant, M:N per la seva complexitat —encara que ha tornat als runtimes de Go i Java—, i Linux va triar 1:1 amb NPTL.

I la revelació de la lliçó: Linux no implementa fils. Implementa clone(), i un fil és una tasca creada amb CLONE_VM|CLONE_THREAD|CLONE_FILES|... mentre que un procés és una tasca creada sense aquests flags. D'aquí que el planificador reparteixi CPU entre fils i no entre processos, que la frontera sigui un continu que els contenidors explotaran al mòdul 6, i que cada fil tingui el seu directori a /proc/<pid>/task/ visible amb ps -eLf.

En C, pthread_create/pthread_join i el patró que importa: els quatre fils de l'ingestor assoleixen 3,49× d'acceleració sense ni una sola primitiva de sincronització, perquè cadascun treballa sobre un rang disjunt de l'array de Lectura i escriu el seu resultat a la seva pròpia estructura. Partir les dades guanya a protegir les dades. En Python, el GIL fa que 4 fils amb càrrega de CPU triguin 5,88 s davant d'1,42 s d'un de sol —pitjor, no igual— mentre que amb càrrega d'E/S escalen perfectament; per calcular, multiprocessing dona 3,8×. Els grups de fils eviten el cost de creació repetit i l'explosió de fils, i el dimensionament surt de nuclis × (1 + espera/cpu). Entre les tres arquitectures de servidor, meteo-api fa servir fils i l'ingestor fa servir epoll, cadascun pel seu perfil de càrrega. I la terminació es fa de manera cooperativa, mai amb pthread_cancel, i mai amb exit() des d'un treballador.

Però en tota la lliçó hem fet trampa. Els quatre fils de l'ingestor no compartien res escrit, i per això funcionaven. Tan bon punt calgui compartir de debò, i sobretot tan bon punt els fluxos siguin processos separats —com ingestor, agregador i meteo-api, que no comparteixen espai d'adreces—, cal un mecanisme explícit perquè es passin dades. Com li envia l'ingestor un lot de lectures a l'agregador, si són processos diferents amb memòries separades? Què és realment una canonada per dins? I com se li diu a un procés «recarrega la teva configuració» sense aturar-lo?

És el torn de Comunicació entre Processos (IPC).

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