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

  1. Concurrència i paral·lelisme no són el mateix
  2. Per què la concurrència és inevitable
  3. L'entrellaçament d'instruccions com a model mental
  4. La condició de carrera, descomposta
  5. El cas real: el comptador de peticions de meteo-api
  6. La secció crítica i els tres requisits
  7. Atomicitat: què ho és de debò i què no
  8. No determinisme, heisenbugs i per què no es reprodueixen
  9. Models de concurrència comparats
  10. 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 ← rax

Aquí 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:

$ gcc -O2 -pthread carrera.c -o carrera_opt
$ ./carrera_opt
Obtingut: 1000000

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:

cache->peticions_totals++;    /* llegir-modificar-escriure sobre memòria compartida */

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 int compartit 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 qualsevol if que decideixi en funció d'un valor compartit que després modifica.
  • Res compost de diverses paraules no és atòmic. Actualitzar una struct Lectura de 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 vruntime acumulat 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:

  1. 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.
  2. 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.
  3. 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 treballador

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

                    1
S(N) = ─────────────────────────
        (1 − P) + P/N

I en el límit, amb infinits nuclis:

S(∞) = 1 / (1 − P)

É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):

Temps = 0,2 + 3,0 + 0,1 = 3,3 s     →  acceleració 4,0/3,3 = 1,21×

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-01 té 8 nuclis i meteo-api els 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:

Temps = 0,2 + 3,0/8 + 0,1 = 0,2 + 0,375 + 0,1 = 0,675 s   →  5,93× sobre l'original

Nou sostre teòric amb infinits nuclis, ja optimitzades les parts seqüencials:

S(∞) = 4,0 / (0,2 + 0,1) = 4,0 / 0,3 = 13,3×   →  0,3 s

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

Mòdul 2: Gestió de Recursos

Mòdul 3: Concurrència

Mòdul 4: Estructures de Fitxers

Mòdul 5: Protecció i Seguretat del Sistema

Mòdul 6: Virtualització i Contenidors

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

© Copyright 2026. Tots els drets reservats