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
- Concurrència i paral·lelisme: no són el mateix
- Classificar abans de repartir: CPU-bound contra I/O-bound
- El GIL de Python, explicat sense embuts
- Fils per a I/O-bound:
ThreadPoolExecutor - Processos per a CPU-bound:
ProcessPoolExecutor - Què es paral·lelitza bé: problemes vergonyosament paral·lels
- La llei d'Amdahl: el sostre del guany
- Costos ocults i quan NO paral·lelitzar
- Condicions de cursa i
Lock - 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) | Sí |
| 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 sQuè està passant, línia a línia:
ThreadPoolExecutor(max_workers=10)crea una piscina de 10 fils reutilitzables; elwithgaranteix que s'espera tots els fils i es tanquen en sortir.pool.map(f, dades)és elmapde 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
pickleentre processos (no comparteixen memòria). Això té dues conseqüències: les funcions i les dades han de ser picklables (unalambdano 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
chunksizeapool.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'
insobre 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 diferentcomptador += 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, sempreDues observacions i tanquem, perquè els sistemes distribuïts queden fora d'aquest curs:
- El
Lockre-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
mapho 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
lambdacom 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 unLockal 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-bound → ThreadPoolExecutor: 2.000 esperes de xarxa que se solapen; el GIL s'allibera durant cada crida. (b) CPU-bound → ProcessPoolExecutor: 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-bound → ProcessPoolExecutor: 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.
Curs d'Anàlisi i Disseny d'Algorismes
Mòdul 1: Introducció als Algorismes
Mòdul 2: Anàlisi d'Algorismes
- Anàlisi de Complexitat Temporal
- Anàlisi de Complexitat Espacial
- Casos de Complexitat: Millor, Pitjor i Mitjà
Mòdul 3: Estratègies de Disseny d'Algorismes
Mòdul 4: Algorismes Clàssics
- Cerca Binària
- Ordenació per Inserció
- Ordenació per Mescla (Merge Sort)
- Ordenació Ràpida (Quick Sort)
- Algorisme de Dijkstra
- Algorisme de Floyd-Warshall
