Cómo funciona asyncio de Python: recreándolo desde cero
(jacobpadilla.com)- asyncio de Python es una herramienta para manejar programas I/O-bound, y el artículo muestra cómo funciona recreándolo con una implementación simple sobre generadores
- La idea central es una estructura donde un event loop con varias tareas ejecuta una tarea, recupera el control en un
yieldy pasa a la siguiente tarea sleeppuede construirse como un subgenerador que haceyieldhasta que pase el tiempo, yyield frompermite continuar la tarea original después de que termina el generador hijo- Al cambiar a
async/await,Task.__await__()cede el control al event loop hasta completarse, ycreate_taskyrunforman una interfaz similar a la API real de asyncio - Si se reemplazan las llamadas personalizadas de
jacobioporasyncio, el código pasa a usar el paquete real, aunque el asyncio real es mucho más complejo y su flujo interno también difiere en parte
Estructura básica de asyncio vista con generadores
- asyncio se usa en Python para manejar programas I/O-bound, y el artículo lo reconstruye de forma simplificada con base en generadores
- Al igual que
rangeen Python 3+, los generadores producen los valores necesarios uno por uno sin guardar toda la secuencia en memoria- Si
range(100_000_000)se creara como una lista, tendría que almacenar 100 millones de elementos, por lo que sería muy ineficiente en memoria - Un generador no conserva toda la secuencia, porque genera cada valor cuando se necesita
- Si
- Una función generadora se define como una función común, pero usa
yield- Al llamar a la función, no ejecuta el cuerpo de inmediato, sino que devuelve un objeto generador
- Al llamar
next(iterator), se ejecuta hasta el siguienteyield - Si ya no hay más
yield, se produce una excepciónStopIteration
yield frompermite que un generador invoque un subgenerador o un objeto iterable, creando una cadena de generadores- La característica importante aquí es que la ejecución de una función puede pausarse y luego retomarse conservando su estado
Simplificar el event loop como una lista
- El event loop es el núcleo de asyncio: ejecuta y administra las tareas actuales
- El event loop real de asyncio está escrito en C, pero en un modelo simple puede verse como una lista que contiene las tareas actuales
- En el ejemplo inicial, las tareas se tratan como objetos generador
- El administrador del event loop recorre la lista de tareas
- Llama
next(task)en cada tarea para ejecutarla - La tarea se pausa con
yielden el momento en que debe esperar, como en una operación I/O-bound, y devuelve el control al event loop
- En un ejemplo donde
task1()ytask2()imprimen y luego hacenyield, la salida aparece alternada- Como ambas funciones tienen un bucle
while True, la ejecución continúa indefinidamente - La salida se repite como
Task 1,Task 2,Task 1,Task 2
- Como ambas funciones tienen un bucle
Crear sleep con yield from
sleep(seconds)se implementa como un generador que registra la hora de inicio y sigue haciendoyieldhasta que transcurra el tiempo indicado- La función de tarea delega la ejecución al subgenerador sleep con
yield from sleep(1)oyield from sleep(5)- Mientras
sleepsigue haciendoyield, la tarea queda pausada - Cuando ya pasó suficiente tiempo, termina el bucle
whiledesleep - Como ya no hay más
yield, se produceStopIteration, yyield fromcontinúa con la siguiente línea de la función de tarea
- Mientras
- En el ejemplo,
task1imprime cada 1 segundo ytask2cada 5 segundos- La salida queda con una forma donde aparecen
Task 1,Task 2, luegoTask 1varias veces y después vuelve a aparecerTask 2
- La salida queda con una forma donde aparecen
Pasar de yield a await
- Para usar
await, el objeto de destino debe tener un método__await__o ser una coroutine - En asyncio normalmente se manejan objetos
Taskmediante funciones comoasyncio.create_task- El objeto
Taskhereda deFutureen asyncio - El objeto
Futuretiene el método__await__
- El objeto
- Al llamar una función marcada con la palabra clave
async, se crea un objeto coroutine- Una coroutine puede pausar su ejecución y retomarla después, como una función generadora
awaitpuede verse como algo parecido ayield from, pero con reglas adicionales de validaciónawait objecttermina haciendoyielddesde el__await__de la instancia del objeto, o esperando otra coroutine
- En el
código fuente de Futurede asyncio también puede verse que, cuando unFutureoTasktodavía no terminó,__await__básicamente llama ayield
Implementación simple de Task, create_task y run
- La implementación personalizada usa una
Queuecomo event loop en lugar de una lista- Es una estructura pensada para agregar y quitar tareas del loop en tiempo constante
- La clase
Taskguarda el objeto generador y si ya terminó o no- Guarda el objeto generador en
self.iter - Inicializa
self.finishedcomoFalse - Si el generador produce
StopIteration, se considera que terminó done()devuelve el estado de finalización
- Guarda el objeto generador en
Task.__await__()llama continuamenteyield selfmientras la tarea no haya terminado- Este comportamiento devuelve el control al event loop
create_task(generator)envuelve el generador en unTask, lo coloca en la cola del event loop y lo devuelve- Su función es programar la tarea en el event loop
run(main)inicia el event loop con una forma similar aasyncio.run()real- Envuelve el
mainrecibido inicialmente en unTasky lo coloca en la cola - Mientras la cola no esté vacía, toma la siguiente tarea
- Avanza la tarea con
task.iter.send(None) - Si ocurre
StopIteration, establecetask.finished = True - Si no hay excepción, vuelve a poner la tarea en la cola del event loop
- Envuelve el
- Usar
task.iter.send(None)en lugar denext(task.iter)es una característica al trabajar con las palabras claveasync/await; aquí cumple el mismo papel
sleep compatible con async y ejemplo de jacobio
- El
sleepanterior era una función generadora, peroawaitno puede combinarse directamente con funciones generadoras- El destino de
awaitdebe ser un objeto con__await__o una función coroutine
- El destino de
- La lógica real de espera se mueve al generador
_sleep(seconds)_sleephaceyieldhasta que pase cierta cantidad de tiempo
async def sleep(seconds)crea una tarea con_sleep(seconds)y espera esa tarea conawaitawait taskllamaTask.__await__()- Si la tarea no terminó, cede el control al event loop con
yield
- El archivo personalizado terminado
jacobio.pyincluye estos elementos- Cola del event loop
_sleepasync sleepTaskcreate_taskrun
- En el ejemplo de uso se reemplaza el
yield fromanterior porawait, y se agregaasynca las funciones que usanawaittask1imprime dos veces y esperajacobio.sleep(1)cada veztask2imprime tres veces y esperajacobio.sleep(0)cada vezmaincrea dos tareas, espera ambas conawaity luego imprimedone
- La salida del ejemplo es
Task 1,Task 2,Task 2,Task 2,Task 1,done, en ese orden
Cambiarlo a asyncio real
- Si en el ejemplo personalizado de
jacobiose reemplaza todojacobioporasyncio, el código pasa a usar el paquete asyncio real - Las funciones correspondientes son las siguientes
jacobio.sleep()→asyncio.sleep()jacobio.create_task()→asyncio.create_task()jacobio.run()→asyncio.run()
- El asyncio real hace muchas más cosas internamente
- Este administrador de event loop es una implementación hecha lo más simple posible, por lo que muestra la idea básica de asyncio, pero difiere un poco del flujo del código fuente real debido al tamaño y la complejidad del paquete real
- Con asyncio real, en lugar de crear dos tareas por separado y esperar ambas con
await, se pueden manejar varias tareas con funciones comoasyncio.gather() - Se enlaza como artículo relacionado handling asyncio tasks like a pro
1 comentarios
Opiniones en Hacker News
Asyncio permite reemplazar la implementación por una event loop propia
En Temporal Python, los workflows se expresan con una event loop de asyncio durable personalizada, así que incluso algo como
asyncio.sleepse convierte en un temporizador durable. Es decir, el código puede reanudarse en otra máquina, por lo que es posible hacer sleep durante semanasLa forma en que está implementado se explica en este artículo: https://temporal.io/blog/durable-distributed-asyncio-event-l...
El mayor problema de asyncio es que en Python es fácil bloquear el hilo de asyncio con una llamada síncrona y detener todo el sistema. Python necesita urgentemente una herramienta de análisis estático que construya el grafo de llamadas y detecte si dentro de un
async defse invoca, directa o indirectamente, una llamada que bloquea el hiloOperaciones básicas como aritmética o acceso a estructuras de datos podrían ponerse en una lista permitida como llamadas síncronas rápidas, y si la event loop gira sospechosamente rápido, quizá se podrían detectar otras operaciones que deberían ser síncronas
Cuando entendí cómo implementaron la event loop de asyncio, fue un verdadero momento de iluminación
Todavía estoy tratando de entender si Python es realmente un lenguaje adecuado para concurrencia. Asyncio siempre me pareció que apenas se mantiene en pie y, para mí, la implementación async de C# es más limpia
Purchaser.purchaseno debería serdo_purchase?Incluso un simple
read()puede bloquear o no dependiendo de qué sea el descriptor y cómo esté configurado. ¿Cómo detectarías eso con análisis estático?Esta implementación parece hacer busy waiting al dormir. Es decir, la event loop sigue girando aunque no haya tareas ejecutables en ese momento
Recuerdo haber visto antes otra implementación de juguete en la que se llevaba el registro, en orden, del próximo momento en que cada tarea podía ejecutarse, y si no había tareas ejecutables en ese momento, la propia event loop hacía sleep. Entiendo que el asyncio real funciona de esa manera
Más adelante, eso se amplió para que la próxima condición de ejecución no fuera solo una hora de reloj de pared, sino que también pudiera depender de cosas como sockets, permitiendo usar
selectcon un timeoutUna implementación conocida es uvloop(https://github.com/MagicStack/uvloop), que básicamente implementa el loop usando libuv, y libuv se encarga de operaciones como el
selectque mencionas[1] https://simpy.readthedocs.io/en/latest/
[2] https://gitlab.com/team-simpy/simpy.io
La event loop no necesariamente tiene que iterar; puede iniciar ejecutando
mainy terminar junto con él cuando este termine. Piensa en arrancar un servidor con un loopwhile trueque espera sockets, y que termina ante una condición de salida o una interrupción, con lo que también finaliza el programaDesde la perspectiva de la event loop, no hay busy waiting ni necesidad de tocar sleeps o sockets. Es la diferencia entre ejecutar hasta completar y ejecutar para siempre
Si vas a hacer una loop de juguete, creo que es mejor no ocuparse del caso de ejecución infinita
La charla de David Beazley sobre asyncio es excelente
A partir de eso construí una herramienta de simulación de eventos discretos. Es bastante genial que puedas implementar asyncio tú mismo y cambiar el reloj del sistema por tiempo simulado
Es un artículo realmente excelente, una explicación de alto nivel que evita bien las partes que podrían aburrir a alguien que lo lee por primera vez
Sería aún mejor si al final agregara otros recursos que expliquen cómo funciona realmente por dentro
Creo que habría sido mejor si el artículo mostrara lo mismo sin usar
yielden absoluto. Sinceramente, ahí es donde está la parte que de verdad se siente mágicaAquí hay una explicación mucho más profunda de las corrutinas de Python: https://aosabook.org/en/500L/a-web-crawler-with-asyncio-coro...
¿No hay ninguna mención de
poll()? Entonces no tiene nada que ver con cómo funciona asyncioEs completamente raro que Python haya interceptado
defpara usarlo en la creación de objetos que en realidad no son funcionesComo mínimo podrían haber creado otra palabra clave
Devuelve un generador o una corrutina, y la firma de tipos puede verse aquí [1]. Incluso sin azúcar sintáctico a nivel del lenguaje, se podría hacer algo parecido en Python puro usando algo como decoradores
[1] https://docs.python.org/3/library/typing.html#typing.Generat...
Dicho eso, menciono la firma de tipos por interés académico; en anotaciones de tipos reales prefiero los más simples
IterableyAwaitableSuena a que priorizaron la semántica de la experiencia del desarrollador por encima de algo como la pureza del lenguaje. Nunca me confundió que una función
asyncsea una función asíncronadefpara las funciones, así que no se pueden crear funciones inline como las arrow functions de JS o las lambdas de C++Eso no es un problema de pureza del lenguaje, es simplemente incómodo