Tanquem el mòdul 2 reconeixent un deute. Cada vegada que dos processos compartien una pàgina amb MAP_SHARED, cada vegada que una softirq i un procés tocaven la mateixa cua de paquets, cada vegada que dos treballadors de meteo-api escrivien a /dev/shm/meteora-cache, vam dir «això requereix sincronització» i vam tirar endavant. Aquesta lliçó comença a pagar-lo.
I comença pel més important: entendre el problema abans d'aprendre les eines. És un error molt habitual llançar-se a memoritzar mutexos, semàfors i variables de condició sense haver comprès amb precisió què és exactament el que va malament quan no s'usen. Qui no entén la fallada, fa servir les eines per superstició: posa un forrellat «per si de cas» on no cal i l'oblida on sí que cal. Aquí descompondrem un comptador++ fins a les seves instruccions màquina i veurem, pas a pas, com es perd un increment.
En acabar sabràs distingir concurrència de paral·lelisme amb rigor, reconèixer una condició de carrera llegint codi, formular amb exactitud els tres requisits que ha de complir qualsevol solució al problema de la secció crítica, i calcular amb la llei d'Amdahl quant es pot accelerar realment l'agregador si li afegim nuclis.
Contingut
- Concurrència i paral·lelisme no són el mateix
- Per què la concurrència és inevitable
- L'entrellaçament d'instruccions com a model mental
- La condició de carrera, descomposta
- El cas real: el comptador de peticions de
meteo-api - La secció crítica i els tres requisits
- Atomicitat: què ho és de debò i què no
- No determinisme, heisenbugs i per què no es reprodueixen
- Models de concurrència comparats
- Escalabilitat i la llei d'Amdahl aplicada a l'
agregador
Concurrència i paral·lelisme no són el mateix
Són dues paraules que s'usen com a sinònimes i no ho són. La distinció és la base de tot el mòdul, així que anem amb definicions precises.
La concurrència és una propietat de l'estructura del programa: diverses tasques estan en curs durant el mateix interval de temps, i el seu avenç s'intercala. No exigeix que s'executin simultàniament; exigeix que cap no hagi d'acabar abans que una altra comenci.
El paral·lelisme és una propietat de l'execució: diverses tasques executen instruccions en el mateix instant físic, sobre unitats de càlcul diferents. Exigeix maquinari amb més d'un nucli (o més d'una CPU, o una GPU, o un clúster).
La frase que ho resumeix millor, atribuïda a Rob Pike: la concurrència és una manera d'estructurar el programa; el paral·lelisme és una manera d'executar-lo.
| Concurrència | Paral·lelisme | |
|---|---|---|
| Naturalesa | Estructura del programa | Forma d'execució |
| Requereix diversos nuclis | No | Sí |
| Pregunta que respon | Com organitzo tasques que se solapen? | Com faig això més ràpid? |
| Objectiu típic | Capacitat de resposta, aprofitar esperes d'E/S | Reduir el temps total de càlcul |
| Es pot donar sense l'altra | Sí (un nucli, molts fils) | Sí (SIMD, vectorització sobre un sol fil lògic) |
| Introdueix condicions de carrera | Sí | Sí (i a més més difícils de veure) |
El punt que gairebé tothom passa per alt i que convé gravar: la concurrència en un sol nucli ja produeix tots els problemes d'aquest mòdul. No cal paral·lelisme real. Si meteo-01 tingués un únic nucli i executés dos treballadors de meteo-api, el planificador continuaria alternant entre ells cada pocs mil·lisegons —o abans, si un es bloqueja en E/S— i aquesta alternança pot caure enmig d'un comptador++. El resultat erroni és exactament el mateix.
El paral·lelisme real hi afegeix un agreujant, no un problema nou: en un sol nucli l'entrellaçament passa només en els punts on el planificador expulsa un fil; amb diversos nuclis passa contínuament i a més hi intervenen les memòries cau de cada nucli, que poden fer que dos fils vegin valors diferents de la mateixa variable durant un instant. Aquest segon efecte el tractarem a Sincronització i Exclusió Mútua, quan parlem de barreres de memòria.
gantt
title Concurrència sense paral·lelisme (1 nucli) davant de paral·lelisme (2 nuclis)
dateFormat X
axisFormat %s
section 1 nucli
Tasca A :0, 2
Tasca B :2, 4
Tasca A :4, 6
Tasca B :6, 8
section Nucli 0
Tasca A :0, 4
section Nucli 1
Tasca B :0, 4
A dalt, un nucli alterna: totes dues tasques estan en curs durant els 8 segons, però mai no corren alhora. A baix, dos nuclis: totes dues acaben en 4 segons perquè corren de debò simultàniament. En els dos casos hi ha concurrència; només en el segon hi ha paral·lelisme.
Per què la concurrència és inevitable
Et podries preguntar si no seria més senzill prohibir-la. La resposta és que un sistema operatiu modern no pot evitar-la ni encara que volgués, per quatre raons acumulatives.
1. El maquinari va deixar d'accelerar-se en freqüència cap al 2005. Durant tres dècades, un programa seqüencial anava més ràpid cada any sense tocar ni una línia de codi: la freqüència de rellotge pujava. Aquella corba es va trencar per dissipació tèrmica: la potència creix aproximadament amb el cub de la freqüència. La indústria va girar cap a afegir nuclis. Un servidor típic com meteo-01 té 8 o 16 nuclis; un programa estrictament seqüencial n'usa un i malbarata el 87 % o el 94 % de la màquina.
2. L'E/S és entre mil i un milió de vegades més lenta que la CPU. Recuperant les xifres del mòdul 2:
| Operació | Latència | Cicles de CPU equivalents (a 3 GHz) |
|---|---|---|
| Accés a memòria cau L1 | ~1 ns | 3 |
| Accés a memòria principal | ~80 ns | 240 |
| Lectura de 4 KB en NVMe | ~80 µs | 240.000 |
| Anada i tornada de xarxa en LAN | ~500 µs | 1.500.000 |
| Lectura de 4 KB en disc dur | ~8 ms | 24.000.000 |
Si l'ingestor atengués una estació cada vegada i n'esperés la resposta de manera seqüencial, passaria el 99,99 % del temps sense fer res. La concurrència és el que permet superposar aquestes esperes: mentre un flux espera dades de l'estació 41, un altre processa les de la 12.
3. El món real és concurrent. Meteora té 800 estacions que envien quan els ve de gust, centenars de clients HTTP consultant l'API i un agregador que ha de calcular mitjanes cada hora. No hi ha cap ordre seqüencial natural entre aquests esdeveniments. Modelar-los com una seqüència seria forçar una mentida.
4. El nucli mateix és concurrent per construcció. Encara que escrivissis un programa d'un sol fil, per sota s'executen interrupcions que l'expulsen sense avisar (mòdul 2), fils del nucli com kswapd o els ksoftirqd, i altres processos competint pels mateixos recursos. La concurrència no és una opció que activis: és el medi on viu el teu codi.
L'entrellaçament d'instruccions com a model mental
Tot el raonament sobre concurrència es recolza en un únic model mental. Val la pena enunciar-lo amb claredat perquè la resta de la lliçó l'utilitza constantment.
Quan diversos fluxos d'execució (fils o processos) avancen concurrentment, l'execució real és algun dels entrellaçaments possibles de les seves instruccions, i tu no controles quin. Un programa concurrent és correcte només si ho és per a tots els entrellaçaments possibles.
Dues conseqüències importants:
- El nombre d'entrellaçaments creix de manera explosiva. Amb dos fils de m i n instruccions respectivament, hi ha C(m+n, m) entrellaçaments. Per a dos fils de només 10 instruccions cadascun: 184.756 ordres possibles. Per a tres fils de 10, més de 5.500 milions. Provar «a veure si falla» no cobreix ni una fracció de l'espai.
- Els punts on hi pot haver un canvi de flux no són els que semblen al codi font. La unitat d'entrellaçament no és la línia de C ni la sentència de Python: és la instrucció màquina, i una línia innocent en pot ser diverses.
Aquest últim punt és exactament el que fa que comptador++ sigui perillós.
La condició de carrera, descomposta
Una condició de carrera (race condition) és una situació en què el resultat del programa depèn de l'ordre relatiu en què s'entrellacen les operacions de diversos fluxos d'execució, quan aquest ordre no està controlat.
Anem amb l'exemple canònic. Aquest codi en C incrementa una variable compartida:
/* carrera.c — dos fils incrementen la mateixa variable */
#include <stdio.h>
#include <pthread.h>
#define VOLTES 1000000
long comptador = 0; /* compartida pels dos fils */
void *treballador(void *arg) {
for (int i = 0; i < VOLTES; i++) {
comptador++; /* ← una línia, tres instruccions */
}
return NULL;
}
int main(void) {
pthread_t f1, f2;
pthread_create(&f1, NULL, treballador, NULL);
pthread_create(&f2, NULL, treballador, NULL);
pthread_join(f1, NULL);
pthread_join(f2, NULL);
printf("Esperat: %d\n", 2 * VOLTES);
printf("Obtingut: %ld\n", comptador);
return 0;
}Dos fils, un milió d'increments cadascun. L'esperat són 2.000.000. El que passa:
$ gcc -O0 -pthread carrera.c -o carrera $ ./carrera Esperat: 2000000 Obtingut: 1298447 $ ./carrera Esperat: 2000000 Obtingut: 1104923 $ ./carrera Esperat: 2000000 Obtingut: 1523061
Es perden entre 400.000 i 900.000 increments, i la xifra canvia a cada execució. Mai no en surten de més; sempre de menys. Per entendre per què, cal mirar què compila realment comptador++:
$ gcc -O0 -pthread -S carrera.c -o - | grep -A3 "comptador(%rip)"
movq comptador(%rip), %rax ; (1) LLEGIR: rax ← memòria
addq $1, %rax ; (2) MODIFICAR: rax ← rax + 1
movq %rax, comptador(%rip) ; (3) ESCRIURE: memòria ← raxAquí hi ha tot el problema. comptador++ no és una operació, en són tres: llegir de memòria a un registre, sumar al registre, escriure el registre a memòria. És el patró llegir-modificar-escriure (read-modify-write), i és l'origen de la immensa majoria de les condicions de carrera que veuràs a la teva vida professional.
El registre %rax és privat de cada fil: forma part del seu context i es desa i es restaura a cada canvi de context (mòdul 2). La variable comptador en memòria és compartida. Entre el pas (1) i el pas (3) d'un fil, el valor en memòria pot haver canviat sense que aquell fil se n'assabenti.
Traça d'un entrellaçament que perd un increment, partint de comptador = 41:
| Temps | Fil A | Fil B | %rax d'A |
%rax de B |
comptador en memòria |
|---|---|---|---|---|---|
| t1 | movq comptador,%rax |
41 | – | 41 | |
| t2 | addq $1,%rax |
42 | – | 41 | |
| t3 | (expulsat) | movq comptador,%rax |
42 | 41 | 41 |
| t4 | addq $1,%rax |
42 | 42 | 41 | |
| t5 | movq %rax,comptador |
42 | 42 | 42 | |
| t6 | movq %rax,comptador |
42 | 42 | 42 |
Resultat: dos increments executats, comptador val 42 en lloc de 43. Se n'ha perdut un. No perquè falli el maquinari ni el compilador: perquè el fil A va llegir 41 a t1 i va escriure el seu resultat a t6, ignorant que entremig B havia escrit 42. L'escriptura d'A trepitja la de B. A la literatura això s'anomena actualització perduda (lost update).
La finestra de perill és l'interval entre (1) i (3): tot just 3 o 4 cicles, al voltant d'1 nanosegon. Sembla impossible que dos fils coincideixin en una finestra tan estreta. Però amb dos milions d'iteracions i un nucli cadascun, aquesta finestra s'obre dos milions de vegades per segon a cada fil. L'improbable, repetit prou vegades, esdevé inevitable. Aquest és el segon punt que cal interioritzar: una condició de carrera amb probabilitat ínfima per operació es converteix en una fallada diària quan l'operació passa milions de vegades.
I compilar amb optimitzacions no arregla res; de vegades emmascara el problema i el fa més traïdor:
Amb -O2, GCC treu comptador del bucle, acumula en un registre i escriu una sola vegada al final. Els dos fils escriuen 1.000.000 i l'últim guanya. El resultat és igual d'incorrecte, però ara és estable, cosa que és pitjor: sembla una fallada determinista de lògica i no una carrera.
El cas real: el comptador de peticions de meteo-api
Portem això a Meteora. meteo-api té quatre treballadors que atenen consultes HTTP i comparteixen un bloc d'estadístiques a /dev/shm/meteora-cache, la memòria compartida que va aparèixer al mòdul 2:
/* Capçalera de /dev/shm/meteora-cache, mapejada amb MAP_SHARED
pels 4 treballadors de meteo-api */
struct cache_meteora {
unsigned long peticions_totals; /* ← comptador compartit */
unsigned long peticions_error;
unsigned long ultima_agregacio; /* timestamp de l'agregador */
struct Lectura ultimes[1024]; /* memòria cau de lectures recents */
};Cada treballador, en acabar d'atendre una consulta, fa:
Amb 1.200 peticions per segon repartides entre 4 treballadors, la comptabilitat al cap d'un dia mostra això:
$ grep -c "GET /api" /var/log/meteora/meteo-api.log 103680000 $ meteora-stats --camp peticions_totals 103_612_884
Falten 67.116 peticions, un 0,065 %. És una desviació petita, i aquí hi ha el perill real: no trenca res visiblement. Ningú no mira un tauler i pensa «això està malament». Simplement la facturació per ús surt un 0,065 % baixa, les alertes per llindar salten una mica tard, i l'informe mensual menteix en una xifra que ningú no contrasta.
Un matís important: aquí els fluxos que competeixen són processos diferents, no fils. Comparteixen la variable perquè comparteixen la pàgina física amb MAP_SHARED, no perquè comparteixin espai d'adreces. La condició de carrera és exactament la mateixa. El que fa perillosa una dada no és on viu, sinó que dos fluxos l'escriguin sense coordinació.
Aquest mateix bloc té un segon problema, més greu que perdre comptes. Quan l'agregador actualitza ultimes[] amb noves mitjanes horàries mentre un treballador de meteo-api l'està llegint, el lector pot veure una estructura mig actualitzada: el timestamp nou amb la temperatura vella. No es perd una dada, se n'inventa una que no va existir mai. Això ja no és una desviació estadística, és una resposta HTTP incorrecta. La solució al cas general la construirem a Sincronització i Exclusió Mútua; aquest patró concret de lectors i escriptors el tractarem a Problemes Clàssics de Concurrència.
La secció crítica i els tres requisits
Ja podem anomenar el problema amb precisió.
Una secció crítica és el fragment de codi d'un flux d'execució que accedeix a un recurs compartit de manera que pot entrar en conflicte amb els accessos d'altres fluxos.
A l'exemple, la secció crítica de cada treballador són les tres instruccions de cache->peticions_totals++. En el cas d'ultimes[], és tot el bloc que escriu els camps d'una Lectura completa.
Un detall que sovint es malinterpreta: la secció crítica no és la dada, és el codi. I la mateixa dada pot tenir diverses seccions crítiques repartides pel programa. Totes s'han de protegir; n'hi ha prou que se n'oblidi una perquè la protecció de les altres no serveixi de res.
El problema de la secció crítica consisteix a dissenyar un protocol —un «vull entrar» i un «he sortit»— que garanteixi tres propietats. Van ser formulades per Dijkstra el 1965 i continuen sent el criteri amb què es jutja qualsevol solució:
1. Exclusió mútua. Si un flux està executant la seva secció crítica, cap altre no pot estar executant la seva sobre el mateix recurs. És la propietat de seguretat: garanteix que mai no passa res dolent.
2. Progrés. Si cap flux no és a la seva secció crítica i hi ha fluxos que hi volen entrar, només ells participen en la decisió de qui entra, i aquesta decisió no es pot posposar indefinidament. Prohibeix que la secció crítica quedi bloquejada per ningú, o que un flux que ni tan sols hi vol entrar impedeixi fer-ho als altres. És el que evita l'interbloqueig, que veurem a Interbloquejos.
3. Espera limitada (bounded waiting). Existeix un límit al nombre de vegades que altres fluxos poden entrar a la seva secció crítica després que un flux hagi sol·licitat entrar-hi i abans que se li concedeixi. És el que evita la inanició: sense això, un flux pot quedar esperant per sempre mentre els seus companys es van tornant indefinidament.
| Requisit | Què evita | Nom de la fallada si no es compleix |
|---|---|---|
| Exclusió mútua | Que dos fluxos toquin la dada alhora | Condició de carrera, corrupció |
| Progrés | Que ningú no pugui entrar encara que sigui lliure | Interbloqueig |
| Espera limitada | Que un flux esperi indefinidament | Inanició |
Els tres són necessaris i són independents: una solució en pot complir dos i fallar en el tercer. Un forrellat que no s'allibera mai compleix l'exclusió mútua a la perfecció i viola el progrés de manera catastròfica. Guarda aquesta llista, perquè a Sincronització i Exclusió Mútua avaluarem cada solució proposada contra aquests tres criteris exactes.
A aquests tres, la pràctica hi afegeix dos supòsits que convé explicitar perquè de vegades s'obliden: no es pot suposar res sobre la velocitat relativa dels fluxos (un pot anar mil vegades més ràpid que un altre) ni sobre el nombre de nuclis.
Atomicitat: què ho és de debò i què no
Una operació és atòmica si, des del punt de vista de qualsevol altre flux, passa del tot o no passa en absolut: no hi ha cap instant en què s'observi a mitges.
La paraula ve del grec átomos, «indivisible». I la pregunta pràctica és: què és atòmic de debò en un sistema real?
| Operació | Atòmica? | Per què |
|---|---|---|
x = 5; amb x de 8 bytes alineat |
Sí en x86-64 | Una sola instrucció mov, dada alineada en una línia de memòria cau |
x = 5; amb x de 8 bytes no alineat a 8 |
No | Creua dues línies de memòria cau: dos accessos separats |
long y = x; (lectura simple, alineada) |
Sí en x86-64 | Un sol mov |
x++ |
No | Llegir-modificar-escriure: 3 instruccions |
x += n |
No | Igual que l'anterior |
if (x == 0) x = 1; |
No | Comprovar i actuar: dues operacions separades |
lock incq x (assemblador amb prefix lock) |
Sí | El maquinari bloqueja la línia de memòria cau durant l'operació |
__atomic_fetch_add(&x, 1, ...) en C11 |
Sí | El compilador emet la instrucció amb lock |
Escriure una struct Lectura de 24 bytes |
No | Tres o més escriptures de 8 bytes |
printf("...") |
No garantida | Funció complexa amb estat intern (memòria intermèdia de stdio) |
Una operació sobre un dict de Python |
Depèn | Un d[k] = v sí; un d[k] += 1 no |
Quatre conclusions pràctiques d'aquesta taula:
- Una assignació simple d'un tipus de la mida de la paraula i alineat és atòmica a les arquitectures actuals. Per això escriure un
intcompartit no corromp el valor: veuràs el vell o el nou, mai una barreja de bits. - Tot allò que sigui llegir-modificar-escriure no és atòmic, i això inclou
++,--,+=, i qualsevolifque decideixi en funció d'un valor compartit que després modifica. - Res compost de diverses paraules no és atòmic. Actualitzar una
struct Lecturade 24 bytes són com a mínim tres escriptures; un lector s'hi pot colar enmig. - Atòmic no vol dir correcte. Encara que
peticions_totals++fos atòmic, si la teva lògica és «llegir el comptador, decidir segons ell, i després escriure'l», la decisió continua sent una carrera. L'atomicitat és a nivell d'operació; la correcció és a nivell d'invariant.
Aquesta última idea és subtil i mereix un exemple. A Meteora, l'agregador decideix si rota el fitxer del dia:
if (cache->ultima_agregacio < ara - 3600) { /* comprovar */
cache->ultima_agregacio = ara; /* actuar */
executar_agregacio(); /* feina pesada */
}Cada línia per separat és atòmica. El conjunt no ho és: dos treballadors poden passar la comprovació abans que cap hagi escrit, i executar l'agregació dues vegades. És el patró check-then-act, i és la segona gran família de condicions de carrera després de read-modify-write. Aprèn a reconèixer les dues llegint codi; t'estalviaran molt temps de depuració.
No determinisme, heisenbugs i per què no es reprodueixen
Un programa seqüencial és determinista: amb les mateixes entrades produeix sempre la mateixa sortida i segueix el mateix camí. És la propietat que fa possible depurar de la manera habitual —reproduir, posar un punt de ruptura, mirar.
Un programa concurrent no és determinista. Amb les mateixes entrades pot produir sortides diferents, perquè l'entrellaçament concret depèn de factors que ni el programa ni tu no controleu:
- Les decisions del planificador CFS-EEVDF, que depenen del
vruntimeacumulat i per tant de tot el que hagi passat abans a la màquina (mòdul 2). - Les interrupcions, que arriben quan arriben i expulsen el fil en curs.
- L'estat de les memòries cau i del TLB: una fallada de memòria cau allarga una instrucció d'1 ns a 80 ns i desplaça la finestra de perill.
- La migració entre nuclis, la freqüència dinàmica de la CPU, la càrrega de la resta del sistema.
D'aquí ve el terme heisenbug: una fallada que canvia de comportament o desapareix quan intentes observar-la. El nom és un joc amb el principi d'incertesa de Heisenberg, i descriu una experiència molt real:
$ ./carrera Obtingut: 1298447 ← falla $ gdb ./carrera (gdb) run Obtingut: 2000000 ← correcte sota el depurador! $ strace -f ./carrera 2>/dev/null Obtingut: 2000000 ← correcte amb strace $ ./carrera ← sense instrumentar Obtingut: 1445912 ← torna a fallar
L'explicació és directa: gdb i strace intercepten esdeveniments i afegeixen desenes de microsegons per operació. Això canvia per complet la distribució temporal i fa que els fils gairebé mai no coincideixin a la finestra d'1 ns. La fallada no s'ha arreglat; s'ha tornat improbable.
Això té tres conseqüències molt pràctiques per a la teva feina:
- No pots demostrar l'absència de carreres provant. Que 10.000 execucions passin no diu res: has mostrejat 10.000 entrellaçaments de milers de milions. La correcció concurrent es demostra raonant sobre invariants, no executant.
- Les carreres apareixen quan canvia l'entorn. El codi que feia dos anys que era en producció falla en migrar de 4 a 32 nuclis, o quan el trànsit es duplica, o en passar d'un disc dur a NVMe. No ha canviat el codi: ha canviat la probabilitat de l'entrellaçament dolent.
- Necessites eines de detecció, no de reproducció. ThreadSanitizer (
gcc -fsanitize=thread) instrumenta cada accés a memòria i detecta accessos conflictius encara que la fallada no arribi a manifestar-se:
$ gcc -O0 -pthread -fsanitize=thread carrera.c -o carrera_tsan
$ ./carrera_tsan
WARNING: ThreadSanitizer: data race (pid=8814)
Write of size 8 at 0x55d3f8a2e010 by thread T2:
#0 treballador carrera.c:11
Previous write of size 8 at 0x55d3f8a2e010 by thread T1:
#0 treballador carrera.c:11
SUMMARY: ThreadSanitizer: data race carrera.c:11 in treballadorEt dona la línia exacta i els dos fils implicats, a la primera execució. El cost és de 5 a 15 vegades més lent i de 5 a 10 vegades més memòria, així que s'usa en proves, no en producció. És, de bon tros, l'eina més rendible d'aquest mòdul.
Models de concurrència comparats
No hi ha una única manera d'estructurar un programa concurrent. Hi ha quatre grans famílies, i triar bé entre elles determina la meitat dels problemes que tindràs després.
| Model | Unitat | Com comparteix estat | Cost de crear una unitat | Aïllament davant de fallades | Risc de carreres | Exemples reals |
|---|---|---|---|---|---|---|
| Multiprocés | Procés | Explícit: IPC, memòria compartida | ~100-300 µs | Alt: una fallada en mata només un | Baix (només en allò compartit) | Apache prefork, PostgreSQL, Chrome |
| Multifil | Fil | Implícit: tota la memòria | ~10-30 µs | Nul: una fallada mata el procés | Molt alt | MySQL, nginx (workers), JVM |
| Basat en esdeveniments | Callback / corutina | No n'hi ha: un sol fil | ~1 µs o menys | Nul | Molt baix | nginx, Node.js, Redis, asyncio |
| Actors / missatges | Actor | No es comparteix: s'envien còpies | ~1-10 µs | Alt per disseny | Molt baix | Erlang/Elixir, Akka, goroutines + canals |
Val la pena entendre el compromís de fons de cadascun:
- Multiprocés: aïllament a canvi de cost. Els espais d'adreces són independents, així que un punter corromput en un procés no pot tocar els altres. Chrome fa servir un procés per pestanya exactament per això. El preu: crear un procés costa un ordre de magnitud més que un fil i compartir dades exigeix un mecanisme explícit, que veurem a Comunicació entre Processos (IPC).
- Multifil: rendiment a canvi de perill. Compartir memòria és gratis, i per això és tan ràpid... i per això qualsevol variable és una carrera potencial. És el model que més disciplina exigeix. El desenvolupem a la lliçó següent, Fils i Processos.
- Basat en esdeveniments: elimina les carreres per construcció, perquè només hi ha un fil i res no s'executa alhora. A canvi, qualsevol operació bloquejant congela tot el servidor, i un càlcul llarg bloqueja tots els clients. És el model de nginx i de Redis, i explica per què Redis, sent monofil, atén centenars de milers d'operacions per segon: tot el que fa és feina de memòria, molt curta.
- Actors: cada actor té estat privat i només es comunica per missatges; com que no es comparteix res, no hi ha res a protegir. És el model que millor escala a sistemes distribuïts, perquè un missatge entre actors funciona igual dins d'una màquina que entre dues. El cost és la còpia de dades i un canvi profund d'estil de programació.
Un sistema real gairebé sempre barreja. Meteora, sense anar més lluny, en fa servir tres alhora: ingestor, agregador i meteo-api són processos separats (aïllament); dins de meteo-api hi ha diversos fils treballadors (rendiment); i l'ingestor atén els seus 800 sockets amb un bucle d'esdeveniments basat en epoll (escalabilitat amb moltes connexions lentes). Aquesta combinació no és casualitat, i a Fils i Processos veurem per què cada peça va triar el que va triar.
Escalabilitat i la llei d'Amdahl aplicada a l'agregador
Última peça, i la més útil per prendre decisions: quant es pot accelerar un programa afegint-hi nuclis?
La resposta la va donar Gene Amdahl el 1967 i és més pessimista del que gairebé ningú espera. Si una fracció P del temps d'execució és paral·lelitzable i la resta (1−P) és estrictament seqüencial, l'acceleració amb N unitats de procés és:
I en el límit, amb infinits nuclis:
És a dir: la part seqüencial posa un sostre absolut, i aquest sostre no depèn del maquinari que compris.
Apliquem-ho a l'agregador de Meteora. La seva feina diària, mesurada amb perf sobre el fitxer 2026-08-31.dat (17 MB, unes 700.000 lectures):
| Fase | Temps | Paral·lelitzable? |
|---|---|---|
| Llegir el fitxer del dia i validar-ne la capçalera | 0,9 s | No: és una lectura seqüencial |
| Calcular mitjanes per estació i per hora | 7,2 s | Sí: cada estació és independent |
| Fusionar els resultats parcials i ordenar | 1,1 s | No: necessita tots els parcials |
| Escriure el resultat i actualitzar la memòria cau | 0,8 s | No: escriptura seqüencial |
| Total | 10,0 s |
La fracció paral·lelitzable és P = 7,2 / 10,0 = 0,72. Amb això ja podem calcular:
| Nuclis | Càlcul | Acceleració | Temps total | Eficiència (S/N) |
|---|---|---|---|---|
| 1 | 1 / (0,28 + 0,72) | 1,00× | 10,00 s | 100 % |
| 2 | 1 / (0,28 + 0,36) | 1,56× | 6,40 s | 78 % |
| 4 | 1 / (0,28 + 0,18) | 2,17× | 4,60 s | 54 % |
| 8 | 1 / (0,28 + 0,09) | 2,70× | 3,70 s | 34 % |
| 16 | 1 / (0,28 + 0,045) | 3,08× | 3,25 s | 19 % |
| 32 | 1 / (0,28 + 0,0225) | 3,31× | 3,02 s | 10 % |
| ∞ | 1 / 0,28 | 3,57× | 2,80 s | 0 % |
Llegeix la taula a poc a poc, perquè té tres lliçons cares:
Primera: el sostre és 3,57×, no 32×. Encara que Meteora comprés un servidor de 128 nuclis, l'agregador no baixaria de 2,8 segons. Els 2,8 s de parts seqüencials no se'n van enlloc.
Segona: l'eficiència s'esfondra. Passar d'1 a 4 nuclis guanya 5,4 segons. Passar de 4 a 16 en guanya només 1,35 més, fent servir dotze nuclis addicionals. Aquests dotze nuclis podrien estar atenent peticions de meteo-api; dedicar-los a l'agregador per guanyar 1,35 s és probablement una mala decisió d'arquitectura.
Tercera, i la que gairebé tothom oblida: la fórmula és optimista. Suposa que paral·lelitzar és gratis, i no ho és. Cal repartir la feina, i sobretot cal sincronitzar, i la sincronització té un cost que creix amb el nombre de fluxos. Si els 8 fils de l'agregador competeixen per un mutex sobre el resultat parcial, el temps real amb 8 nuclis pot ser pitjor que el previst —i en casos de contenció alta, pitjor que amb 4 nuclis. És el que s'anomena escalabilitat negativa, i és sorprenentment freqüent. Mesurarem aquest cost amb números concrets a Sincronització i Exclusió Mútua.
La conclusió operativa: abans de paral·lelitzar, mesura P. Si la teva P és 0,72, l'objectiu realista són 4 nuclis i 2,2×; comprar-ne 32 és llençar diners. I moltes vegades reduir la part seqüencial (aquí, 0,9 s de lectura: es pot solapar amb el càlcul?) dona més que afegir nuclis.
Errors Habituals i Consells
Creure que si el programa no falla, no hi ha carrera. És l'error més car del mòdul. Una carrera pot tenir una probabilitat de 10⁻⁹ per operació i no manifestar-se en dos anys... fins que el trànsit es multiplica per deu o migres a una màquina amb el doble de nuclis. Fes servir -fsanitize=thread a les teves proves: detecta l'accés conflictiu encara que la fallada no arribi a passar.
Confondre «és una sola línia» amb «és atòmic». comptador++, llista.append(x), if (p == NULL) p = crear(); són una línia cadascun i cap no és atòmic. La unitat d'entrellaçament és la instrucció màquina, i en llenguatges d'alt nivell, l'operació de l'intèrpret. Pregunta't sempre: això llegeix i després escriu? Si sí, és una carrera potencial.
Pensar que la concurrència només importa amb diversos nuclis. És fals. Un nucli amb dos fils ja pateix totes les condicions de carrera d'aquesta lliçó, perquè el planificador expulsa en qualsevol punt. Limitar el procés a un nucli amb taskset -c 0 redueix la freqüència de la fallada, no l'elimina, i crea una falsa sensació de seguretat.
Afegir fils esperant una millora proporcional. La llei d'Amdahl diu que no, i la sincronització empitjora encara més la predicció. Mesura la fracció paral·lelitzable abans de redissenyar.
Fer servir volatile en C creient que serveix per a la concurrència. No serveix. volatile diu al compilador que no guardi la variable en memòria cau dins d'un registre, però no impedeix la reordenació del processador ni fa atòmic un ++. Un volatile long comptador; comptador++; continua perdent increments. És un error tan estès que li dedicarem un apartat sencer a Sincronització i Exclusió Mútua.
Consell: identifica les dades compartides abans que les seccions crítiques. Fes una llista de quines variables toca més d'un flux i qui les escriu. A Meteora aquesta llista és curta: peticions_totals, ultimes[], ultima_agregacio. Gairebé tota la resta és privada de cada fil i no necessita protecció. Protegir el que no cal costa rendiment; oblidar el que sí que cal costa correcció.
Consell: prefereix no compartir a sincronitzar bé. Si cada treballador de meteo-api porta el seu propi comptador i algú els suma un cop per minut, no hi ha secció crítica a protegir. La tècnica s'anomena sharding del comptador i sol ser molt més ràpida que un comptador compartit perfectament sincronitzat.
Exercicis
Exercici 1: reproduir i mesurar la carrera
Escriu un programa en C amb dos fils que incrementin una variable compartida un nombre configurable de vegades (per argument). Executa'l amb 1.000, 100.000 i 10.000.000 d'iteracions, cinc vegades cadascuna, i construeix una taula amb el percentatge mitjà d'increments perduts. Explica la tendència. Després compila'l amb ThreadSanitizer i comprova si detecta la carrera amb només 1.000 iteracions.
Exercici 2: classificar seccions crítiques
Per a cada fragment, indica si conté una condició de carrera quan l'executen dos fils, de quin tipus és (read-modify-write, check-then-act, escriptura no atòmica d'estructura, o cap) i quina és exactament la secció crítica.
/* (a) */ cache->peticions_totals++;
/* (b) */ long copia = cache->peticions_totals;
printf("Total: %ld\n", copia);
/* (c) */ if (cache->ultimes[i].timestamp == 0)
cache->ultimes[i] = nova_lectura;
/* (d) */ int local = 0;
for (int j = 0; j < 1000; j++) local += dades[j];
/* dades[] només es llegeix, ningú no la modifica */
/* (e) */ cache->ultimes[index_fil] = nova_lectura;
/* cada fil fa servir el seu propi index_fil, diferent dels altres */
/* (f) */ cache->ultima_agregacio = ara; /* unsigned long alineat */Exercici 3: decisió amb la llei d'Amdahl
L'ingestor de Meteora triga 4,0 s a processar un lot de 100.000 lectures: 0,5 s a llegir del socket (seqüencial), 3,0 s a validar i convertir cada lectura (paral·lelitzable), 0,5 s a escriure el fitxer (seqüencial). Meteora es planteja dues inversions que costen el mateix:
- Opció A: paral·lelitzar la validació amb 8 fils.
- Opció B: deixar-ho monofil però optimitzar l'escriptura de 0,5 s a 0,1 s i la lectura de 0,5 s a 0,2 s.
Calcula el temps final de cada opció i raona quina triaries. Després calcula què passa si es fan totes dues i quin és el nou sostre teòric.
Solucions
Solució 1
/* mesurar_carrera.c */
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
long comptador = 0;
long voltes;
void *treballador(void *arg) {
for (long i = 0; i < voltes; i++) comptador++;
return NULL;
}
int main(int argc, char **argv) {
voltes = atol(argv[1]);
pthread_t f1, f2;
pthread_create(&f1, NULL, treballador, NULL);
pthread_create(&f2, NULL, treballador, NULL);
pthread_join(f1, NULL); pthread_join(f2, NULL);
long esperat = 2 * voltes;
printf("%ld %ld %.4f%%\n", esperat, comptador,
100.0 * (esperat - comptador) / esperat);
return 0;
}Compilar amb gcc -O0 -pthread mesurar_carrera.c -o mesurar_carrera (el -O0 és important: amb -O2 el compilador treu el comptador del bucle i el fenomen canvia de naturalesa).
Resultats típics:
| Iteracions per fil | Pèrdua mitjana | Rang observat |
|---|---|---|
| 1.000 | 0,0 % | sempre exacte |
| 100.000 | ~11 % | 0 % – 28 % |
| 10.000.000 | ~48 % | 41 % – 52 % |
Explicació de la tendència. Amb 1.000 iteracions el bucle dura uns 3 µs, menys que el temps que triga el segon fil a arrencar (~20 µs): a la pràctica s'executen un darrere l'altre i no hi ha solapament. Amb 100.000 el solapament existeix però és parcial, i la pèrdua és erràtica. Amb 10 milions tots dos fils corren en paral·lel gairebé tota l'estona, en nuclis diferents, i la línia de memòria cau amb comptador rebota entre ells constantment: cada fil llegeix valors obsolets gairebé sempre i la pèrdua s'acosta al 50 %, que és el màxim teòric (els dos fils avancen «en paral·lel» sobre el mateix valor i només en compta un).
Aquest resultat il·lustra la lliçó clau: amb poques iteracions el programa «funciona». Si la teva prova unitària fa servir 1.000 i producció en fa servir 10 milions, la teva prova passa sempre i producció falla sempre.
Amb ThreadSanitizer:
$ gcc -O0 -pthread -fsanitize=thread mesurar_carrera.c -o mesurar_tsan
$ ./mesurar_tsan 1000
WARNING: ThreadSanitizer: data race (pid=9012)
Write of size 8 at 0x5581... by thread T2:
#0 treballador mesurar_carrera.c:8
2000 2000 0.0000%Detecta la carrera amb 1.000 iteracions, on el resultat és correcte. Això és exactament el que el fa valuós: no busca símptomes, busca la causa. TSan manté rellotges vectorials per fil i detecta que dos accessos, un d'ells d'escriptura, toquen la mateixa adreça sense cap relació d'ordre que els separi.
Solució 2
| Cas | Hi ha carrera? | Tipus | Secció crítica |
|---|---|---|---|
| (a) | Sí | Read-modify-write | Les tres instruccions del ++ |
| (b) | No (amb matís) | – | Cap: només llegeix, i la lectura d'un long alineat és atòmica. Pot llegir un valor desactualitzat, però mai corromput. Si la lògica depengués d'aquest valor per escriure després, sí que hi hauria check-then-act. |
| (c) | Sí | Check-then-act i escriptura no atòmica | Des de l'if fins al final de l'assignació. Dos fils poden veure timestamp == 0 i tots dos escriure; a més, l'assignació de 24 bytes no és atòmica i un lector pot veure l'estructura a mitges |
| (d) | No | – | Cap: local és de pila (privada de cada fil) i dades[] és de només lectura. Les dades immutables o privades no necessiten mai protecció |
| (e) | No, en principi | – | Cada fil escriu una posició diferent de l'array. Compte: és correcte, però pot ser lent per false sharing si dues posicions cauen a la mateixa línia de memòria cau de 64 bytes; amb struct Lectura de 24 bytes, els índexs 0, 1 i 2 comparteixen línia |
| (f) | No per a l'escriptura en si | – | Escriure un unsigned long alineat és una sola instrucció. Altres fils veuran el valor vell o el nou, mai una barreja. Però si un altre fil fa check-then-act sobre aquest camp, la carrera és allà, no aquí |
El cas (e) mereix un comentari extra perquè ensenya una cosa important: correcte i ràpid són coses diferents. No hi ha carrera, el resultat sempre és correcte, però si els fils escriuen en posicions contigües de l'array, els nuclis s'invaliden la línia de memòria cau mútuament i el rendiment pot caure 5 o 10 vegades. La solució és alinear cada element a 64 bytes. És un problema de rendiment causat per la concurrència, no de correcció.
Solució 3
Estructura del temps original: seqüencial = 0,5 + 0,5 = 1,0 s; paral·lelitzable = 3,0 s; total 4,0 s. Per tant P = 3,0/4,0 = 0,75.
Opció A (8 fils en la validació):
S(8) = 1 / (0,25 + 0,75/8) = 1 / (0,25 + 0,09375) = 1 / 0,34375 = 2,91× Temps = 4,0 / 2,91 = 1,375 s
Comprovació directa: 0,5 + 3,0/8 + 0,5 = 0,5 + 0,375 + 0,5 = 1,375 s.
Opció B (optimitzar les parts seqüencials):
Quina triar. L'opció A és clarament millor en temps brut (1,375 s davant de 3,3 s, 2,4 vegades més ràpida). Però la decisió d'enginyeria té més arestes:
- A consumeix 8 nuclis durant 0,375 s; B en consumeix 1 durant 3,3 s. Si
meteo-01té 8 nuclis imeteo-apiels necessita per atendre clients, A degrada la latència de l'API durant aquesta estona. - A introdueix concurrència sobre les dades: cal repartir el lot, i els fils escriuran resultats que després es fusionen. Això obre la porta a totes les carreres d'aquesta lliçó. B no toca l'estructura del programa i no pot introduir cap fallada de concurrència.
- B redueix la part seqüencial, cosa que puja el sostre per a futures paral·lelitzacions.
Si l'objectiu és la latència del lot i hi ha nuclis lliures, A. Si el sistema ja està saturat o l'equip té poca experiència en concurrència, B és una millora segura i barata.
Fent totes dues:
Nou sostre teòric amb infinits nuclis, ja optimitzades les parts seqüencials:
Fixa't en el que ha passat: el sostre original era 4,0/1,0 = 4×. Reduir la part seqüencial d'1,0 s a 0,3 s l'ha pujat a 13,3×. Optimitzar la part seqüencial no només millora el temps actual: millora el retorn de tot el paral·lelisme futur. És la lliçó menys intuïtiva de la llei d'Amdahl i la més útil a la pràctica.
Conclusió
La concurrència és una propietat de l'estructura d'un programa —tasques en curs durant el mateix interval—; el paral·lelisme és una propietat de la seva execució —instruccions en el mateix instant físic—. La distinció importa perquè un sol nucli amb dos fils ja produeix tots els problemes d'aquest mòdul: n'hi ha prou que el planificador expulsi en el punt equivocat. I la concurrència no és opcional: el maquinari va deixar d'accelerar-se en freqüència, l'E/S és entre mil i un milió de vegades més lenta que la CPU, el món que modelem és concurrent, i el nucli mateix ho és per construcció.
El model mental que ho governa tot és l'entrellaçament: l'execució real és un dels molts ordres possibles de les instruccions de cada flux, no tries quin, i el teu programa només és correcte si ho és per a tots. Amb dos fils de deu instruccions hi ha 184.756 entrellaçaments; provar no cobreix res. D'aquí neix la condició de carrera, que hem descompost fins a l'assemblador: comptador++ són tres instruccions —llegir, modificar, escriure— i la finestra d'1 ns entre la primera i la tercera és suficient per perdre mig milió d'increments per execució. A Meteora això es tradueix en 67.116 peticions no comptabilitzades al dia: un 0,065 % que no trenca res visible i per això és perillós.
La zona a protegir és la secció crítica, que és codi i no dada, i qualsevol solució ha de complir tres requisits independents: exclusió mútua (mai dos a dins), progrés (si està lliure, algú hi entra) i espera limitada (ningú no espera per sempre). Són el criteri amb què jutjarem cada primitiva del mòdul. Hem vist també què és atòmic de debò —una escriptura alineada de la mida de la paraula sí; ++, +=, un if que després escriu, o qualsevol estructura de diverses paraules, no— i les dues famílies de carreres que reconeixeràs en el 90 % del codi real: read-modify-write i check-then-act.
Aquestes fallades són no deterministes, depenen del planificador, de les interrupcions i de les memòries cau, i per això desapareixen sota gdb o strace: són heisenbugs. La conseqüència pràctica és que no es depuren reproduint, sinó amb detectors com ThreadSanitizer, que troba la carrera encara que el resultat surti correcte. Els quatre models de concurrència —multiprocés, multifil, esdeveniments i actors— reparteixen de manera diferent el mateix compromís entre aïllament, cost i risc de carreres, i Meteora en fa servir tres alhora. I la llei d'Amdahl hi posa el límit: amb P = 0,72, l'agregador no baixarà mai de 2,8 s encara que tingui 128 nuclis, i passar de 4 a 16 nuclis guanya tot just 1,35 s.
Ja saps què va malament i per què. Ara toca conèixer els protagonistes de prop. Hem parlat de «fluxos d'execució» sense comprometre'ns: processos que comparteixen una pàgina, fils que ho comparteixen tot. Què és exactament un fil? Què comparteix amb els seus germans i què manté privat? Per què crear un fil costa 20 µs i crear un procés 200? I per què a Linux, per dins, un fil i un procés són la mateixa cosa anomenada d'una altra manera?
Ho veiem a Fils i Processos.
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
