3 puntos por GN⁺ 2024-05-09 | 1 comentarios | Compartir por WhatsApp
  • 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 yield y pasa a la siguiente tarea
  • sleep puede construirse como un subgenerador que hace yield hasta que pase el tiempo, y yield from permite 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, y create_task y run forman una interfaz similar a la API real de asyncio
  • Si se reemplazan las llamadas personalizadas de jacobio por asyncio, 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 range en 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
  • 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 siguiente yield
    • Si ya no hay más yield, se produce una excepción StopIteration
  • yield from permite 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 yield en 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() y task2() imprimen y luego hacen yield, 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

Crear sleep con yield from

  • sleep(seconds) se implementa como un generador que registra la hora de inicio y sigue haciendo yield hasta que transcurra el tiempo indicado
  • La función de tarea delega la ejecución al subgenerador sleep con yield from sleep(1) o yield from sleep(5)
    • Mientras sleep sigue haciendo yield, la tarea queda pausada
    • Cuando ya pasó suficiente tiempo, termina el bucle while de sleep
    • Como ya no hay más yield, se produce StopIteration, y yield from continúa con la siguiente línea de la función de tarea
  • En el ejemplo, task1 imprime cada 1 segundo y task2 cada 5 segundos
    • La salida queda con una forma donde aparecen Task 1, Task 2, luego Task 1 varias veces y después vuelve a aparecer Task 2

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 Task mediante funciones como asyncio.create_task
    • El objeto Task hereda de Future en asyncio
    • El objeto Future tiene el método __await__
  • 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
  • await puede verse como algo parecido a yield from, pero con reglas adicionales de validación
    • await object termina haciendo yield desde el __await__ de la instancia del objeto, o esperando otra coroutine
  • En el código fuente de Future de asyncio también puede verse que, cuando un Future o Task todavía no terminó, __await__ básicamente llama a yield

Implementación simple de Task, create_task y run

  • La implementación personalizada usa una Queue como event loop en lugar de una lista
    • Es una estructura pensada para agregar y quitar tareas del loop en tiempo constante
  • La clase Task guarda el objeto generador y si ya terminó o no
    • Guarda el objeto generador en self.iter
    • Inicializa self.finished como False
    • Si el generador produce StopIteration, se considera que terminó
    • done() devuelve el estado de finalización
  • Task.__await__() llama continuamente yield self mientras la tarea no haya terminado
    • Este comportamiento devuelve el control al event loop
  • create_task(generator) envuelve el generador en un Task, 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 a asyncio.run() real
    • Envuelve el main recibido inicialmente en un Task y 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, establece task.finished = True
    • Si no hay excepción, vuelve a poner la tarea en la cola del event loop
  • Usar task.iter.send(None) en lugar de next(task.iter) es una característica al trabajar con las palabras clave async/await; aquí cumple el mismo papel

sleep compatible con async y ejemplo de jacobio

  • El sleep anterior era una función generadora, pero await no puede combinarse directamente con funciones generadoras
    • El destino de await debe ser un objeto con __await__ o una función coroutine
  • La lógica real de espera se mueve al generador _sleep(seconds)
    • _sleep hace yield hasta que pase cierta cantidad de tiempo
  • async def sleep(seconds) crea una tarea con _sleep(seconds) y espera esa tarea con await
    • await task llama Task.__await__()
    • Si la tarea no terminó, cede el control al event loop con yield
  • El archivo personalizado terminado jacobio.py incluye estos elementos
    • Cola del event loop
    • _sleep
    • async sleep
    • Task
    • create_task
    • run
  • En el ejemplo de uso se reemplaza el yield from anterior por await, y se agrega async a las funciones que usan await
    • task1 imprime dos veces y espera jacobio.sleep(1) cada vez
    • task2 imprime tres veces y espera jacobio.sleep(0) cada vez
    • main crea dos tareas, espera ambas con await y luego imprime done
  • 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 jacobio se reemplaza todo jacobio por asyncio, 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 como asyncio.gather()
  • Se enlaza como artículo relacionado handling asyncio tasks like a pro

1 comentarios

 
GN⁺ 2024-05-09
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.sleep se convierte en un temporizador durable. Es decir, el código puede reanudarse en otra máquina, por lo que es posible hacer sleep durante semanas
    La 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 def se invoca, directa o indirectamente, una llamada que bloquea el hilo

    • Quizá sea una mala idea, pero me pregunto qué pasaría si se hiciera al revés en lugar del enfoque actual, donde todas las llamadas son síncronas por defecto
      Operaciones 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
    • Temporal es realmente genial
      Cuando entendí cómo implementaron la event loop de asyncio, fue un verdadero momento de iluminación
    • Me pregunto por qué no usar threads
      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
    • En el ejemplo de workflow, ¿Purchaser.purchase no debería ser do_purchase?
    • Detectar eso estáticamente no sería fácil
      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 select con un timeout

    • En el propio asyncio también se pueden usar implementaciones de event loop personalizadas además de la implementación incluida por defecto
      Una 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 select que mencionas
    • Esto suena bastante parecido a SimPy [1] / Simpy.io [2], pero SimPy apareció varios años antes que asyncio
      [1] https://simpy.readthedocs.io/en/latest/
      [2] https://gitlab.com/team-simpy/simpy.io
    • Desde cierto punto de vista, este enfoque no tiene mayor problema
      La event loop no necesariamente tiene que iterar; puede iniciar ejecutando main y terminar junto con él cuando este termine. Piensa en arrancar un servidor con un loop while true que espera sockets, y que termina ante una condición de salida o una interrupción, con lo que también finaliza el programa
      Desde 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
    • ¿Cuál sería el mejor libro para aprender este tipo de cosas?
  • 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 yield en absoluto. Sinceramente, ahí es donde está la parte que de verdad se siente mágica
    Aquí 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 asyncio

  • Es completamente raro que Python haya interceptado def para usarlo en la creación de objetos que en realidad no son funciones
    Como mínimo podrían haber creado otra palabra clave

    • Sí es una función, solo que se transforma en una función con otro tipo de retorno
      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 Iterable y Awaitable
    • ¿Cuál es el problema real? ¿O es solo una cuestión de pureza del lenguaje?
    • No creo que esto deba verse como una tragedia
      Suena 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 async sea una función asíncrona
    • El problema más grande de Python es que, para empezar, hay que usar def para 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
    • ¿Hablas de decoradores? En el contexto de Python, es bastante confuso entender a qué te refieres