Módulo 1 · Fundamentos que sostienen todo

Lección 03 — Concurrencia y asincronía

Hilos, procesos, event loop, condiciones de carrera y deadlocks — con la cola de compra de TicketFlow.

Publicada
En esta lección
  1. Objetivos
  2. 1. Concurrency no es parallelism (empieza por el vocabulario)
  3. 2. El GIL: la regla del juego en CPython
  4. 3. La condición de carrera: el bug que no se ve en demo
  5. 4. Deadlocks: el abrazo mortal
  6. 5. Async en Django: qué resuelve y qué no
  7. 6. Mapa de concurrencia de TicketFlow (cierra el círculo)
  8. Autoevaluación (respóndeme en el chat)

Stack: Django + DRF · Proyecto: TicketFlow Estado: Publicada — impartición tras corregir la 02 Prerrequisito: Lección 02 — Redes básicas


Objetivos

Al terminar esta lección podrás:

  1. Explicar la diferencia entre proceso, hilo y event loop — y qué modelo usa tu stack en cada capa.
  2. Demostrar una condición de carrera con código (la misma que puede vender dos veces un asiento).
  3. Razonar sobre el GIL de Python: qué bloquea y qué no, y por qué Django sigue escalando con hilos.
  4. Decidir cuándo una vista async (ASGI) aporta y cuándo es ruido.
  5. Reconocer un deadlock antes de crearlo, con reglas de adquisición de bloqueos.

1. Concurrency no es parallelism (empieza por el vocabulario)

  • Concurrencia: el programa gestiona varias tareas a la vez (entrelazándolas). Una cafetera con dos colas y un barista.
  • Paralelismo: el programa ejecuta varias tareas al mismo instante físico. Dos baristas.

Un servidor web necesita concurrencia siempre (mientras tu vista espera a la BD, otro request debe poder atenderse) y paralelismo cuando hay CPU real (parsear un CSV grande).

Los tres modelos que existen:

ModeloQuién decide el turnoEjemplo en tu stack
ProcesosEl SO (preemptivo)workers de Gunicorn (--workers 4)
HilosEl SO (preemptivo)hilos dentro de cada worker, workers de Celery
Event loopEl programa (cooperativo)vistas async de Django sobre ASGI (Uvicorn), Celery con gevent

Clave: los modelos se combinan. Gunicorn: 4 procesos × 2 hilos. Uvicorn: 1 proceso con event loop + workers para lo bloqueante. Celery: procesos por concurrencia prefork o hilos con --pool=threads.

2. El GIL: la regla del juego en CPython

El GIL (Global Interpreter Lock) permite que solo un hilo ejecute bytecode Python a la vez. Consecuencias:

  • Dos hilos de Python no paralelizan trabajo de CPU (aunque tengas 8 núcleos).
  • Las operaciones de I/O (red, disco, BD) liberan el GIL mientras esperan: por eso los hilos sí sirven para servidores web, cuyo tiempo se va esperando.
  • Las extensiones en C (psycopg2, lxml, numpy) liberan el GIL en sus partes pesadas.

Entonces, ¿cómo escala Django si el GIL impide paralelismo por hilo? Con la combinación correcta en cada capa:

Gunicorn --workers 4        ← paralelismo real (4 procesos, GIL por proceso)
         hilos/async        ← concurrencia barata dentro de cada proceso
         BD y Redis fuera   ← el estado compartido vive en otro proceso (¡y por eso hay que sincronizar!)

Clave de entrevista: "el GIL impide paralelismo de CPU entre hilos, no concurrencia de I/O". Y para CPU: procesos o workers más finos. (CPython 3.13 experimenta con el free-threaded build; en 2026 sigue siendo la excepción, no el estándar de producción.)

3. La condición de carrera: el bug que no se ve en demo

Una carrera ocurre cuando el resultado depende del entrelazado de dos flujos. Patrón clásico check-then-act:

python
def reserve(seat_id, user):
    seat = Seat.objects.get(pk=seat_id)
    if seat.is_reserved:                      # CHECK
        raise Conflict("ocupado")
    ReservationItem.objects.create(           # ACT
        seat=seat, reservation=..., price_at_purchase=...)

Entre el CHECK y el ACT hay una ventana de milisegundos. Dos peticiones concurrentes pueden ambas leer is_reserved=False y ambas crear su ítem. En tu portátil con un solo usuario nunca pasa; con 500 usuarios el día del concierto, pasa cada hora.

Demostración controlada (la harás en los ejercicios):

python
import threading

counter = 0

def increment():
    global counter
    for _ in range(100_000):
        counter += 1          # leer, sumar, escribir: tres pasos, no uno

t1 = threading.Thread(target=increment)
t2 = threading.Thread(target=increment)
t1.start(); t2.start(); t1.join(); t2.join()
print(counter)                # ¿200_000? Ejecútalo varias veces...

counter += 1 no es atómico: son tres operaciones (leer, sumar, escribir) y el planificador puede interrumpir entre ellas. Con dos hilos, el resultado sale por debajo de 200.000 y distinto en cada ejecución. Eso es una carrera: ni determinista ni reproducible con fiabilidad.

Las defensas, de más barata a más fuerte:

  1. Nada compartido mutable (aísla: una reserva la procesa un solo worker).
  2. Locks en memoria (threading.Lock) — solo sirve dentro de un proceso: con 4 workers de Gunicorn no te protege. Error clásico.
  3. Atomicidad en la BD: update atómicos, constraints únicas, transacciones con bloqueo (Lección 10).
  4. Colas: serializa el acceso (un job Celery por reserva; el productor-consumidor hace el resto).

Clave: tu invariante I1 de TicketFlow (un asiento, una reserva activa) ya está garantizada por constraints parciales (Lección 00b, ADR-0004). Esta lección te enseña a reconocer todas las ventanas donde el código puede intercalarse, aunque la BD tape las más caras.

4. Deadlocks: el abrazo mortal

Un deadlock necesita cuatro condiciones simultáneas (Coffman): exclusión mutua, retención y espera, no expropiación y espera circular. El caso típico en BD:

Transacción A: UPDATE seats WHERE id=1 → luego UPDATE seats WHERE id=2
Transacción B: UPDATE seats WHERE id=2 → luego UPDATE seats WHERE id=1

Cada una retiene su primer bloqueo y espera el que la otra retiene: nadie avanza hasta que la BD mata a una (PostgreSQL lanza Deadlock detected y hace rollback de una transacción; tu código debe reintentarla).

Reglas del oficio para no crearlos:

  1. Orden global de adquisición: todos los recursos (filas, locks) se toman en el mismo orden (por ejemplo, siempre por id ascendente). Rompe la espera circular.
  2. Transacciones cortas: dentro de una transacción, ni red ni llamadas lentas (nunca llamar a la pasarela de pago dentro de la transacción de reserva — lo serio llega en la Lección 54).
  3. Un solo lock si basta: agrupa la adquisición (lock de grano grueso cuando la contención es baja).
  4. Timeout + reintento: asume que puede pasar y responde con reintentos idempotentes (Lección 14).

5. Async en Django: qué resuelve y qué no

Django 5 soporta vistas async def servidas por Uvicorn/Daphne (ASGI). El event loop atiende miles de conexiones con un solo hilo si las esperas son asíncronas (await): una vista que espera a la BD con sync_to_async o drivers async no bloquea a las demás.

Aporta cuando: tu vista hace I/O paralelizable (llamar a dos APIs externas a la vez con asyncio.gather, webhooks, agregadores de pago) o tienes muchísimas conexiones lentas (SSE/WebSockets — Lección 16).

No aporta cuando: la vista es CPU (el event loop es un solo hilo: bloquea a todos), o usas librerías síncronas sin envolverlas (un requests.get en una vista async bloquea el event loop entero: peor que sync).

sync (Gunicorn):    1 request → 1 worker/hilo ocupado
async (Uvicorn):    1 request → "await" libre → el loop atiende otros

Regla práctica para TicketFlow: el proyecto vive feliz con Gunicorn sync + Celery. Async entra cuando llegue el streaming de disponibilidad (Lección 16) o las llamadas paralelas a pasarelas. Elegir por necesidad medible, no por moda.

6. Mapa de concurrencia de TicketFlow (cierra el círculo)

CapaModeloQué protege
Gunicorn workersprocesosparalelismo CPU; crash isolation
Hilos dentro del workerhilosI/O simultánea por request
PostgreSQLlocks + transaccionesinvariante I1 (asiento no vendible dos veces)
Redisatómico single-threadedcontadores, rate limits
Celeryprocesos/hilos por colaserializar trabajos lentos (pagos, correos)

Cada capa sincroniza lo suyo. El error de arquitectura es mezclarlas: un lock de Python no protege contra otro proceso; una constraint de BD no acelera una cola; un rate limit en Redis no sustituye una transacción.


Autoevaluación (respóndeme en el chat)

  1. Con 4 workers de Gunicorn, ¿te protege un threading.Lock contra dos peticiones concurrentes que incrementan el mismo contador? ¿Por qué no, y qué usarías?
  2. ¿Por qué el patrón check-then-act falla aunque el CHECK sea el primer statement? ¿Qué parte del sistema garantiza la atomicidad que falta?
  3. Una vista async llama a requests.get(...) (síncrono). ¿Qué pasa con el event loop y con las demás peticiones? ¿Cómo se arregla?
  4. Diseña el orden de adquisición correcto para una operación que toca la fila del asiento 1051 y la fila del usuario 7. ¿Qué regla aplicas?
  5. ¿Cuándo dirías "a TicketFlow le toca ASGI"? Nombra el gatillo concreto del proyecto.

Continúa con los ejercicios. Las solutions.md solo tras intentarlo.