Capítulo 4. Asincronía, concurrencia y Starlette Tour

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

Starlette es un framework/toolkit ASGI ligero, ideal para construir servicios web asíncronos en Python.

Tom Christie, creador de Starlette

Vista previa

El capítulo anterior presentó brevemente las primeras cosas que un desarrollador se encontraría al escribir una nueva aplicación FastAPI. Este capítulo hace hincapié en la biblioteca Starlette subyacente de FastAPI, en particular en su soporte del procesamiento asíncrono. Tras una visión general de las múltiples formas de "hacer más cosas a la vez" en Python, verás cómo sus palabras clave más recientes async y awaitse han incorporado a Starlette y FastAPI.

Starlette

Gran parte del código web de FastAPI se basa en elpaquete Starlette, creado por Tom Christie. Puede utilizarse como marco web por derecho propio o como biblioteca para otros marcos, como FastAPI. Como cualquier otro marco web, Starlette se encarga de todo el análisis sintáctico habitual de las solicitudes HTTP y de la generación de respuestas. Es similar aWerkzeug, el paquete subyacente a Flask.

Pero su característica más importante es su compatibilidad con el moderno estándar web asíncrono de Python :ASGI. Hasta ahora, la mayoría de los frameworks web de Python (como Flask y Django) se han basado en elestándar WSGI síncrono tradicional . Dado que las aplicaciones web se conectan con tanta frecuencia a código mucho más lento (por ejemplo, acceso a bases de datos, archivos y redes), ASGI evita el bloqueo y la espera ocupada de las aplicaciones basadas en WSGI.

Como resultado, Starlette y los frameworks que lo utilizan son los paquetes web de Python más rápidos, rivalizando incluso con las aplicaciones Go y Node.js.

Tipos de concurrencia

Antes de que entre en los detalles del soporte asíncrono proporcionado por Starlette y FastAPI, es útil conocer las múltiples formas en que podemos implementarla concurrencia.

En la informáticaparalela de , una tarea se reparte entre varias CPU dedicadas al mismo tiempo. Esto es habitual en aplicaciones de "cálculo numérico" como los gráficos y el aprendizaje automático.

En la informática concurrente, cada CPU alterna entre múltiples tareas. Algunas tareas tardan más que otras, y queremos reducir el tiempo total necesario. Leer un archivo o acceder a un servicio de red remoto es literalmente miles o millones de veces más lento que ejecutar cálculos en la CPU.

Las aplicaciones web realizan gran parte de este trabajo lento. ¿Cómo podemos hacer que los servidores web, o cualquier servidor, funcionen más rápido? En esta sección se analizan algunas posibilidades, desde todo el sistema hasta el tema central de este capítulo: la implementación de FastAPI deasync y await de Python.

Computación Distribuida y Paralela

Si tienes una aplicación realmente grande -que resoplaría en una sola CPU- puedes dividirla en trozos y hacer que esos trozos se ejecuten en CPU distintas de una sola máquina o en varias máquinas. Puedes hacerlo de muchas, muchas maneras, y si tienes una aplicación así, ya conoces varias de ellas. Gestionar todos esos trozos es más complejo y caro que gestionar un solo servidor.

En este libro, la atención se centra en aplicaciones de tamaño pequeño o mediano que puedan caber en una sola caja. Y estas aplicaciones pueden tener una mezcla de código síncrono y asíncrono, bien gestionado por FastAPI.

Procesos del sistema operativo

Un sistema operativo (o SO, porque escribir duele) programa los recursos: memoria, CPU, dispositivos, redes, etc. Cada programa que ejecuta ejecuta su código en uno o variosprocesos. El SO proporciona a cada proceso un acceso gestionado y protegido a los recursos, incluyendo cuándo pueden utilizar la CPU.

La mayoría de los sistemas utilizan programación de procesospreventiva, que no permite que ningún proceso acapare la CPU, la memoria o cualquier otro recurso. Un SO suspende y reanuda procesos continuamente, según su diseño y configuración.

Para los desarrolladores, la buena noticia es: ¡no es tu problema! Pero la mala noticia (que normalmente parece hacer sombra a la buena) es: no puedes hacer mucho para cambiarlo, aunque quieras.

Con aplicaciones Python que consumen mucha CPU, la solución habitual es utilizar varios procesos y dejar que el SO los gestione. Python tiene unmódulo de multiprocesamiento para ello.

Hilos del sistema operativo

Puedes también ejecutar hilos de control dentro de un mismo proceso. Elpaquete de hilos de Python los gestiona.

A menudo se recomiendan los hilos cuando tu programa está ligado a la E/S, y los procesos múltiples cuando estás ligado a la CPU. Pero los hilos son complicados de programar y pueden causar errores difíciles de encontrar. En Introducción a Python, comparé los hilos con fantasmas flotando en una casa encantada: independientes e invisibles, detectados sólo por sus efectos. Eh, ¿quién ha movido ese candelabro?

Tradicionalmente, Python mantenía separadas las bibliotecas basadas en procesos de las basadas en hilos. Los desarrolladores tenían que aprender los arcanos detalles de ambas para utilizarlas. Un paquete más reciente llamadoconcurrent.futureses una interfaz de alto nivel que facilita su uso.

Como verás, puedes obtener las ventajas de los hilos más fácilmente con las nuevas funciones asíncronas. FastAPI también gestiona hilos para funciones síncronas normales (def, no async def) mediante threadpools.

Hilos Verdes

Un mecanismo más misterioso de es el que presentanlos hilos verdes de, comogreenlet,geventyEventlet. Estos son cooperativos (no preferentes). Son similares a los hilos del SO, pero se ejecutan en el espacio de usuario (es decir, en tu programa) y no en el núcleo del SO. Funcionan parcheandofunciones estándar de Python (modificando funciones estándar de Python mientras se ejecutan) para que el código concurrente parezca código secuencial normal: ceden el control cuando se bloquearían esperando la E/S.

Los hilos del SO son más "ligeros" (utilizan menos memoria) que los procesos del SO, y los hilos verdes son más ligeros que los hilos del SO. En algunaspruebas comparativas, todos los métodos asíncronos fueron en general más rápidos que sus homólogos sincronizados.

Nota

Después de leer este capítulo, puede que te preguntes qué es mejor: ¿gevent o asyncio? No creo que haya una única preferencia para todos los usos. Los hilos verdes se implementaron antes (utilizando ideas del juego multijugador Eve Online). Este libro presenta el asyncio estándar de Python, que utiliza FastAPI, es más sencillo que los hilos y funciona bien.

Devoluciones de llamada

Desarrolladores de aplicaciones interactivas, como juegos e interfaces gráficas de usuario, probablemente estén familiarizados con las retrollamadas. Escribes funciones y las asocias a un evento, como un clic del ratón, la pulsación de una tecla o el tiempo. El paquete destacado de Python en esta categoría esTwisted. Su nombre refleja la realidad de que los programas basados en retrollamadas son un poco "del revés" y difíciles de seguir.

Generadores Python

Como la mayoría de los lenguajes, Python suele ejecutar el código secuencialmente. Cuando llamas a una función, Python la ejecuta desde su primera línea hasta su final o una return.

Pero en una función generadora de Python, puedes detenerte y regresar desde cualquier punto,y volver a ese punto más tarde. El truco es la palabra clave yield.

En un episodio de Los Simpson, Homer choca con su coche contra una estatua de ciervo, seguido de tres líneas de diálogo.El ejemplo 4-1 define una función normal de Python para return estas líneas como una lista y hacer que quien las llama itere sobre ellas.

Ejemplo 4-1. Utiliza return
>>> def doh():
...     return ["Homer: D'oh!", "Marge: A deer!", "Lisa: A female deer!"]
...
>>> for line in doh():
...     print(line)
...
Homer: D'oh!
Marge: A deer!
Lisa: A female deer!

Esto funciona perfectamente cuando las listas son relativamente pequeñas. Pero, ¿y si vamos a coger todos los diálogos de todos los episodios de Los Simpson? Las listas consumen memoria.

El Ejemplo 4-2 muestra cómo una función generadora repartiría las líneas.

Ejemplo 4-2. Utiliza yield
>>> def doh2():
...     yield "Homer: D'oh!"
...     yield "Marge: A deer!"
...     yield "Lisa: A female deer!"
...
>>> for line in doh2():
...     print(line)
...
Homer: D'oh!
Marge: A deer!
Lisa: A female deer!

En lugar de iterar sobre una lista devuelta por la función simple doh(), estamos iterando sobre un objeto generadordevuelto por lafunción generadora doh2(). La iteración real (for...in) tiene el mismo aspecto. Python devuelve la primera cadena de doh2(), pero sigue la pista de dónde está para la siguiente iteración, y así sucesivamente hasta que la función se quede sin diálogo.

Cualquier función que contenga yield es una función generadora. Dada esta capacidad de volver a la mitad de una función y reanudar la ejecución, la siguiente sección parece una adaptación lógica de .

Python async, await y asyncio

Las funcionesasynciode Python se han ido introduciendo a lo largo de varias versiones. Estás ejecutando al menos Python 3.7, cuando los términos async y await se convirtieron en palabras clave reservadas.

Los siguientes ejemplos muestran una broma que sólo tiene gracia cuando se ejecuta de forma asíncrona. Ejecútalos tú mismo, porque la sincronización importa.

Primero, ejecuta el Ejemplo 4-3, que no tiene gracia.

Ejemplo 4-3. Matidez
>>> import time
>>>
>>> def q():
...     print("Why can't programmers tell jokes?")
...     time.sleep(3)
...
>>> def a():
...     print("Timing!")
...
>>> def main():
...     q()
...     a()
...
>>> main()
Why can't programmers tell jokes?
Timing!

Verás un espacio de tres segundos entre la pregunta y la respuesta. Bostezo.

Pero el Ejemplo 4-4 asíncrono es un poco diferente.

Ejemplo 4-4. Hilaridad
>>> import asyncio
>>>
>>> async def q():
...     print("Why can't programmers tell jokes?")
...     await asyncio.sleep(3)
...
>>> async def a():
...     print("Timing!")
...
>>> async def main():
...     await asyncio.gather(q(), a())
...
>>> asyncio.run(main())
Why can't programmers tell jokes?
Timing!

Esta vez, la respuesta debería salir justo después de la pregunta, seguida de tres segundos de silencio, como si la estuviera diciendo un programador. ¡Ja, ja! Ejem.

Nota

He utilizado asyncio.gather() y asyncio.run()en el Ejemplo 4-4, pero hay múltiples formas de llamar a funciones asíncronas. Cuando utilices FastAPI, no necesitarás utilizarlas.

Python piensa esto al ejecutar el Ejemplo 4-4:

  1. Ejecuta q(). De momento, sólo la primera línea.

  2. Vale, perezoso asíncrono q(), he puesto en marcha mi cronómetro y volveré a hablar contigo dentro de tres segundos.

  3. Mientras tanto, ejecutaré a(), imprimiendo la respuesta de inmediato.

  4. Ningún otro await, así que de vuelta a q().

  5. ¡Aburrido bucle de eventos! Me sentaré aquí y me quedaré mirando el resto de los tres segundos.

  6. Vale, ya he terminado.

Este ejemplo utiliza asyncio.sleep() para una función que tarda algún tiempo, como una función que lee un archivo o accede a un sitio web. Pones await delante de la función que podría pasar la mayor parte del tiempo esperando. Y esa función necesita tener async antes de su def .

Nota

Si defines una función con async def, su invocador debe poner un await antes de la llamada a la misma. Y el propio invocador debe declararseasync def, y su invocador debe await, hasta arriba.

Por cierto, puedes declarar una función como asyncaunque no contenga una llamada await a otra función asíncrona . No perjudica a.

FastAPI y Async

Después de que largo viaje de campo por colinas y valles, volvamos a FastAPI y por qué algo de esto importa.

Dado que los servidores web pasan mucho tiempo esperando, se puede aumentar el rendimiento evitando parte de esa espera, es decir, la concurrencia. Otros servidores web utilizan muchos de los métodos mencionados anteriormente: hilos, gevent, etc. Una de las razones por las que FastAPI es uno de los frameworks web de Python más rápidos es su incorporación de código asíncrono, a través del soporte ASGI del paquete subyacente Starlette, y algunas de sus propias invenciones.

Nota

El uso de async y await por sí solos no hace que el código se ejecute más rápido. De hecho, puede ser un poco más lento, por la sobrecarga de la configuración asíncrona. El principal uso de async es evitar largas esperas de E/S.

Ahora, veamos nuestras anteriores llamadas a puntos finales web y veamos cómo hacerlas asíncronas.

Las funciones que asignan URL a código se denominanfunciones de rutaen la documentación de FastAPI. También las he llamado puntos finales web, y ya has visto ejemplos síncronos de ellas en el Capítulo 3. Hagamos algunos asíncronos. Como en esos ejemplos anteriores, por ahora sólo utilizaremos tipos sencillos como números y cadenas.El Capítulo 5 introduce sugerencias de tipo y Pydantic, que necesitaremos para manejar estructuras de datos más sofisticadas.

El Ejemplo 4-5 vuelve a tratar el primer programa FastAPI del capítulo anterior y lo hace asíncrono.

Ejemplo 4-5. Un tímido punto final asíncrono(greet_async.py)
from fastapi import FastAPI
import asyncio

app = FastAPI()

@app.get("/hi")
async def greet():
    await asyncio.sleep(1)
    return "Hello? World?"

Para ejecutar ese trozo de código web, necesitas un servidor web como Uvicorn.

La primera forma es ejecutar Uvicorn en la línea de comandos:

$ uvicorn greet_async:app

La segunda, como en el Ejemplo 4-6, es llamar a Uvicorn desde dentro del código del ejemplo, cuando se ejecuta como programa principal en lugar de como módulo.

Ejemplo 4-6. Otro tímido punto final asíncrono(greet_async_uvicorn.py)
from fastapi import FastAPI
import asyncio
import uvicorn

app = FastAPI()

@app.get("/hi")
async def greet():
    await asyncio.sleep(1)
    return "Hello? World?"

if __name__ == "__main__":
    uvicorn.run("greet_async_uvicorn:app")

Cuando se ejecuta como programa independiente, Python lo denomina main. Eso de if __name__... es la forma que tiene Python de ejecutarlo sólo cuando se llama como programa principal. Sí, es feo.

Este código hará una pausa de un segundo antes de devolver su saludo timorato. La única diferencia con una función síncrona que utilizara la función estándar sleep(1) es que el servidor web puede gestionar otras peticiones mientras tanto con el ejemplo asíncrono.

Utilizando asyncio.sleep(1) se simula una función del mundo real que podría tardar un segundo, como llamar a una base de datos o descargar una página web. En capítulos posteriores se mostrarán ejemplos de este tipo de llamadas desde esta capa Web a la capa de Servicio, y de ahí a la capa de Datos, empleando realmente ese tiempo de espera en trabajo real.

FastAPI llama ella misma a esta función de ruta asíncrona greet() cuando recibe una peticiónGET para la URL /hi. No es necesario que añadas await en ningún sitio. Pero para cualquier otra definición de función async def que hagas, el llamante debe poner await antes de cada llamada.

Nota

FastAPI ejecuta un bucle de eventos asíncrono que coordina las funciones de ruta asíncrona, y unthreadpool para las funciones de ruta síncrona. Un desarrollador no necesita conocer los detalles complicados, lo cual es una gran ventaja. Por ejemplo, no necesitas ejecutar métodos comoasyncio.gather() o asyncio.run(), como en el ejemplo de broma (independiente, sin FastAPI) anterior.

Utilizar Starlette directamente

FastAPI no expone a Starlette tanto como a Pydantic. Starlette es en gran medida la maquinaria que zumba en la sala de máquinas, manteniendo la nave en marcha sin problemas.

Pero si tienes curiosidad, puedes utilizar Starlette directamente para escribir una aplicación web.El Ejemplo 3-1 del capítulo anterior podría parecerse al Ejemplo 4-7.

Ejemplo 4-7. Utilizar Starlette: starlette_hello.py
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route

async def greeting(request):
    return JSONResponse('Hello? World?')

app = Starlette(debug=True, routes=[
    Route('/hi', greeting),
])

Ejecuta esta aplicación web con esto:

$ uvicorn starlette_hello:app

En mi opinión, los añadidos de FastAPI facilitan mucho el desarrollo de APIs web.

Interludio: Limpiar la casa de pistas

tienes una pequeña (muy pequeña: sólo tú) empresa de limpieza de casas. Has estado viviendo a base de ramen, pero acabas de conseguir un contrato que te permitirá permitirte un ramen mucho mejor.

Tu cliente compró una vieja mansión que se construyó al estilo del juego de mesa Cluedo y quiere celebrar allí pronto una fiesta de personajes. Pero el lugar es un increíble desastre. Si Marie Kondo viera el lugar, haría lo siguiente:

  • Grita

  • Mordaza

  • Huye

  • Todas las anteriores

Tu contrato incluye una bonificación por velocidad. ¿Cómo puedes limpiar el lugar a fondo, en el menor tiempo posible? Lo mejor habría sido disponer de más Unidades de Conservación de Pistas (CPU), pero ya está.

Así que puedes probar uno de estos:

  • Hazlo todo en una habitación, luego todo en la siguiente, etc.

  • Haz una tarea específica en una habitación, luego en la siguiente, etc. Como pulir la plata en la Cocina y el Comedor, o las bolas de billar en la Sala de Billar.

¿Difiere tu tiempo total para estos enfoques? Tal vez. Pero podría ser más importante considerar si tienes que esperar un tiempo apreciable para algún paso. Un ejemplo podría ser bajo los pies: después de limpiar las alfombras y encerar los suelos, podrían necesitar secarse durante horas antes de volver a mover los muebles sobre ellos.

Así pues, éste es tu plan para cada habitación:

  1. Limpia todas las partes estáticas (ventanas, etc.).

  2. Traslada todos los muebles de la habitación a la Sala.

  3. Elimina años de suciedad de la alfombra y/o del suelo de madera.

  4. Haz cualquiera de estas dos cosas:

    1. Espera a que se seque la alfombra o la cera, pero despídete de tu prima.

    2. Ve ahora a la siguiente habitación, y repite. Después de la última habitación, vuelve a mover los muebles a la primera habitación, y así sucesivamente.

El enfoque de esperar a que se seque es el síncrono, y puede ser el mejor si el tiempo no es un factor y necesitas un descanso. El segundo es asíncrono y guarda el tiempo de espera para cada sala.

Supongamos que eliges el camino asíncrono, por dinero. Consigues que el viejo vertedero brille y recibes esa bonificación de tu agradecido cliente. La fiesta posterior resulta ser un gran éxito, salvo por estos problemas:

  1. Un invitado sin memoria vino como Mario.

  2. Enceraste demasiado la pista de baile del Salón de Baile, y un achispado profesor Ciruela patinó en calcetines, hasta que chocó contra una mesa y derramó champán sobre la señorita Escarlata.

Moraleja de esta historia:

  • Los requisitos pueden ser contradictorios y/o extraños.

  • Estimar el tiempo y el esfuerzo puede depender de muchos factores.

  • Secuenciar tareas puede ser tanto arte como ciencia.

  • Te sentirás muy bien cuando esté todo hecho. Mmm, ramen.

Revisa

Tras una visión general de las formas de aumentar la concurrencia, este capítulo se ha extendido en las funciones que utilizan las recientes palabras clave de Python async y await. Ha mostrado cómo FastAPI y Starlette manejan tanto las antiguas funciones síncronas como estas nuevas funciones asíncronas funky.

El siguiente capítulo presenta la segunda pata de FastAPI: cómo Pydantic te ayuda a definir tus datos.

Get FastAPI now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.