Amb el codi afinat (05-01) i la memòria sota control (05-02), queda el recurs que fins ara hem ignorat: un processador modern té 4, 8 o 16 nuclis, i tot el que hem escrit al curs en fa servir exactament un. Aquesta lliçó ensenya a repartir la feina — quan fer-ho, com fer-ho a Python i, sobretot, quan no fer-ho. Perquè paral·lelitzar no és de franc: exigeix classificar la tasca (espera o calcula?), esquivar una peculiaritat famosa de Python anomenada GIL, pagar costos ocults d'arrencada i comunicació, i acceptar un sostre matemàtic — la llei d'Amdahl — que cap quantitat de nuclis no pot aixecar. Sobre RutaBus veurem els dos escenaris canònics: consultar 50 APIs de trànsit municipal (esperar) i recalcular la matriu de temps de tota la xarxa (calcular), cadascun amb la seva eina correcta. La lliçó tanca, a més, el mòdul: al final recapitularem el mètode complet de l'ofici d'optimitzar.

Contingut

  1. Concurrència i paral·lelisme: no són el mateix
  2. Classificar abans de repartir: CPU-bound contra I/O-bound
  3. El GIL de Python, explicat sense embuts
  4. Fils per a I/O-bound: ThreadPoolExecutor
  5. Processos per a CPU-bound: ProcessPoolExecutor
  6. Què es paral·lelitza bé: problemes vergonyosament paral·lels
  7. La llei d'Amdahl: el sostre del guany
  8. Costos ocults i quan NO paral·lelitzar
  9. Condicions de cursa i Lock
  10. Tancament del mòdul: el mètode complet

Concurrència i paral·lelisme: no són el mateix

Dues paraules que es confonen constantment i que anomenen coses diferents:

  • Concurrència: gestionar diverses tasques alhora, encara que no avancin simultàniament. Un cuiner sol que atén tres cassoles — mentre una bull, en remena una altra — és concurrent: el progrés s'entrellaça.
  • Paral·lelisme: executar diverses tasques literalment al mateix temps. Tres cuiners, tres cassoles, tres fogons: el progrés és simultani.
Concurrència Paral·lelisme
Idea Estructurar tasques que se solapen Executar càlculs alhora
Requereix diversos nuclis No (amb un n'hi ha prou)
Guanya temps quan... les tasques esperen (xarxa, disc) les tasques calculen
A Python threading, asyncio multiprocessing

La distinció no és acadèmica: és la que decideix quina eina fer servir. La concurrència aprofita les esperes d'unes tasques per fer avançar les altres (amb un sol nucli n'hi ha prou, perquè esperar no ocupa CPU); el paral·lelisme reparteix càlcul entre nuclis. I per saber quina de les dues necessita el teu problema, primer cal classificar-lo.

Classificar abans de repartir: CPU-bound contra I/O-bound

Tota tasca lenta ho és per una de dues raons:

  • CPU-bound (limitada pel càlcul): el processador treballa al 100 % tota l'estona. Més velocitat = més nuclis calculant.
  • I/O-bound (limitada per l'entrada/sortida): el processador passa gairebé tot el temps esperant — la xarxa, el disc, una base de dades. Més velocitat = solapar les esperes.

Classifiquem tasques reals de RutaBus:

Tasca On se'n va el temps? Tipus Estratègia
Recalcular la matriu de temps (Dijkstra des de cada parada) Càlcul pur sobre el graf CPU-bound Processos
Consultar les 50 APIs de trànsit dels municipis Esperar respostes HTTP (~1 s cadascuna) I/O-bound Fils
Llegir el fitxer de fitxatges de 2 GB Esperar el disc I/O-bound (poc a guanyar: un sol disc)
Generar els informes PDF del dia Càlcul (maquetar) CPU-bound Processos
Avisar 10.000 usuaris per push Esperar la passarel·la d'enviament I/O-bound Fils

Un truc de diagnòstic: mira el monitor del sistema mentre corre la tasca. Un nucli clavat al 100 %? CPU-bound. CPU avorrida i la tasca igual de lenta? I/O-bound. (I sí: això també és mesurar abans d'actuar — el mètode de 05-01 no fa vacances.)

El GIL de Python, explicat sense embuts

Aquí Python té una peculiaritat que cal explicar honestament. CPython — l'intèrpret estàndard — té un GIL (Global Interpreter Lock): un forrellat global que garanteix que només un fil executa bytecode Python alhora. Encara que llancis 8 fils en una màquina de 8 nuclis, les seves instruccions Python es tornen sobre el forrellat: mai no n'hi ha dos executant-se simultàniament.

Conseqüències pràctiques, sense embuts:

  • Els fils NO acceleren tasques CPU-bound en CPython. Vuit fils calculant Dijkstras sumen la mateixa feina per un sol forrellat, més el cost de tornar-se: sol sortir igual o més lent que en sèrie.
  • Els fils SÍ que acceleren tasques I/O-bound. Quan un fil es queda esperant la xarxa o el disc, deixa anar el GIL i un altre fil avança. Cinquanta fils esperant cinquanta APIs se solapen d'allò més bé: esperar no requereix forrellat.
  • La sortida per a CPU-bound són els processos. Cada procés és un intèrpret Python independent, amb la seva pròpia memòria i el seu propi GIL: vuit processos sí que fan servir vuit nuclis de debò.
  • Matisos per al mapa complet: les biblioteques en C (NumPy, esmentada a 05-02) sovint deixen anar el GIL durant els seus càlculs, per la qual cosa se salten parcialment la limitació; i CPython treballa des de la versió 3.13 en un mode experimental sense GIL (free-threaded). Però la regla que has de memoritzar avui és la de sempre en producció: fils per esperar, processos per calcular.

Fils per a I/O-bound: ThreadPoolExecutor

La interfície recomanada per a tots dos mons és concurrent.futures: mateixos mètodes (map, submit), només canvia l'executor. Comencem per les 50 APIs de trànsit:

import time
from concurrent.futures import ThreadPoolExecutor

def consulta_transit(municipi):
    """Consulta l'estat del trànsit d'un municipi (~1 s d'espera de xarxa)."""
    time.sleep(1.0)                       # simula la crida HTTP; en real: requests.get(...)
    return municipi, "fluid"

municipis = [f"Municipi-{i:02d}" for i in range(50)]

# EN SÈRIE: 50 crides × 1 s = ~50 s
inici = time.perf_counter()
estats = dict(consulta_transit(m) for m in municipis)
print(f"sèrie: {time.perf_counter() - inici:.1f} s")     # sèrie: 50.1 s

# AMB FILS: les 50 esperes se solapen
inici = time.perf_counter()
with ThreadPoolExecutor(max_workers=10) as pool:
    estats = dict(pool.map(consulta_transit, municipis))
print(f"fils:  {time.perf_counter() - inici:.1f} s")     # fils:  5.1 s

Què està passant, línia a línia:

  • ThreadPoolExecutor(max_workers=10) crea una piscina de 10 fils reutilitzables; el with garanteix que s'espera tots els fils i es tanquen en sortir.
  • pool.map(f, dades) és el map de tota la vida, repartit: cada fil pren un municipi, llança la consulta i, mentre espera la resposta (GIL alliberat), un altre fil llança la seva. Retorna els resultats en l'ordre d'entrada.
  • 50 tasques entre 10 fils = 5 tandes d'~1 s ≈ 5 s. Per què no 50 fils i trigar 1 s? Es pot, però cada fil consumeix recursos i les APIs reals limiten les peticions simultànies; max_workers és un dial que s'ajusta mesurant.

Per processar respostes a mesura que van arribant (en comptes d'esperar l'ordre), existeix concurrent.futures.as_completed; te'l trobaràs en codi real i funciona igual amb tots dos executors.

Processos per a CPU-bound: ProcessPoolExecutor

Ara la tasca de calcular: recalcular els temps mínims de tota la xarxa, zona a zona. Canviar fils per processos és literalment canviar una paraula:

from concurrent.futures import ProcessPoolExecutor

def recalcula_zona(zona):
    """CPU pura: Dijkstra des de cada parada de la zona (04-05)."""
    return zona["nom"], {origen: dijkstra(zona["xarxa"], origen)[0]
                         for origen in zona["xarxa"]}

if __name__ == "__main__":                # OBLIGATORI amb processos (vegeu més avall)
    zones = carrega_zones()               # p. ex. 8 zones de la xarxa metropolitana
    with ProcessPoolExecutor(max_workers=4) as pool:
        matrius = dict(pool.map(recalcula_zona, zones))

Diferències que importen respecte dels fils:

  • Cada worker és un procés Python independent: memòria pròpia, GIL propi, un nucli de debò per a ell. Quatre processos en quatre nuclis ≈ 4x en càlcul pur (menys el peatge de l'apartat 8).
  • El guardià if __name__ == "__main__": no és decoratiu: a Windows i macOS els processos fills reimporten el teu mòdul per arrencar, i sense el guardià cada fill rellançaria la creació de la piscina — processos engendrant processos sense fi.
  • Els arguments i els resultats viatgen serialitzats amb pickle entre processos (no comparteixen memòria). Això té dues conseqüències: les funcions i les dades han de ser picklables (una lambda no ho és, una funció de mòdul sí), i moure dades grans costa — ho quantifiquem a l'apartat 8.

Què es paral·lelitza bé: problemes vergonyosament paral·lels

No tot problema es deixa repartir. El cas ideal té nom propi: vergonyosament paral·lel (embarrassingly parallel) — la feina es divideix en peces que no necessiten res les unes de les altres.

L'exemple perfecte ja el coneixem de 04-06: la matriu de temps tots-amb-tots es pot construir executant Dijkstra des de cada origen per separat. El Dijkstra des de la Plaça Major no llegeix ni escriu res del Dijkstra des de la Universitat: V tasques independents, repartibles tal qual entre els nuclis:

from concurrent.futures import ProcessPoolExecutor
from functools import partial

def matriu_de_temps(xarxa):
    with ProcessPoolExecutor() as pool:                      # workers = nre. de nuclis
        distancies = pool.map(partial(dijkstra_des_de, xarxa), xarxa.keys())
    return dict(zip(xarxa.keys(), distancies))

(partial fixa l'argument xarxa perquè map només reparteixi els orígens; dijkstra_des_de(xarxa, origen) retorna el dict de distàncies.) Amb 8 nuclis i 20.000 orígens, prop de 8x. El contrast instructiu és Floyd-Warshall: el seu bucle extern en k és una dependència seqüencial — la iteració k necessita la matriu completa que va deixar la k−1 (era l'essència de l'algorisme a 04-06) — així que no es pot repartir per les bones. Regla general per reconèixer cada cas:

  • Cada peça es calcula només amb l'entrada comuna (la xarxa) i les seves pròpies dades? → vergonyosament paral·lel: simulacions per escenari, informes per zona, un Dijkstra per origen.
  • Cada pas necessita el resultat de l'anterior? → cadena seqüencial: iteracions de Floyd-Warshall, un acumulador que depèn de l'ordre, la pila del backtracking de 03-04.

La majoria de problemes reals són al mig: una part repartible i una part seqüencial (llegir les dades, ajuntar els resultats). Fins a quin punt limita aquesta part seqüencial té fórmula exacta.

La llei d'Amdahl: el sostre del guany

Si una fracció p del temps d'un programa és paral·lelitzable (i la fracció 1−p és forçosament seqüencial), l'acceleració màxima amb n nuclis és:

S(n) = 1 / ((1 − p) + p/n)

La intuïció: la part paral·lela es divideix entre n, però la seqüencial es paga sencera, tant sí com no. Números:

p (fracció paral·lelitzable) n = 2 n = 4 n = 8 n = 16 n → ∞ (sostre)
50 % 1.33x 1.60x 1.78x 1.88x 2x
90 % 1.82x 3.08x 4.71x 6.40x 10x
95 % 1.90x 3.48x 5.93x 9.14x 20x
99 % 1.98x 3.88x 7.48x 13.9x 100x

Dues lectures que fan mal i que convé interioritzar:

  • Amb la meitat del programa seqüencial, infinits nuclis donen 2x. Ni un més.
  • Fins i tot amb el 90 % paral·lelitzable, 8 nuclis no donen 8x: donen 4.71x. El fullet del processador ven nuclis; Amdahl reparteix realitat.

Aplicat al job de la matriu de RutaBus: si carregar la xarxa i escriure els resultats (seqüencial) és el 10 % del temps total, el sostre és 10x encara que el clúster tingui 64 nuclis. La conseqüència pràctica enllaça amb tot el mòdul: reduir la part seqüencial (optimitzant-la amb 05-01 i 05-02) apuja el sostre de p — sovint rendeix més que afegir nuclis.

Costos ocults i quan NO paral·lelitzar

Amdahl és el sostre teòric; a la pràctica s'està encara més avall, perquè paral·lelitzar cobra peatges:

  • Arrencada de processos: crear un procés Python costa desenes o centenars de mil·lisegons (importa mòduls, inicialitza l'intèrpret). Per a una tasca de 2 segons totals, la piscina pot costar més que la feina.
  • Serialització (pickle): cada argument i cada resultat se serialitza, viatja i es deserialitza. Enviar la xarxa metropolitana completa (diversos MB, 05-02) a cadascun de 20.000 treballs la copia 20.000 vegades. Mitigacions: enviar referències barates (la zona, no el graf sencer; que cada worker carregui la xarxa una vegada), agrupar treballs amb chunksize a pool.map, i retornar resultats agregats en comptes de dades crues.
  • Sincronització: si les tasques comparteixen alguna cosa (apartat 9), coordinar-ne l'accés consumeix temps i, en el pitjor cas, re-seqüencialitza allò que es volia paral·lelitzar.

D'aquí la llista de quan NO paral·lelitzar:

  • Quan la feina total és petita: el sobrecost supera el guany. Es comprova mesurant, no estimant.
  • Quan encara no s'ha optimitzat en sèrie: paral·lelitzar la versió amb l'in sobre llista de 05-01 és multiplicar feina inútil per 8 nuclis. L'ordre del mòdul és l'ordre del mètode: algorisme → codi → memòria → i només llavors, paral·lelitzar.
  • Quan la part seqüencial domina (Amdahl): amb p = 50 %, complicar el codi per a un sostre de 2x rarament compensa.
  • Quan moure les dades costa més que calcular-les: tasques diminutes sobre dades enormes són el pitjor client de pickle.

Condicions de cursa i Lock

El preu en correcció del paral·lelisme es diu condició de cursa: dos fils tocant la mateixa dada alhora, amb un resultat que depèn de l'atzar de l'entrellaçat. L'exemple mínim — un comptador global de fitxatges processats:

import threading

comptador = 0

def processa_lot(lot):
    global comptador
    for _ in lot:
        comptador += 1        # NO és atòmic!: llegir, sumar, escriure (3 passos)

fils = [threading.Thread(target=processa_lot, args=([0] * 100_000,)) for _ in range(4)]
for fil in fils: fil.start()
for fil in fils: fil.join()
print(comptador)              # esperat 400000; obtingut p. ex. 273481 — i cada vegada un de diferent

comptador += 1 són tres operacions (llegir el valor, sumar-hi 1, escriure'l). Si dos fils llegeixen "1000" alhora, tots dos escriuen "1001": una suma es perd. El GIL no protegeix d'això — garanteix un fil per instrucció de bytecode, però pot canviar de fil entre les tres operacions. La solució és un Lock (forrellat) que converteix la seqüència en exclusiva:

forrellat = threading.Lock()

def processa_lot(lot):
    global comptador
    for _ in lot:
        with forrellat:      # només un fil a dins alhora
            comptador += 1   # ara sí: 400000, sempre

Dues observacions i tanquem, perquè els sistemes distribuïts queden fora d'aquest curs:

  • El Lock re-seqüencialitza la secció que protegeix: si gairebé tota la feina passa pel forrellat, adeu paral·lelisme (Amdahl, un altre cop). Millor disseny: que cada fil acumuli en el seu comptador local i que se sumin al final.
  • La millor condició de cursa és la que no pot existir: per això els exemples d'aquesta lliçó reparteixen feina sense estat compartit (cada tasca rep les seves dades, retorna el seu resultat, i map ho ajunta). Els processos, com que no comparteixen memòria, fan d'aquesta disciplina l'opció per defecte.

Tancament del mòdul: el mètode complet

Amb aquesta lliçó es completa l'ofici que prometia el final del Mòdul 4. El mètode, en ordre — i l'ordre és el mètode:

Pas Pregunta Eines Lliçó
1. Mesurar On se'n va el temps/la memòria? perf_counter, timeit, cProfile, tracemalloc 05-01, 05-02
2. Algorisme L'estratègia i l'estructura són les correctes? Mòduls 2–4 (anàlisi, disseny, clàssics) M2–M4
3. Codi La implementació malbarata feina? jerarquia: hoisting, lru_cache, idiomàtic 05-01
4. Memòria Materialitza o reté de més? generadors, lots, __slots__, representació 05-02
5. Paral·lelitzar Queda càlcul repartible que ho justifiqui? concurrent.futures; Amdahl com a sostre 05-03

Cada pas multiplica els següents: paral·lelitzar (pas 5) un algorisme equivocat (pas 2) reparteix l'error entre 8 nuclis; i optimitzar la part seqüencial (passos 3–4) és el que apuja el sostre d'Amdahl del pas 5. I tot comença i acaba mesurant: la mesura inicial diu on actuar i la final demostra que ha servit.

Errors Comuns i Consells

  • Usar fils per accelerar càlcul en CPython. L'error clàssic del GIL: 8 fils calculant rendeixen com 1 (o pitjor). Fils per esperar, processos per calcular.
  • Oblidar if __name__ == "__main__": amb processos. A Windows produeix errors d'arrencada o una cascada de processos. Hi va sempre, sense excepcions.
  • Passar dades enormes o no picklables als workers. Una lambda com a funció de treball falla; un graf de 100 MB com a argument per tasca converteix la CPU guanyada en serialització perduda. Funcions de mòdul i arguments lleugers.
  • Paral·lelitzar abans d'optimitzar en sèrie. Multiplicar per 4 un codi 100 vegades més lent del necessari és quedar-se 25 vegades per sota de la versió en sèrie ben escrita. Passos 1–4 primer.
  • Protegir de més o de menys. Sense Lock, resultats corruptes i intermitents (els pitjors bugs de reproduir); amb un Lock al voltant de tot, un programa seqüencial disfressat de paral·lel. La sortida bona sol ser redissenyar per no compartir estat.
  • Consell: cronometra sempre les tres versions — sèrie, fils, processos — amb dades realistes, com hem fet amb les APIs de trànsit. La taula de tres números decideix sola, i de vegades la guanyadora és la versió en sèrie.

Exercicis

Exercici 1. Classifica aquestes tasques de RutaBus com a CPU-bound o I/O-bound i assigna a cadascuna l'eina adequada (ThreadPoolExecutor, ProcessPoolExecutor, o "en sèrie, no compensa"), justificant-ho en una línia: (a) geocodificar 2.000 adreces cridant un servei web extern; (b) comprimir els 365 fitxers de fitxatges de l'any (compressió = càlcul intens); (c) validar 500 fitxatges contra un set en memòria; (d) executar la simulació de demanda amb 12 escenaris de paràmetres independents.

Exercici 2. El recàlcul nocturn de RutaBus triga 200 s: 20 s de càrrega i escriptura (seqüencial) i 180 s de Dijkstras independents (paral·lelitzable). (a) Calcula p i l'acceleració amb 4 i amb 16 nuclis segons Amdahl, i el sostre amb infinits. (b) Un company redueix la càrrega de 20 s a 5 s aplicant 05-02 (lectura per generador). Recalcula el sostre. Què ensenya això sobre l'ordre del mètode?

Exercici 3. Aquest codi paral·lel amb fils registra les parades saturades detectades per 4 analitzadors concurrents, i de vegades perd avisos. Explica la condició de cursa exacta i dona dues solucions diferents: una amb Lock i una altra sense estat compartit (redisseny amb ThreadPoolExecutor.map).

avisos = []
def analitza(zona):
    for parada in zona:
        if parada["ocupacio"] > 0.9:
            avisos.append(f"Saturada: {parada['nom']}")

Solucions

Solució 1. (a) I/O-boundThreadPoolExecutor: 2.000 esperes de xarxa que se solapen; el GIL s'allibera durant cada crida. (b) CPU-boundProcessPoolExecutor: comprimir és càlcul pur i els 365 fitxers són independents (vergonyosament paral·lel). (c) En sèrie: 500 consultes O(1) a un set són microsegons; qualsevol piscina costa més que la feina (sobrecost > guany). (d) CPU-boundProcessPoolExecutor: 12 simulacions independents, ideal per a map; amb més de 12 nuclis, el límit passa a ser el nombre d'escenaris.

Solució 2. (a) p = 180/200 = 0,90. S(4) = 1/(0,1 + 0,9/4) = 3,08x (65 s); S(16) = 1/(0,1 + 0,9/16) = 6,4x (31 s); sostre S(∞) = 1/0,1 = 10x (20 s: la part seqüencial sencera). (b) Amb 5 s seqüencials: p = 180/185 ≈ 0,973 → sostre 1/(5/185) = 37x, i S(16) puja a ~11,6x. Moralitat: optimitzar la part seqüencial amb les tècniques de 05-01/05-02 ha elevat el sostre de 10x a 37x — més del que mai no donaria afegir nuclis amb el sostre antic. Els passos 3–4 del mètode van abans que el 5 també per matemàtiques, no només per prudència.

Solució 3. La cursa és a avisos.append(...) des de 4 fils: encara que cada append individual és atòmic en CPython, el patró general d'acumular en estructures compartides des de diversos fils és fràgil (n'hi ha prou de canviar a avisos += [...], o a un comptador, per perdre actualitzacions: llegir-modificar-escriure entrellaçat). Solució 1, forrellat:

avisos, forrellat = [], threading.Lock()
def analitza(zona):
    for parada in zona:
        if parada["ocupacio"] > 0.9:
            with forrellat:
                avisos.append(f"Saturada: {parada['nom']}")

Solució 2, sense estat compartit (preferible): cada tasca retorna els seus avisos i el fil principal els ajunta — no hi ha res a protegir perquè no hi ha res compartit:

def analitza(zona):
    return [f"Saturada: {p['nom']}" for p in zona if p["ocupacio"] > 0.9]

with ThreadPoolExecutor(max_workers=4) as pool:
    avisos = [a for lot in pool.map(analitza, zones) for a in lot]

És el patró de tota la lliçó: repartir l'entrada, retornar resultats, agregar al final.

Conclusió

Aquest mòdul ha recorregut l'ofici complet de convertir un bon algorisme en un programa ràpid: mesurar abans de tocar res (05-01), esprémer el codi per nivells d'impacte, domar la memòria quan és ella la que escasseja (05-02), i per últim — només per últim — repartir el càlcul entre nuclis, amb els fils per a les esperes, els processos per al càlcul, la llei d'Amdahl com a sostre i els costos de serialització i sincronització com a lletra petita. El mètode queda destil·lat en cinc passos que convé recitar en ordre: mesurar → algorisme → codi → memòria → paral·lelitzar. Amb això, el curs ha completat el seu arsenal: sabem analitzar (M1–M2), dissenyar (M3), reconèixer els clàssics (M4) i optimitzar (M5). El que encara no s'ha completat és la soltesa, i aquesta no es llegeix: s'entrena. El Mòdul 6 és exactament això — bateries d'exercicis de complexitat, de disseny i d'optimització, i uns projectes finals que ajunten totes les peces sobre RutaBus —, perquè la diferència entre conèixer aquestes eines i pensar amb elles es tanca practicant, i és allà on anem ara.

© Copyright 2026. Tots els drets reservats