Saltar al contenido

Concurrencia y async / await

Detalles sobre la sintaxis async def para funciones de path operation y algo de contexto sobre código asincrónico, concurrencia y paralelismo.

¿Tienes prisa?

TL;DR:

Si estás usando librerías de terceros que te dicen que las llames con await, como:

results = await some_library()

Entonces, declara tus funciones de path operation con async def así:

@app.get('/')
async def read_results():
    results = await some_library()
    return results

Nota

Solo puedes usar await dentro de funciones creadas con async def.


Si estás usando una librería de terceros que se comunica con algo (una base de datos, una API, el sistema de archivos, etc.) y no tiene soporte para usar await, (este es actualmente el caso de la mayoría de las librerías de bases de datos), entonces declara tus funciones de path operation de forma normal, con solo def, así:

@app.get('/')
def results():
    results = some_library()
    return results

Si tu aplicación (de alguna manera) no tiene que comunicarse con nada más y esperar a que responda, usa async def, incluso si no necesitas usar await dentro.


Si simplemente no lo sabes, usa def normal.


Nota: Puedes mezclar def y async def en tus funciones de path operation tanto como necesites y definir cada una usando la mejor opción para ti. FastAPI hará lo correcto con ellas.

De todas formas, en cualquiera de los casos anteriores, FastAPI seguirá funcionando de forma asincrónica y siendo extremadamente rápido.

Pero siguiendo los pasos anteriores, podrá hacer algunas optimizaciones de rendimiento.

Detalles Técnicos

Las versiones modernas de Python tienen soporte para "código asincrónico" usando algo llamado "corrutinas", con la sintaxis async y await.

Veamos esa frase por partes en las secciones a continuación:

  • Asynchronous Code
  • async and await
  • Coroutines

Código Asincrónico

El código asincrónico simplemente significa que el lenguaje 💬 tiene una forma de decirle a la computadora / programa 🤖 que en algún punto del código, tendrá que esperar a que algo más termine en otro lugar. Digamos que algo más se llama "archivo-lento" 📝.

Así que, durante ese tiempo, la computadora puede ir y hacer otro trabajo, mientras el "archivo-lento" 📝 termina.

Luego la computadora / programa 🤖 volverá cada vez que tenga la oportunidad porque está esperando de nuevo, o cuando 🤖 termine todo el trabajo que tenía en ese momento. Y 🤖 verá si alguna de las tareas que estaba esperando ya terminaron, haciendo lo que tuviera que hacer.

Luego, 🤖 toma la primera tarea en terminar (digamos, nuestro "archivo-lento" 📝) y continúa lo que tuviera que hacer con ella.

Ese "esperar a algo más" normalmente se refiere a operaciones de I/O que son relativamente "lentas" (comparadas con la velocidad del procesador y la memoria RAM), como esperar a:

  • que los datos del cliente se envíen a través de la red
  • que los datos enviados por tu programa sean recibidos por el cliente a través de la red
  • que el contenido de un archivo en el disco sea leído por el sistema y dado a tu programa
  • que el contenido que tu programa le dio al sistema sea escrito al disco
  • una operación de una API remota
  • que una operación de base de datos termine
  • que una consulta a la base de datos devuelva los resultados
  • etc.

Como el tiempo de ejecución se consume principalmente esperando operaciones de I/O, se les llama operaciones "I/O bound".

Se llama "asincrónico" porque la computadora / programa no tiene que estar "sincronizada" con la tarea lenta, esperando el momento exacto en que la tarea termine, sin hacer nada, para poder tomar el resultado de la tarea y continuar el trabajo.

En lugar de eso, al ser un sistema "asincrónico", una vez terminada, la tarea puede esperar en la fila un poco (algunos microsegundos) a que la computadora / programa termine lo que sea que fue a hacer, y luego volver para tomar los resultados y continuar trabajando con ellos.

Para "sincrónico" (contrario a "asincrónico") también se usa comúnmente el término "secuencial", porque la computadora / programa sigue todos los pasos en secuencia antes de cambiar a una tarea diferente, incluso si esos pasos implican esperar.

Concurrencia y Hamburguesas

Esta idea de código asincrónico descrita arriba también se llama a veces "concurrencia". Es diferente de "paralelismo".

La concurrencia y el paralelismo ambos se relacionan con "cosas diferentes que pasan más o menos al mismo tiempo".

Pero los detalles entre concurrencia y paralelismo son bastante diferentes.

Para ver la diferencia, imagina la siguiente historia sobre hamburguesas:

Hamburguesas Concurrentes

Vas con tu crush a comprar comida rápida, te pones en la fila mientras el cajero toma los pedidos de las personas delante de ti. 😍

Luego es tu turno, haces tu pedido de 2 hamburguesas muy elegantes para tu crush y para ti. 🍔🍔

El cajero le dice algo al cocinero en la cocina para que sepan que tienen que preparar tus hamburguesas (aunque actualmente están preparando las de los clientes anteriores).

Pagas. 💸

El cajero te da el número de tu turno.

Mientras esperas, vas con tu crush y eliges una mesa, se sientan y hablan con tu crush por un largo rato (como tus hamburguesas son muy elegantes y tardan un poco en prepararse).

Mientras estás sentado en la mesa con tu crush, esperando las hamburguesas, puedes aprovechar ese tiempo admirando lo increíble, lindo e inteligente que es tu crush ✨😍✨.

Mientras esperas y hablas con tu crush, de vez en cuando, revisas el número mostrado en el mostrador para ver si ya es tu turno.

Luego en algún momento, finalmente es tu turno. Vas al mostrador, tomas tus hamburguesas y vuelves a la mesa.

Tú y tu crush comen las hamburguesas y la pasan bien. ✨

Nota

Hermosas ilustraciones de Ketrina Thompson. 🎨


Imagina que eres la computadora / programa 🤖 en esa historia.

Mientras estás en la fila, simplemente estás inactivo 😴, esperando tu turno, sin hacer nada muy "productivo". Pero la fila es rápida porque el cajero solo está tomando los pedidos (no preparándolos), así que está bien.

Luego, cuando es tu turno, haces trabajo "productivo" real, procesas el menú, decides qué quieres, obtienes la elección de tu crush, pagas, verificas que das el billete o tarjeta correctos, verificas que te cobren correctamente, verificas que el pedido tenga los items correctos, etc.

Pero luego, aunque todavía no tienes tus hamburguesas, tu trabajo con el cajero está "en pausa" ⏸, porque tienes que esperar 🕙 a que tus hamburguesas estén listas.

Pero al alejarte del mostrador y sentarte en la mesa con un número para tu turno, puedes cambiar 🔀 tu atención a tu crush, y "trabajar" ⏯ 🤓 en eso. Entonces estás otra vez haciendo algo muy "productivo" como es coquetear con tu crush 😍.

Luego el cajero 💁 dice "Terminé de hacer las hamburguesas" poniendo tu número en la pantalla del mostrador, pero no saltas como loco inmediatamente cuando el número mostrado cambia a tu turno. Sabes que nadie te robará las hamburguesas porque tienes el número de tu turno, y ellos tienen el suyo.

Así que esperas a que tu crush termine la historia (terminar el trabajo actual ⏯ / tarea siendo procesada 🤓), sonríes suavemente y dices que vas por las hamburguesas ⏸.

Luego vas al mostrador 🔀, a la tarea inicial que ahora está terminada ⏯, tomas las hamburguesas, das las gracias y las llevas a la mesa. Eso termina ese paso / tarea de interacción con el mostrador ⏹. Eso, a su vez, crea una nueva tarea, de "comer hamburguesas" 🔀 ⏯, pero la anterior de "conseguir hamburguesas" está terminada ⏹.

Hamburguesas Paralelas

Ahora imaginemos que estas no son "Hamburguesas Concurrentes", sino "Hamburguesas Paralelas".

Vas con tu crush a comprar comida rápida paralela.

Te pones en la fila mientras varios (digamos 8) cajeros que al mismo tiempo son cocineros toman los pedidos de las personas delante de ti.

Todos los que están antes que tú están esperando que sus hamburguesas estén listas antes de dejar el mostrador porque cada uno de los 8 cajeros va y prepara la hamburguesa inmediatamente antes de tomar el siguiente pedido.

Luego finalmente es tu turno, haces tu pedido de 2 hamburguesas muy elegantes para tu crush y para ti.

Pagas 💸.

El cajero va a la cocina.

Esperas, de pie frente al mostrador 🕙, para que nadie más tome tus hamburguesas antes que tú, ya que no hay números para turnos.

Como tú y tu crush están ocupados no dejando que nadie se ponga delante y tome sus hamburguesas cuando lleguen, no pueden prestar atención a tu crush. 😞

Este es trabajo "sincrónico", estás "sincronizado" con el cajero/cocinero 👨‍🍳. Tienes que esperar 🕙 y estar ahí en el momento exacto en que el cajero/cocinero 👨‍🍳 termine las hamburguesas y te las dé, o de lo contrario, alguien más podría tomarlas.

Luego tu cajero/cocinero 👨‍🍳 finalmente vuelve con tus hamburguesas, después de un largo tiempo esperando 🕙 ahí frente al mostrador.

Tomas tus hamburguesas y vas a la mesa con tu crush.

Simplemente las comes, y has terminado. ⏹

No hubo mucha conversación ni coqueteo ya que la mayor parte del tiempo se pasó esperando 🕙 frente al mostrador. 😞

Nota

Hermosas ilustraciones de Ketrina Thompson. 🎨


En este escenario de las hamburguesas paralelas, eres una computadora / programa 🤖 con dos procesadores (tú y tu crush), ambos esperando 🕙 y dedicando su atención ⏯ a "esperar en el mostrador" 🕙 por un largo tiempo.

El local de comida rápida tiene 8 procesadores (cajeros/cocineros). Mientras que el local de hamburguesas concurrentes podría haber tenido solo 2 (un cajero y un cocinero).

Pero aún así, la experiencia final no es la mejor. 😞


Esta sería la historia equivalente paralela para las hamburguesas. 🍔

Para un ejemplo más "de la vida real" de esto, imagina un banco.

Hasta hace poco, la mayoría de los bancos tenían múltiples cajeros 👨‍💼👨‍💼👨‍💼👨‍💼 y una gran fila 🕙🕙🕙🕙🕙🕙🕙🕙.

Todos los cajeros haciendo todo el trabajo con un cliente tras otro 👨‍💼⏯.

Y tienes que esperar 🕙 en la fila por un largo tiempo o pierdes tu turno.

Probablemente no querrías llevar a tu crush 😍 contigo a hacer diligencias al banco 🏦.

Conclusión sobre las Hamburguesas

En este escenario de "hamburguesas de comida rápida con tu crush", como hay mucha espera 🕙, tiene mucho más sentido tener un sistema concurrente ⏸🔀⏯.

Este es el caso de la mayoría de las aplicaciones web.

Muchos, muchos usuarios, pero tu servidor está esperando 🕙 su conexión no tan buena para enviar sus peticiones.

Y luego esperando 🕙 otra vez a que las respuestas vuelvan.

Esta "espera" 🕙 se mide en microsegundos, pero aún así, sumándolo todo, es mucha espera al final.

Por eso tiene mucho sentido usar código asincrónico ⏸🔀⏯ para APIs web.

Este tipo de asincronicidad es lo que hizo popular a NodeJS (aunque NodeJS no es paralelo) y esa es la fortaleza de Go como lenguaje de programación.

Y ese es el mismo nivel de rendimiento que obtienes con FastAPI.

Y como puedes tener paralelismo y asincronicidad al mismo tiempo, obtienes un rendimiento superior al de la mayoría de los frameworks NodeJS probados y a la par de Go, que es un lenguaje compilado más cercano a C (todo gracias a Starlette).

¿Es la concurrencia mejor que el paralelismo?

¡No! Esa no es la moraleja de la historia.

La concurrencia es diferente del paralelismo. Y es mejor en escenarios específicos que involucran mucha espera. Por eso, generalmente es mucho mejor que el paralelismo para el desarrollo de aplicaciones web. Pero no para todo.

Así que, para equilibrar las cosas, imagina la siguiente breve historia:

Tienes que limpiar una casa grande y sucia.

Sí, esa es toda la historia.


No hay espera 🕙 en ninguna parte, solo mucho trabajo por hacer, en múltiples lugares de la casa.

Podrías tener turnos como en el ejemplo de las hamburguesas, primero la sala, luego la cocina, pero como no estás esperando 🕙 nada, solo limpiando y limpiando, los turnos no afectarían nada.

Tomaría la misma cantidad de tiempo terminar con o sin turnos (concurrencia) y habrías hecho la misma cantidad de trabajo.

Pero en este caso, si pudieras traer a los 8 ex-cajeros/cocineros/ahora-limpiadores, y cada uno de ellos (más tú) pudiera tomar una zona de la casa para limpiarla, podrías hacer todo el trabajo en paralelo, con la ayuda extra, y terminar mucho antes.

En este escenario, cada uno de los limpiadores (incluyéndote a ti) sería un procesador, haciendo su parte del trabajo.

Y como la mayor parte del tiempo de ejecución se dedica al trabajo real (en lugar de esperar), y el trabajo en una computadora lo hace un CPU, a estos problemas se les llama "CPU bound".


Ejemplos comunes de operaciones CPU bound son cosas que requieren procesamiento matemático complejo.

Por ejemplo:

  • Procesamiento de audio o imágenes.
  • Visión por computadora: una imagen está compuesta por millones de píxeles, cada píxel tiene 3 valores / colores, procesarlo normalmente requiere calcular algo sobre esos píxeles, todos al mismo tiempo.
  • Machine Learning: normalmente requiere muchas multiplicaciones de "matrices" y "vectores". Piensa en una hoja de cálculo enorme con números y multiplicarlos todos juntos al mismo tiempo.
  • Deep Learning: este es un subcampo de Machine Learning, así que, lo mismo aplica. Solo que no hay una sola hoja de cálculo de números para multiplicar, sino un conjunto enorme de ellas, y en muchos casos, usas un procesador especial para construir y/o usar esos modelos.

Concurrencia + Paralelismo: Web + Machine Learning

Con FastAPI puedes aprovechar la concurrencia que es muy común para el desarrollo web (el mismo principal atractivo de NodeJS).

Pero también puedes explotar los beneficios del paralelismo y el multiprocesamiento (tener múltiples procesos ejecutándose en paralelo) para cargas de trabajo CPU bound como las de los sistemas de Machine Learning.

Eso, más el simple hecho de que Python es el lenguaje principal para Data Science, Machine Learning y especialmente Deep Learning, hace que FastAPI sea una muy buena combinación para APIs web de Data Science / Machine Learning y aplicaciones (entre muchas otras).

Para ver cómo lograr este paralelismo en producción, consulta la sección sobre Deployment.

async y await

Las versiones modernas de Python tienen una forma muy intuitiva de definir código asincrónico. Esto hace que se vea igual que el código "secuencial" normal y haga el "awaiting" por ti en los momentos correctos.

Cuando hay una operación que requerirá esperar antes de dar los resultados y tiene soporte para estas nuevas características de Python, puedes codificarla así:

burgers = await get_burgers(2)

La clave aquí es el await. Le dice a Python que tiene que esperar ⏸ a que get_burgers(2) termine de hacer lo suyo 🕙 antes de guardar los resultados en burgers. Con eso, Python sabrá que puede ir y hacer algo más 🔀 ⏯ mientras tanto (como recibir otra petición).

Para que await funcione, tiene que estar dentro de una función que soporte esta asincronicidad. Para hacerlo, simplemente la declaras con async def:

async def get_burgers(number: int):
    # Do some asynchronous stuff to create the burgers
    return burgers

...en lugar de def:

# This is not asynchronous
def get_sequential_burgers(number: int):
    # Do some sequential stuff to create the burgers
    return burgers

Con async def, Python sabe que, dentro de esa función, tiene que estar atento a expresiones await, y que puede "pausar" ⏸ la ejecución de esa función e ir a hacer algo más 🔀 antes de volver.

Cuando quieres llamar a una función async def, tienes que hacerle "await". Así que, esto no funcionará:

# This won't work, because get_burgers was defined with: async def
burgers = get_burgers(2)

Así que, si estás usando una librería que te dice que puedes llamarla con await, necesitas crear las funciones de path operation que la usan con async def, como en:

@app.get('/burgers')
async def read_burgers():
    burgers = await get_burgers(2)
    return burgers

Más detalles técnicos

Puede que hayas notado que await solo se puede usar dentro de funciones definidas con async def.

Pero al mismo tiempo, las funciones definidas con async def tienen que ser "awaited". Así que, las funciones con async def solo pueden ser llamadas dentro de funciones definidas con async def también.

Así que, sobre el huevo y la gallina, ¿cómo llamas a la primera función async?

Si estás trabajando con FastAPI no tienes que preocuparte por eso, porque esa "primera" función será tu función de path operation, y FastAPI sabrá cómo hacer lo correcto.

Pero si quieres usar async / await sin FastAPI, también puedes hacerlo.

Escribe tu propio código async

Starlette (y FastAPI) están basados en AnyIO, lo que los hace compatibles tanto con asyncio de la librería estándar de Python como con Trio.

En particular, puedes usar AnyIO directamente para tus casos de uso de concurrencia avanzada que requieren patrones más avanzados en tu propio código.

E incluso si no estuvieras usando FastAPI, también podrías escribir tus propias aplicaciones async con AnyIO para ser altamente compatible y obtener sus beneficios (p. ej. concurrencia estructurada).

Creé otra librería sobre AnyIO, como una capa delgada por encima, para mejorar un poco las anotaciones de tipos y obtener mejor autocompletado, errores en línea, etc. También tiene una introducción amigable y un tutorial para ayudarte a entender y escribir tu propio código async: Asyncer. Sería particularmente útil si necesitas combinar código async con código regular (bloqueante/sincrónico).

Otras formas de código asincrónico

Este estilo de usar async y await es relativamente nuevo en el lenguaje.

Pero hace que trabajar con código asincrónico sea mucho más fácil.

Esta misma sintaxis (o casi idéntica) también se incluyó recientemente en versiones modernas de JavaScript (en el navegador y NodeJS).

Pero antes de eso, manejar código asincrónico era bastante más complejo y difícil.

En versiones anteriores de Python, podrías haber usado threads o Gevent. Pero el código es mucho más complejo de entender, depurar y pensar.

En versiones anteriores de NodeJS / JavaScript del navegador, habrías usado "callbacks". Lo que lleva al "callback hell".

Corrutinas

Corrutina es simplemente el término muy elegante para lo que devuelve una función async def. Python sabe que es algo parecido a una función, que puede iniciarse y que terminará en algún momento, pero que también podría pausarse ⏸ internamente, siempre que haya un await dentro de ella.

Pero toda esta funcionalidad de usar código asincrónico con async y await se resume muchas veces como usar "corrutinas". Es comparable a la principal característica clave de Go, las "Goroutines".

Conclusión

Veamos la misma frase de arriba:

Las versiones modernas de Python tienen soporte para "código asincrónico" usando algo llamado "corrutinas", con la sintaxis async y await.

Eso debería tener más sentido ahora. ✨

Todo eso es lo que impulsa a FastAPI (a través de Starlette) y lo que hace que tenga un rendimiento tan impresionante.

Detalles Muy Técnicos

Aviso

Probablemente puedas saltarte esto.

Estos son detalles muy técnicos sobre cómo funciona FastAPI por debajo.

Si tienes bastante conocimiento técnico (corrutinas, threads, bloqueo, etc.) y tienes curiosidad sobre cómo FastAPI maneja async def vs def normal, sigue adelante.

Funciones de path operation

Cuando declaras una función de path operation con def normal en lugar de async def, se ejecuta en un threadpool externo que luego es awaited, en lugar de ser llamada directamente (ya que bloquearía el servidor).

Si vienes de otro framework async que no funciona de la manera descrita arriba y estás acostumbrado a definir funciones de path operation triviales de solo cálculo con def plano para una pequeña ganancia de rendimiento (unos 100 nanosegundos), ten en cuenta que en FastAPI el efecto sería bastante opuesto. En estos casos, es mejor usar async def a menos que tus funciones de path operation usen código que realiza I/O bloqueante.

Aún así, en ambas situaciones, lo más probable es que FastAPI sea aún más rápido que (o al menos comparable a) tu framework anterior.

Dependencias

Lo mismo aplica para las dependencias. Si una dependencia es una función def estándar en lugar de async def, se ejecuta en el threadpool externo.

Sub-dependencias

Puedes tener múltiples dependencias y sub-dependencias que se requieren entre sí (como parámetros de las definiciones de funciones), algunas pueden ser creadas con async def y otras con def normal. Seguiría funcionando, y las creadas con def normal serían llamadas en un thread externo (del threadpool) en lugar de ser "awaited".

Otras funciones de utilidad

Cualquier otra función de utilidad que llames directamente puede ser creada con def normal o async def y FastAPI no afectará la forma en que la llamas.

Esto contrasta con las funciones que FastAPI llama por ti: funciones de path operation y dependencias.

Si tu función de utilidad es una función normal con def, será llamada directamente (como la escribes en tu código), no en un threadpool, si la función es creada con async def entonces deberías hacer await a esa función cuando la llames en tu código.


De nuevo, estos son detalles muy técnicos que probablemente te sean útiles si viniste buscándolos.

De lo contrario, deberías estar bien con las directrices de la sección anterior: ¿Tienes prisa?.