Papyrus es vol modernitzar: abans de reposar estoc, l'Anna consulta el preu de cada títol a tres distribuïdores. Cada consulta triga uns 2 segons (xarxa, servidors lents), i el programa es queda aturat esperant: 3 consultes × 2 s = 6 segons mirant el cursor. El mateix passa en baixar les portades dels llibres nous. L'observació clau és que durant aquests 2 segons Python no fa res: espera. La concurrència consisteix a aprofitar aquestes esperes per fer avançar altres tasques. En aquesta lliçó: fils, el famós GIL explicat amb honestedat, les condicions de carrera (amb un comptador de vendes que es corromp en directe), i processos per a quan el problema no és esperar sinó calcular. Una nota abans de començar: simularem les consultes amb time.sleep() — és la manera estàndard d'aprendre concurrència sense dependre de servidors reals; les peticions HTTP de debò arriben al mòdul 10.

Contingut

  1. Seqüencial, concurrent i paral·lel
  2. threading.Thread: el primer fil
  3. El GIL, explicat honestament
  4. Condicions de carrera: el comptador que perd vendes
  5. Lock: zona d'un sol fil
  6. concurrent.futures.ThreadPoolExecutor: l'API recomanada
  7. Processos per a CPU: multiprocessing i ProcessPoolExecutor
  8. La taula de decisió (inclosa l'opció "no necessites concurrència")

Seqüencial, concurrent i paral·lel

Tres paraules que es confonen i no volen dir el mateix:

  • Seqüencial: una tasca rere l'altra. Consulta A, espera; consulta B, espera; consulta C.
  • Concurrent: diverses tasques en curs alhora, encara que només una avanci en cada instant — com l'Anna atenent la Júlia mentre a l'Omar se li imprimeix el tiquet. Aprofita les esperes.
  • Paral·lel: diverses tasques executant-se literalment al mateix temps en nuclis diferents de la CPU. Aprofita el maquinari.
gantt
    dateFormat  s
    axisFormat %S s
    title Consultar 3 distribuïdores (2 s cadascuna)
    section Seqüencial (6 s)
    Distribuïdora A :a1, 0, 2s
    Distribuïdora B :a2, after a1, 2s
    Distribuïdora C :a3, after a2, 2s
    section Concurrent amb fils (~2 s)
    Distribuïdora A :b1, 0, 2s
    Distribuïdora B :b2, 0, 2s
    Distribuïdora C :b3, 0, 2s

Les tres esperes se solapen: el total passa de la suma (6 s) al màxim (2 s).

threading.Thread: el primer fil

Un fil és una línia d'execució dins del mateix procés: comparteix memòria (el mateix cataleg, les mateixes variables) amb els altres fils.

import threading
import time

def consultar_preu(distribuidora: str, titol: str) -> None:
    print(f"→ consultant {titol} a {distribuidora}...")
    time.sleep(2)                       # simula l'espera de xarxa (E/S)
    print(f"← {distribuidora} respon")

inici = time.perf_counter()

fils = [
    threading.Thread(target=consultar_preu, args=(d, "Faust"))
    for d in ("Distribuidora A", "Distribuidora B", "Distribuidora C")
]
for fil in fils:
    fil.start()    # engega el fil: la funcio comenca a executar-se "en paralel"
for fil in fils:
    fil.join()     # espera que aquest fil acabi

print(f"Total: {time.perf_counter() - inici:.1f} s")   # Total: 2.0 s (no 6.0)

Dos detalls de l'API: a target se li passa la funció sense cridar (l'objecte funció, com als decoradors de 08-02) i els arguments a part, a args; i join() bloqueja fins que el fil acabi — sense els join(), el programa principal podria acabar abans que les consultes.

El GIL, explicat honestament

Aquí toca la conversa que molts tutorials esquiven. CPython (el Python que fas servir) té el GIL (Global Interpreter Lock): un cadenat global que permet que només un fil executi bytecode Python en cada instant. Encara que el teu portàtil tingui 8 nuclis, dos fils Python no executen codi Python alhora.

Llavors, per què l'exemple anterior ha trigat 2 s i no 6? Perquè el GIL es deixa anar durant les operacions d'E/S: mentre un fil espera a time.sleep(), en una lectura de disc o en una resposta de xarxa, allibera el GIL i un altre fil avança. Les esperes sí que se solapen; el càlcul no.

Tipus de tasca Els fils ajuden? Per què
E/S (xarxa, disc, esperes) El GIL s'allibera en esperar; les esperes se solapen
CPU pura (càlculs, bucles grans) No (fins i tot una mica pitjor) El GIL deixa avançar un sol fil; els altres fan cua

Demostració amb CPU pura — sumar 10 milions de números en 2 fils triga el mateix o més que en un:

def calcular():                 # CPU pura: sense esperes a solapar
    return sum(range(10_000_000))

# 1 fil: ~0.4 s · 2 fils amb threading: ~0.4 s tambe (o mes, per la gestio!)

Conclusió honesta: en Python, els fils són per esperar diverses coses alhora, no per calcular més ràpid. Per calcular en paral·lel hi ha una altra eina (processos, més avall). Nota al marge: el 2026 existeixen builds experimentals de CPython sense GIL, però el Python que tens instal·lat funciona com s'acaba de descriure.

Condicions de carrera: el comptador que perd vendes

Compartir memòria entre fils té un preu. Suposa que cada consulta registra vendes en un comptador global:

comptador_vendes = 0

def registrar_vendes(n: int) -> None:
    global comptador_vendes
    for _ in range(n):
        comptador_vendes += 1     # sembla atomic... i NO ho es

fils = [threading.Thread(target=registrar_vendes, args=(100_000,)) for _ in range(2)]
for fil in fils: fil.start()
for fil in fils: fil.join()

print(comptador_vendes)   # esperat: 200000 · obtingut: 200000... o 173942, segons el dia

comptador_vendes += 1 són en realitat tres passos: llegir el valor, sumar-hi 1, escriure'l. Si el fil A llegeix 50, el sistema canvia al fil B, que també llegeix 50, suma i escriu 51... i després A escriu el seu 51: una venda s'ha perdut. És una condició de carrera (race condition): el resultat depèn de l'ordre atzarós en què s'intercalen els fils. És el pitjor tipus de bug — no falla sempre, no deixa rastre a papyrus.log, i desapareix quan intentes reproduir-lo.

Lock: zona d'un sol fil

La solució és un cadenat que converteixi llegir-sumar-escriure en una operació indivisible. I la manera de fer-lo servir et sonarà de la lliçó anterior — un Lock és un gestor de context:

comptador_vendes = 0
forrellat = threading.Lock()

def registrar_vendes(n: int) -> None:
    global comptador_vendes
    for _ in range(n):
        with forrellat:               # __enter__ adquireix; __exit__ allibera SEMPRE
            comptador_vendes += 1

Mentre un fil és dins del with forrellat:, qualsevol altre que arribi es queda esperant a la porta. Resultat: 200000, sempre. El cost: aquesta zona torna a ser seqüencial — com més gran sigui el bloc protegit, menys concurrència queda. Protegeix el mínim imprescindible.

ThreadPoolExecutor: l'API recomanada

Crear fils a mà està bé per aprendre, però l'API moderna és concurrent.futures: un pool (equip) de fils reutilitzables al qual lliures tasques. A més — i això els Thread a pèl no ho fan — recull els valors de retorn i et rellança les excepcions on puguis atrapar-les amb les anelles de M7:

from concurrent.futures import ThreadPoolExecutor, as_completed

def consultar_preu(distribuidora: str, titol: str) -> tuple[str, float]:
    time.sleep(2)                              # E/S simulada, recorda
    preus = {"Distribuidora A": 19.90, "Distribuidora B": 21.50,
             "Distribuidora C": 20.10}
    return distribuidora, preus[distribuidora]

distribuidores = ["Distribuidora A", "Distribuidora B", "Distribuidora C"]

# Opcio 1 — map: resultats en l'ORDRE d'enviament (API gairebe identica a map() de M3)
with ThreadPoolExecutor(max_workers=3) as pool:      # un altre context manager (08-04)
    for dist, preu in pool.map(lambda d: consultar_preu(d, "Faust"),
                               distribuidores):
        print(f"{dist}: {preu} €")

# Opcio 2 — submit + as_completed: resultats A MESURA que arriben
with ThreadPoolExecutor(max_workers=3) as pool:
    futurs = [pool.submit(consultar_preu, d, "Faust") for d in distribuidores]
    for futur in as_completed(futurs):
        try:
            dist, preu = futur.result()    # aqui reapareix l'excepcio, si n'hi ha hagut
            print(f"{dist}: {preu} €")
        except OSError:
            logger.exception("una distribuidora ha fallat")   # el patro de 07-05
  • submit(f, *args) retorna un futur: un rebut per un resultat que arribarà.
  • as_completed els lliura a mesura que acaben — la distribuïdora ràpida no espera la lenta.
  • futur.result() retorna el retorn o rellança l'excepció que el fil ha patit: res no es perd en silenci.
  • El with del pool espera que totes les tasques acabin abans de sortir: __exit__ fent de join() col·lectiu.

Processos: paral·lelisme de debò per a CPU

I quan el coll d'ampolla sí que és el càlcul? Imagina que l'informes/ anual de Papyrus exigeix processar estadístiques pesades per any. Un procés és un Python complet i independent, amb la seva pròpia memòria i el seu propi GIL — per tant, diversos processos sí que calculen en paral·lel en nuclis diferents:

from concurrent.futures import ProcessPoolExecutor

def generar_informe_pesat(exercici: int) -> tuple[int, float]:
    total = sum(i * i for i in range(20_000_000))   # CPU pura (simulada)
    return exercici, total

if __name__ == "__main__":                # OBLIGATORI amb processos a Windows
    with ProcessPoolExecutor() as pool:   # per defecte, un proces per nucli
        for exercici, total in pool.map(generar_informe_pesat, [2023, 2024, 2025, 2026]):
            print(f"informe {exercici} llest")

La mateixa API que ThreadPoolExecutor — només canvia la classe — i aquí rau la seva elegància: proves amb fils, i si el problema resulta ser de CPU, canvies una paraula. Els costos dels processos: engegar-los és lent, no comparteixen memòria (els arguments i retorns es serialitzen per viatjar entre processos, en un procés semblant al json de M6 — res de compartir el cataleg alegrement), i l'if __name__ == "__main__": de M3 deixa de ser opcional: sense ell, a Windows cada procés fill reimporta el mòdul i rellança processos sense fi.

La taula de decisió

La teva situació Eina Per què
Moltes esperes de xarxa/disc (consultes, baixades de portades) ThreadPoolExecutor El GIL s'allibera en esperar; les esperes se solapen
Càlcul intensiu (informes pesats, estadístiques) ProcessPoolExecutor Cada procés té el seu GIL: paral·lelisme real
Milers de connexions simultànies asyncio (propera lliçó) Els fils no escalen a aquests números
Triga poc, s'executa un cop, o el codi és simple Res: seqüencial La concurrència afegeix bugs subtils; no la paguis sense necessitat

L'última fila va de debò: el 90 % del codi de Papyrus — vendre un llibre, desar el catàleg, el tancament diari — és i ha de continuar sent seqüencial. La concurrència s'afegeix on hi ha una espera mesurada que fa mal, no per defecte.

Errors Comuns i Consells

  • Fer servir fils per accelerar càlcul pur: el GIL ho impedeix; obtindràs el mateix temps amb més complexitat. Càlcul → processos.
  • Oblidar join() (o el with del pool): el programa principal segueix el seu curs i pot acabar amb consultes a mitges.
  • Compartir estat mutable sense Lock: comptadors, llistes o el cataleg tocats des de diversos fils són condicions de carrera latents. Millor encara que el Lock: que cada tasca retorni el seu resultat i el fil principal els combini — els futurs existeixen per a això.
  • if __name__ == "__main__": absent amb ProcessPoolExecutor: a Windows produeix un error d'arrencada (o una cascada de processos). És l'error número u amb processos.
  • Ignorar les excepcions dels futurs: si mai no crides futur.result(), l'excepció del fil mor en silenci — el pecat de 07-04 en versió concurrent. Crida sempre result() dins d'un try.
  • Consell: mesura abans i després amb el @cronometrar de 08-02 o el cronometre de 08-04. Si la versió concurrent no guanya clarament, torna a la seqüencial.

Exercicis

  1. Escriu consultar_totes(titol) que consulti les tres distribuïdores amb ThreadPoolExecutor.map i retorni la més barata com a tupla (distribuidora, preu). Fes servir la consultar_preu de la lliçó (amb el seu time.sleep(2)) i comprova amb time.perf_counter() que el total ronda els 2 s.

  2. Sense executar-lo: què imprimeix (aproximadament) aquest codi i per què? Quina única paraula canviaries per arreglar-lo?

    def digerir_estadistiques(dades):
        return sum(x * x for x in dades)      # CPU pura
    
    with ThreadPoolExecutor(max_workers=4) as pool:
        resultats = list(pool.map(digerir_estadistiques, quatre_blocs_grans))
    
  3. Dos fils reposen estoc del mateix Llibre amb llibre.estoc += n. Explica la condició de carrera pas a pas (amb valors concrets: estoc inicial 4, dues reposicions de 3) i arregla-la amb un Lock, protegint el mínim codi possible.

Solucions

  1. from concurrent.futures import ThreadPoolExecutor
    import time
    
    def consultar_totes(titol: str) -> tuple[str, float]:
        distribuidores = ["Distribuidora A", "Distribuidora B", "Distribuidora C"]
        inici = time.perf_counter()
        with ThreadPoolExecutor(max_workers=3) as pool:
            resultats = list(pool.map(lambda d: consultar_preu(d, titol),
                                      distribuidores))
        print(f"consultat en {time.perf_counter() - inici:.1f} s")   # ~2.0 s
        return min(resultats, key=lambda r: r[1])    # el sorted key de M3
    

    Tres esperes de 2 s solapades ≈ 2 s en total; min(key=...) tria la barata.

  2. Imprimeix els resultats correctes, però triga igual (o més) que en seqüencial: digerir_estadistiques és CPU pura i el GIL només deixa avançar un fil cada vegada — els 4 workers fan torns, no paral·lelisme. La paraula a canviar: ThreadPoolExecutorProcessPoolExecutor (recordant l'if __name__ == "__main__":).

  3. Carrera: el fil A llegeix estoc = 4; el sistema canvia a B, que llegeix també 4, suma 3 i escriu 7; A es desperta i escriu el seu resultat, 7. Estoc final: 7 en lloc de 10 — una reposició de Faust s'ha esfumat sense deixar rastre. Arranjament:

    forrellat = threading.Lock()
    
    def reposar_concurrent(llibre, n: int) -> None:
        with forrellat:
            llibre.estoc += n     # NOMES l'operacio llegir-sumar-escriure
    

    El with forrellat: embolica únicament la línia crítica: qualsevol feina prèvia (validar n, logging) queda fora per no seqüencialitzar de més.

Conclusió

Els fils solapen esperes (el GIL es deixa anar en E/S), els processos paral·lelitzen càlcul (cadascun amb el seu GIL), concurrent.futures dona a tots dos la mateixa API amb futurs que transporten resultats i excepcions, i compartir memòria exigeix Lock — que ha resultat ser un gestor de context de la lliçó 08-04 treballant a preu fet. I l'opció més subestimada continua disponible: no fer servir concurrència quan no hi ha cap espera que faci mal. Però queda una pregunta incòmoda: per esperar 3 consultes hem muntat 3 fils, amb el seu Lock, les seves carreres potencials i el seu cost per fil. I si hi ha 500 consultes? 500 fils? Existeix un altre model on un sol fil gestiona totes les esperes tornant-se entre tasques — sense cadenats ni carreres — i es diu asyncio. És l'última lliçó del mòdul, i tanca la visita de Papyrus als temes avançats.

Curs de Programació en Python

Mòdul 1: Introducció a Python

Mòdul 2: Estructures de Control

Mòdul 3: Funcions i Mòduls

Mòdul 4: Estructures de Dades

Mòdul 5: Programació Orientada a Objectes

Mòdul 6: Gestió de Fitxers

Mòdul 7: Gestió d'Errors i Excepcions

Mòdul 8: Temes Avançats

Mòdul 9: Proves i Depuració

Mòdul 10: Desenvolupament Web amb Python

Mòdul 11: Ciència de Dades amb Python

Mòdul 12: Projecte Final

© Copyright 2026. Tots els drets reservats