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. Ejercicio 1 — La carrera del contador
  2. Ejercicio 2 — GIL: CPU vs I/O
  3. Ejercicio 3 — La ventana check-then-act
  4. Ejercicio 4 — I1 con peticiones reales
  5. Ejercicio 5 — Deadlock en miniatura
  6. Ejercicio 6 — Async o sync
  7. Ejercicio 7 — Mapa de concurrencia
  8. Resumen del profesor

Después de intentarlo, aquí está el mapa completo. Lo que no hayas sufrido, léelo dos veces.


Ejercicio 1 — La carrera del contador

  1. Resultados típicos: 100.137, 152.403, 178.922, 199.211... nunca (o casi nunca) 200.000 exactos. Varían porque el planificador interrumpe los hilos en puntos distintos en cada ejecución: la carrera es no determinista, y esa es justamente su maldad (no se reproduce con un breakpoint).
  2. Con threading.Lock() alrededor del counter += 1, el resultado es siempre 200.000: la sección crítica (leer-sumar-escribir) se vuelve atómica respecto a otros hilos.
  3. Con lock, el bucle se ralentiza bastante (adquisición por iteración + contención de dos hilos peleándose por el lock). Nota fina: si agrupas el lock por bloques (1000 incrementos por adquisición), el coste cae — es la idea del lock granularity que reaparece en transacciones.

Ejercicio 2 — GIL: CPU vs I/O

  1. CPU: dos hilos tardan aproximadamente lo mismo (o más, por el overhead de cambio de contexto) que un hilo con el doble de trabajo. No escaló: el GIL serializa el bytecode.
  2. I/O (sleep): dos hilos tardan ~1 s en total, no 2. Sí escaló: sleep libera el GIL (como libera el socket al esperar a la BD).
  3. "El GIL impide el paralelismo de CPU entre hilos, no la concurrencia de I/O; para CPU, procesos".

Ejercicio 3 — La ventana check-then-act

  1. Con 20 hilos y sleep de 1 ms se crean varias reservas (típicamente 10-20: casi todos los hilos entran a la vez). Con sleep de 0.01 son más; con 0.0001, menos pero aún >1. La moraleja: la probabilidad de desastre crece con la duración de la ventana y con el número de hilos — exactamente lo que pasa con la carga real.
  2. (a) El lock en memoria arregla el experimento... pero solo dentro del proceso: con 4 workers de Gunicorn hay 4 "memorias" y el lock no ve a los otros. (b) La operación atómica única en BD es una constraint única (la que ya tienes en ReservationItem) aplicada dentro de una transacción con bloqueo (Lección 10: select_for_update o el INSERT fallando con IntegrityError). La memoria del proceso no escala; el estado compartido se defiende donde vive: en la BD.

Ejercicio 4 — I1 con peticiones reales

  1. Lo esperado: una petición 201 y la otra 409 (o un 500 si la vista no captura la excepción). La constraint de la 00b hizo imposible la corrupción; la pregunta es si tu API responde con la semántica correcta. Un IntegrityError sin capturar se convierte en 500 "bug del servidor" cuando en realidad el servidor funcionó perfectamente: protegió el negocio.
  2. El diff debería incluir algo así:
python
from django.db import IntegrityError
from rest_framework import status

@api_view(["POST"])
def reserve(request):
    ...
    try:
        item = ReservationItem.objects.create(...)
    except IntegrityError:
        return Response(
            {"code": "seat_already_reserved", "detail": "El asiento acaba de reservarse"},
            status=status.HTTP_409_CONFLICT,
        )
    return Response(..., status=status.HTTP_201_CREATED)

Detalle senior: el mensaje no dice "error": dice qué pasó en el dominio. Y el 409 de la Lección 01, Ejercicio 2, se materializa aquí — la teoría y el modelo se abrazan.

Ejercicio 5 — Deadlock en miniatura

  1. Con op2() en orden inverso y la ventana de sleep, el programa se cuelga: op1 retiene A y espera B; op2 retiene B y espera A. Espera circular: condición 4 de Coffman cumplida. (Sin el sleep, quizá ni lo ves en 100 ejecuciones: los deadlocks son intermitentes por naturaleza — otro regalo para producción.)
  2. Con orden global A → B en todas las funciones, desaparece la espera circular y el programa termina siempre: la única condición rompible baratamente de las cuatro de Coffman es la circular. En la BD real: actualizar filas siempre ordenadas (por ejemplo ORDER BY id o IDs ascendentes al tocar asientos de una reserva).

Ejercicio 6 — Async o sync

  1. Sync. La query es I/O pero corto y no paralelizable dentro de la petición; async no aporta nada aquí y complica el stack (drivers, testing).
  2. Sync. Verificar firma es CPU barato y encolar es una operación rápida (RPUSH en Redis). El trabajo pesado ya se fue a Celery: la vista debe ser rápida, y lo es.
  3. Async. Tres llamadas externas independientes con asyncio.gather() bajan la latencia de ~suma a ~la más lenta. Este es el caso de uso de async: I/O externo paralelizable.
  4. Ni sync ni async: así no es. Un CSV de 2 GB en memoria mata al worker y bloquea (async incluido: CPU pura). Respuesta senior: 202 Accepted + job en Celery + descarga cuando esté (lo diseñaste en la Lección 01, Ejercicio 7).

Ejercicio 7 — Mapa de concurrencia

Configuración defendible para 2 vCPU / 4 GB:

Gunicorn --workers 2 --threads 4      # 2 procesos (paralelismo CPU) × 4 hilos (concurrencia I/O)
Celery --concurrency 2 (cola "pagos") # serializa pasarela/webhooks, aislada de la web
Redis                                 # cache + broker (una sola pieza más que vigilar)
PostgreSQL (misma región)             # invariante I1 vive aquí, no en Python

Justificación: los workers no deben superar vCPU para trabajo CPU, y los hilos cubren esperas de I/O (la métrica real es la memoria por worker y la latencia p95 bajo carga, no un número mágico). La cola aparte evita que un pico de correos no retrase los pagos. Si tu propuesta difiere con argumentos (p. ej. --workers 4 con hilos 2 por I/O alto), también es correcta: lo que se evalúa es el porqué.


Resumen del profesor

  • Concurrency ≠ parallelism; y el GIL no es el monstruo de la historia: solo muerde la CPU.
  • Toda ventana check-then-act es una carrera potencial; la defensa donde vive el estado: la BD.
  • Orden global de adquisición mata los deadlocks antes de nacer.
  • Async cuando hay I/O externo paralelizable; sync cuando no; colas cuando hay que serializar.

Cuando entregues, cerramos la 03 y pasamos a la Lección 04 — Estructuras de datos y algoritmos: el carrito de asientos de TicketFlow como excusa para hablar de complejidad que se nota en producción.