Concurrencia en la cocina de medianoche
·
Resumen del curso
1
La cola de la primera noche: concurrencia y paralelismo
2
Contratar más cocineros: lo básico de threading
3
La pizarra no cuadra: condiciones de carrera y Lock
4
Solo tres fogones: herramientas de sincronización
5
Cuchillos y tablas: interbloqueo
6
El riel de comandas: queue
7
Cocineros de agencia: concurrent.futures
8
Por qué la masa no va más rápido: el GIL y el paralelismo
9
Una sola encargada de sala: primeros pasos con asyncio
10
Se cae la reserva de grupo: concurrencia estructurada
11
La sala se congela: llamadas bloqueantes y combinar herramientas
12
La noche de la crítica: todo junto
Tiempo 7:30–8:20
La comanda de esta noche
#10 · Décima noche
Problema
Una reserva de grupo de seis mesas. La mesa 3 cancela su menú por un problema de alergia, y el grupo decide volver todo junto otro día. Pero en las otras cinco mesas las ollas siguen hirviendo.
Objetivo
Si falla una mesa, parar al momento el resto de platos; y que ningún plato pase del tiempo fijado.
Restaurante nocturno Dongsi
Diez y media de la noche. Un grupo ocupa seis mesas de golpe. Lou toma los pedidos y los pasa a cocina, y la cocina arranca los seis menús a la vez. Diez minutos después, levantan la mano en la mesa 3: alguien avisó tarde de que tiene alergia a los cacahuetes. La persona que organiza el grupo dice: «Entonces hoy nos vamos todos».
Minho no lo oye y sigue con el guiso de la mesa 5 al fuego. Sua le apaga el fuego y dice: «No había ninguna regla que dijera que, si se cae una mesa, se para todo».
La chef Yoon Seoyeon pulsa el temporizador. «Lo que empieza junto tiene que terminar junto. Esta noche toca prometer eso en código».
Probemos a llevar la reserva de grupo con el asyncio.gather de la lección 9. La mesa 2 falla a los 0,1 segundos.
import asyncio
async def table(n, sec, fail=False):
await asyncio.sleep(sec)
if fail:
raise ValueError(f"T{n} allergy")
print(f"T{n} served")
async def main():
try:
await asyncio.gather(table(1, 0.3), table(2, 0.1, fail=True), table(3, 0.5))
except ValueError as e:
print("caught:", e)
await asyncio.sleep(0.6) # mantenemos el local abierto un rato más
asyncio.run(main())
# caught: T2 allergy
# T1 served
# T3 servedgather deja subir la primera excepción al instante. Por eso caught: sale primero.served.gather(return_exceptions=True) solo mete las excepciones en la lista de resultados; tampoco para al resto. Si lo que quieres es «si falla uno, se para todo», hace falta otra herramienta.
asyncio.TaskGroup (3.11) maneja las tareas como un solo paquete. Esta forma de agruparlas y gestionarlas se llama concurrencia estructurada (structured concurrency).
import asyncio
async def table(n, sec):
await asyncio.sleep(sec)
return f"T{n}"
async def main():
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(table(n, 0.1 * n)) for n in range(1, 4)]
# al llegar aquí, las tres tareas han terminado
print([t.result() for t in tasks])
asyncio.run(main())
# ['T1', 'T2', 'T3']TaskGroup hace tres promesas.
async with, todas las tareas creadas dentro han terminado. No hace falta hacerles await por separado.ExceptionGroup y lo lanza.Como tg.create_task guarda una referencia a cada tarea, tampoco hay que preocuparse de las «tareas que desaparecen al perder la referencia» de la lección 9. Ahora, la reserva de grupo en la que falla la mesa 3.
import asyncio
async def table(n, sec, fail=False):
try:
await asyncio.sleep(sec)
if fail:
raise ValueError(f"T{n} allergy")
print(f"T{n} served")
except asyncio.CancelledError:
print(f"T{n} cancelled")
raise # la cancelación recibida se vuelve a lanzar
async def main():
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(table(1, 0.05))
tg.create_task(table(2, 0.3))
tg.create_task(table(3, 0.1, fail=True))
tg.create_task(table(4, 0.5))
except* ValueError as eg:
print("group failed:", eg.exceptions)
await asyncio.sleep(0.6)
asyncio.run(main())
# T1 served
# T2 cancelled (el orden de T2 y T4 cambia en cada ejecución)
# T4 cancelled
# group failed: (ValueError('T3 allergy'),)served más. A diferencia de gather, no queda ninguna olla hirviendo a escondidas.asyncio.CancelledError salta en el punto del await. Por eso puedes capturarla con except y recoger. La cancelación recibida hay que volver a lanzarla siempre (10-5).La línea de tiempo de abajo ejecuta un TaskGroup de verdad en el servidor. Cuanto más alto es el número de mesa, un poco más tarda su plato (T1 0,3 s, T2 0,345 s, … T6 unos 0,53 s). La mesa que elijas como «Mesa que falla» falla justo cuando su plato estaba a punto de terminar.
Línea de tiempo de la cocina
Pedidos
6
Mesa que falla
Ninguno
Límite de tiempo (s)
apagado
Cocinando
Esperando
Cancelado
Falló
Varias tareas pueden fallar a la vez. Por eso TaskGroup no lanza una sola excepción, sino un paquete de excepciones.
import asyncio
async def boom(exc, sec):
await asyncio.sleep(sec)
raise exc
async def main():
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(boom(ValueError("T2 allergy"), 0.1))
tg.create_task(boom(KeyError("T5"), 0.1))
except Exception as e:
print(type(e).__name__, "|", e)
print(e.exceptions)
asyncio.run(main())
# ExceptionGroup | unhandled errors in a TaskGroup (2 sub-exceptions)
# (ValueError('T2 allergy'), KeyError('T5'))ExceptionGroup (3.11) es una excepción que guarda una lista de excepciones en .exceptions. Para capturarlas por tipo dentro del paquete, se usa except*.
eg = ExceptionGroup("kitchen", [ValueError("T2 allergy"), KeyError("T5"), ValueError("T6 salt")])
try:
raise eg
except* ValueError as g:
print("ValueError:", [str(e) for e in g.exceptions])
except* KeyError as g:
print("KeyError:", g.exceptions)
# ValueError: ['T2 allergy', 'T6 salt']
# KeyError: (KeyError('T5'),)except normal se ejecuta un solo bloque. Con except* se ejecutan todos los bloques que encajan. Arriba se ejecutan los dos.g siempre es un ExceptionGroup: un paquete más pequeño con solo las excepciones que encajan.except* vuelven a subir en un paquete con las que quedan.eg = ExceptionGroup("kitchen", [ValueError("T2"), KeyError("T5")])
try:
try:
raise eg
except* ValueError:
print("handled ValueError")
except ExceptionGroup as rest:
print("left:", rest.exceptions)
print(isinstance(eg, Exception), eg.subgroup(ValueError).exceptions)
# handled ValueError
# left: (KeyError('T5'),)
# True (ValueError('T2'),)except* tiene dos reglas. En 3.14, las dos se detectan como errores de sintaxis antes de ejecutar nada.
src1 = '''
def f():
try:
pass
except* ValueError:
return 1
'''
src2 = '''
try:
pass
except ValueError:
pass
except* KeyError:
pass
'''
for src in (src1, src2):
try:
compile(src, "<menu>", "exec")
except SyntaxError as e:
print("SyntaxError:", e.msg)
# SyntaxError: 'break', 'continue' and 'return' cannot appear in an except* block
# SyntaxError: cannot have both 'except' and 'except*' on the same 'try'except* no se puede usar return, break ni continue. Varios bloques tienen que ejecutarse uno tras otro, y no se puede salir a mitad. Guarda el resultado en una variable y devuélvelo fuera del try.try no se pueden mezclar except y except*. Si necesitas los dos, anida dos try. Así lo hace también el motor de los bancos de pruebas de este curso.El consejo de la chef
Si te entran ganas de hacer return dentro de un except*, apunta el resultado en una variable y devuélvelo fuera del try. En la cocina, igual: primero recibes todos los partes de incidencias y luego te vas a casa. No cierras la puerta al oír el primero.
La segunda regla de esta noche es «ningún plato pasa del tiempo fijado». asyncio.timeout() (3.11) pone un límite de tiempo a todo un bloque.
import asyncio, time
async def stew(sec):
await asyncio.sleep(sec)
return "stew"
async def main():
start = time.perf_counter()
try:
async with asyncio.timeout(0.2):
await stew(1.0)
except TimeoutError:
print(f"timeout after {time.perf_counter() - start:.2f}s")
try:
print(await asyncio.wait_for(stew(0.1), timeout=0.2))
print(await asyncio.wait_for(stew(1.0), timeout=0.2))
except TimeoutError:
print("wait_for: timeout")
print(asyncio.TimeoutError is TimeoutError)
asyncio.run(main())
# timeout after 0.20s
# stew
# wait_for: timeout
# TrueTimeoutError integrado. Desde 3.11, asyncio.TimeoutError es lo mismo que el TimeoutError integrado.asyncio.wait_for(corrutina, timeout=) es la forma de siempre de poner un límite a una sola corrutina. Es cómoda cuando quieres el resultado directamente.asyncio.timeout puede agrupar varios await en un solo bloque. Algo como «20 minutos en total, desde que se toma el pedido hasta que se sirve».El límite también se puede fijar como «a tal hora» en vez de «dentro de tantos segundos», y se puede cambiar después de entrar en el bloque.
import asyncio, time
async def main():
loop = asyncio.get_running_loop()
start = time.perf_counter()
deadline = loop.time() + 0.3 # el «instante» que será dentro de 0.3 s
try:
async with asyncio.timeout_at(deadline):
await asyncio.sleep(1)
except TimeoutError:
print(f"timeout_at: {time.perf_counter() - start:.1f}s")
start = time.perf_counter()
try:
async with asyncio.timeout(None) as cm: # al principio, sin límite
cm.reschedule(loop.time() + 0.2) # más tarde se fija en 0.2 s
print(cm.when() is not None)
await asyncio.sleep(1)
except TimeoutError:
print(f"reschedule: {time.perf_counter() - start:.1f}s, expired={cm.expired()}")
asyncio.run(main())
# timeout_at: 0.3s
# True
# reschedule: 0.2s, expired=Truetimeout_at(instante) recibe un instante absoluto medido con loop.time(). Viene bien cuando varias fases comparten una misma «hora límite».timeout(None) empieza sin límite. Con cm.reschedule(instante) puedes fijar el límite más tarde o aplazarlo.TaskGroup y los límites de tiempo, al final, hacen lo mismo: enviar una cancelación a una tarea. Para enviarla tú directamente, usa task.cancel().
import asyncio
async def braise():
print("stove on")
try:
await asyncio.sleep(10)
finally:
print("stove off") # aunque se cancele, se apaga el fuego
async def main():
task = asyncio.create_task(braise())
await asyncio.sleep(0.1)
task.cancel()
try:
await task
except asyncio.CancelledError:
print("cancelled:", task.cancelled())
asyncio.run(main())
# stove on
# stove off
# cancelled: Truecancel() no mata la tarea en el acto. Solo hace que salte CancelledError en el próximo await en el que la tarea se detenga.try/finally. Se ejecuta siempre: con cancelación, con éxito o con fallo.CancelledError no hereda de Exception, sino de BaseException. Está diseñado así para que un except Exception: no se trague una cancelación por descuido.¿Qué pasa si te tragas la cancelación?
import asyncio, time
async def stubborn():
try:
await asyncio.sleep(0.5)
except asyncio.CancelledError:
pass # se ha tragado la cancelación
await asyncio.sleep(0.5) # y sigue cocinando
return "late"
async def main():
start = time.perf_counter()
try:
async with asyncio.timeout(0.1):
print(await stubborn())
except TimeoutError:
print("timeout")
print(f"{time.perf_counter() - start:.1f}s")
asyncio.run(main())
# late
# 0.6sPusimos un límite de 0,1 segundos, pero tardó 0,6 y ni siquiera saltó TimeoutError. El límite de tiempo funciona mediante una cancelación, y la corrutina se la comió. La «cancelación del resto» de TaskGroup se viene abajo exactamente igual. Por eso, si recibes un CancelledError, recoge lo que tengas que recoger y vuelve a lanzarlo (raise).
El consejo de la chef
Si te llega una cancelación: apagas el fuego, limpias la tabla y levantas la mano. En cuanto piensas «si ya casi está» y lo dejas hervir un poco más, se viene abajo toda la regla de la cocina. Minho, va por ti.
Hay trabajos que no pueden quedarse a medias, como guardar un tique. asyncio.shield() impide que una cancelación que llega de fuera baje hasta la tarea de dentro.
import asyncio
async def save_receipt():
await asyncio.sleep(0.3)
print("receipt saved")
return "ok"
async def main():
inner = asyncio.create_task(save_receipt())
try:
async with asyncio.timeout(0.1):
await asyncio.shield(inner)
except TimeoutError:
print("waiter gave up")
print(await inner) # la tarea de dentro siguió en marcha
asyncio.run(main())
# waiter gave up
# receipt saved
# okQuien esperaba se rindió a los 0,1 segundos, pero la tarea de dentro llegó hasta el final. Para recoger su resultado más tarde, tienes que guardar una referencia a esa tarea.
Task.cancelling() devuelve cuántas peticiones de cancelación ha recibido la tarea, y Task.uncancel() resta una. Son herramientas que asyncio.timeout y TaskGroup usan por dentro para distinguir «¿esta cancelación la envié yo?». Rara vez tendrás que usarlas directamente.
import asyncio
async def worker():
try:
await asyncio.sleep(1)
except asyncio.CancelledError:
me = asyncio.current_task()
print("cancelling:", me.cancelling())
me.uncancel()
print("after uncancel:", me.cancelling())
return "rescued"
async def main():
t = asyncio.create_task(worker())
await asyncio.sleep(0.05)
t.cancel()
print(await t)
asyncio.run(main())
# cancelling: 1
# after uncancel: 0
# rescuedAplicamos las dos reglas de esta noche en una misma cocina: un límite de 0,6 segundos por mesa, y las seis mesas agrupadas en un solo TaskGroup.
import asyncio, time
async def table(n, sec, allergy, stopped):
try:
async with asyncio.timeout(0.6): # ningún plato pasa de 0.6 s
await asyncio.sleep(sec)
if n == allergy:
raise ValueError(f"T{n} allergy")
return f"T{n}"
except asyncio.CancelledError:
stopped.append(f"T{n}") # apaga el fuego y levanta la mano
raise
async def service(menu, allergy=None):
start, stopped = time.perf_counter(), []
try:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(table(n, s, allergy, stopped)) for n, s in menu.items()]
print("served:", [t.result() for t in tasks])
except* ValueError as eg:
print("cancelled by:", [str(e) for e in eg.exceptions], "stopped:", sorted(stopped))
except* TimeoutError as eg:
print("too slow:", len(eg.exceptions))
print(f"{time.perf_counter() - start:.1f}s")
MENU = {1: 0.30, 2: 0.35, 3: 0.20, 4: 0.40, 5: 0.45, 6: 0.50}
asyncio.run(service(MENU))
asyncio.run(service(MENU, allergy=3))
asyncio.run(service({**MENU, 6: 0.9}))
# served: ['T1', 'T2', 'T3', 'T4', 'T5', 'T6']
# 0.5s
# cancelled by: ['T3 allergy'] stopped: ['T1', 'T2', 'T4', 'T5', 'T6']
# 0.2s
# too slow: 1
# 0.6sTimeoutError de esa mesa sube dentro del paquete y, otra vez, el resto se para.Cerca del cierre, la clienta de la ventana termina su bol de fideos y cierra su libretita. Al pagar, le dice a la chef en pocas palabras: «Vuelvo el viernes que viene».
La chef asiente y se gira hacia su segundo de cocina. «Tanto la cancelación como el límite de tiempo pasan, al final, en un await. Pero el horno viejo del rincón no sabe lo que es un await. Si Lou se queda plantada delante esperándolo, ¿qué le pasa a la sala?».
Diario de servicio — Décima noche. Agrupamos las seis mesas del grupo en un solo TaskGroup: cuando se cayó la mesa 3, el resto de ollas se apagaron a la vez. Pusimos un límite de tiempo a cada mesa y colgamos en la pared de la cocina la regla de no tragarse nunca una cancelación.
gather de 10-1 por un TaskGroup y haz que, cuando falle la mesa 2, las mesas 1 y 3 impriman cancelled.table del código del cierre, borra el raise y pon en su lugar await asyncio.sleep(0.3) (ignorar la cancelación y seguir hirviendo), y ejecútalo con allergy=3. Mira cómo cambia el tiempo y apunta qué ha ido mal.La tarjeta de recetas de la chef
gather no para el resto aunque falle uno. Lo que debe terminar junto, agrúpalo con TaskGroup (3.11).
async with asyncio.TaskGroup() as tg: tg.create_task(...) — al salir del bloque todo ha terminado, y si falla una tarea, las demás se cancelan.
Los fallos llegan juntos en un ExceptionGroup. Captúralos por tipo con except* ValueError (sin return, sin mezclar con except).
Límite de tiempo: async with asyncio.timeout(segundos) o wait_for(corrutina, timeout=). Si se pasa, TimeoutError integrado.
La cancelación llega como CancelledError en un await. Recoge con finally y vuelve a hacer raise de la cancelación recibida.
Lo que no puede quedarse a medias: asyncio.shield(task). cancelling()/uncancel() son piezas para quien construye herramientas.
← Anterior
Lección 9 · Una sola encargada de sala: primeros pasos con asyncio
Siguiente →
Lección 11 · La sala se congela: llamadas bloqueantes y combinar herramientas
Recibe avisos de nuevas lecciones
Te avisaremos por correo cuando este curso tenga lecciones nuevas o se abra un curso de continuación.
R
ReflexKR
Aprende Python y Reflex haciendo. Todos los ejemplos se ejecutaron y verificaron en Reflex 0.10.0.
ReflexKR es gratis y sin banners publicitarios. Si te ayudó, puedes apoyarlo con un café. ☕ Invítanos un café
Aviso de afiliados© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
Preguntas y respuestas
Pregunta lo que quieras sobre esta lección. Aquí se reúnen las preguntas de todas las versiones de idioma de esta página.