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
- Què és un fil i quina és la seva unitat mínima privada
- Què comparteix i què no comparteix un fil amb els seus germans
- El bloc de control del fil
- Per què existeixen els fils: els números
- Models d'implementació: N:1, 1:1 i M:N
- Com ho fa Linux de debò:
clone() - Inspeccionar fils des de fora:
ps -eLfi/proc/<pid>/task/ - Fils POSIX en C: l'
ingestoramb quatre fils - Fils en Python, el GIL i
multiprocessing - Grups de fils: per què gairebé mai no es creen a mà
- Procés per connexió, fil per connexió i asíncron a
meteo-api - Criteris d'elecció entre fils i processos
- 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) = 4312Aquí 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:
- El planificador CFS planifica fils, no processos. Quan al mòdul 2 vam parlar de
vruntimei 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. - 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). - A
/procels 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:
PID2841 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: 5Un 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:
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'
ingestoresperant 800 sockets és un cas ideal per athreading. - «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ó dezlib), 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:
- 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.
- 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.
- 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
withgaranteix eljoinde 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:
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), imainveu 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 (0x7f3a4bff8e5cdavant de0x7f3a4b7f7e5c, 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, imainveu 0, encara quetlsestà declarada com a global.__threadcrea 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
- Conceptes Bàsics de Sistemes Operatius
- Història i Evolució dels Sistemes Operatius
- Tipus de Sistemes Operatius
- Funcions Principals d'un Sistema Operatiu
- Arquitectura del Nucli: Monolític, Microkernel i Híbrid
- Mode Usuari, Mode Nucli i Crides al Sistema
Mòdul 2: Gestió de Recursos
- Gestió de Processos
- Planificació de la CPU
- Gestió de Memòria
- Memòria Virtual i Paginació
- Gestió d'Emmagatzematge
- Gestió de Dispositius
- Controladors, Interrupcions i Operacions d'E/S
Mòdul 3: Concurrència
- Conceptes de Concurrència
- Fils i Processos
- Comunicació entre Processos (IPC)
- Sincronització i Exclusió Mútua
- Problemes Clàssics de Concurrència
- Interbloquejos: Prevenció, Detecció i Recuperació
Mòdul 4: Estructures de Fitxers
- Sistemes de Fitxers
- Estructures de Directoris
- Particions, Muntatge i Sistema de Fitxers Virtual
- Gestió de Fitxers
- Assignació d'Espai, Journaling i Integritat
- Seguretat i Permisos de Fitxers
Mòdul 5: Protecció i Seguretat del Sistema
- Principis de Protecció i Control d'Accés
- Usuaris, Autenticació i Escalada de Privilegis
- Amenaces Habituals i Enfortiment del Sistema
- Auditoria, Registres i Resposta a Incidents
Mòdul 6: Virtualització i Contenidors
- Virtualització: Hipervisors i Màquines Virtuals
- Contenidors: Namespaces i cgroups
- El Sistema Operatiu al Núvol
- Sistemes Operatius Mòbils i de Temps Real
