12k
All articles

async/await en JavaScript vs Rust vs Python

Compara async/await en JavaScript, Rust y Python: descubre cuándo se ejecutan las funciones, qué ocurre si omites await y cómo ejecutar tareas en paralelo.

OpenReplay Team
OpenReplay Team
async/await en JavaScript vs Rust vs Python

Una función async de JavaScript empieza a ejecutarse en el momento en que la llamas. Un async fn de Rust o un async def de Python no hace absolutamente nada hasta que algo lo espera con await o lo planifica para su ejecución.

Esta diferencia en el momento en que arranca el trabajo asíncrono provoca un bug muy conocido. Al portar a Rust o Python un código TypeScript que funciona, la nueva versión o bien nunca ejecuta la función, o bien ejecuta las llamadas una tras otra cuando esperabas que se solaparan. Este artículo ejecuta el mismo fragmento de código en Node.js, en Rust con Tokio y en Python 3.11+, y muestra el orden de salida en cada caso. Se da por hecho que ya conoces la programación asíncrona en JavaScript. Si antes quieres repasar esos conceptos, consulta las guías de OpenReplay sobre buenas prácticas de programación asíncrona en JavaScript y consejos, trucos y trampas de la programación asíncrona en JavaScript.

Puntos clave

  • Al llamar a una función async en JavaScript, su cuerpo se ejecuta de forma síncrona hasta el primer await, por lo que el código anterior a ese await se ejecuta de inmediato.
  • Al llamar a un async fn de Rust se obtiene un Future, y no se ejecuta nada del cuerpo hasta que se espera el future con .await o se entrega a un runtime como Tokio.
  • Al llamar a una función async def de Python se obtiene un objeto corrutina que no ejecuta nada hasta que se espera con await o se envuelve en una tarea.
  • Si creas dos llamadas async y luego esperas cada una por turnos, el trabajo se solapa en JavaScript, pero se ejecuta secuencialmente en Rust y en Python.
  • Los runtimes de JavaScript traen un event loop integrado. Rust necesita un executor como #[tokio::main], y Python inicia su loop con asyncio.run().

Un fragmento, tres lenguajes, tres salidas

Cada una de las versiones siguientes define la misma función: imprime un mensaje, espera con await una pausa de 10 ms y vuelve a imprimir. Después, cada versión llama a la función sin esperarla. Solo JavaScript imprime a: start.

Los ejemplos están pensados para una versión LTS de Node.js, Tokio 1.x y Python 3.11 o posterior.

JavaScript (Node.js)

async function work(label) {
  console.log(`${label}: start`);
  await new Promise((r) => setTimeout(r, 10));
  console.log(`${label}: end`);
}

work("a"); // no await
console.log("main: after call");
a: start
main: after call
a: end

Rust (Tokio) con tokio = { version = "1", features = ["macros", "rt", "time"] }

use std::time::Duration;

async fn work(label: &'static str) {
    println!("{}: start", label);
    tokio::time::sleep(Duration::from_millis(10)).await;
    println!("{}: end", label);
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
    work("a"); // future created, never awaited
    println!("main: after call");
}
main: after call

Python 3.11+

import asyncio

async def work(label):
    print(f"{label}: start")
    await asyncio.sleep(0.01)
    print(f"{label}: end")

async def main():
    work("a")  # coroutine created, never awaited
    print("main: after call")

asyncio.run(main())
main: after call

¿Por qué async/await en Rust no se ejecuta hasta que se hace await?

La diferencia entre async/await en JavaScript, Rust y Python radica en lo que devuelve la llamada a una función async. JavaScript devuelve una Promise asociada a un trabajo que ya ha comenzado. Rust y Python devuelven un objeto que representa un trabajo que todavía no ha empezado.

  • JavaScript: una async function se ejecuta de forma síncrona hasta su primer await. La Promise que recibes pertenece a un cuerpo que ya se está ejecutando.
  • Rust: un async fn devuelve impl Future. El trait Future describe un valor que solo avanza cuando se le hace polling. No se ejecuta nada del cuerpo hasta que escribes .await o pasas el future a algo como tokio::spawn.
  • Python: llamar a una función async def te devuelve un objeto corrutina. La documentación de asyncio indica claramente que llamar a una corrutina no la planifica para su ejecución.

El artículo de investigación de Gray, Krishnamurthi y Crichton describe esta decisión como una dimensión de «Eagerness» (impaciencia) en el diseño asíncrono, con tres opciones: Lazy, Eager y Semi-Eager. En la práctica, JavaScript inicia el trabajo en el punto de llamada, mientras que los otros dos esperan.

¿Qué ocurre si olvidas el await?

Los tres lenguajes te permiten olvidar el await, pero fallan de maneras distintas. JavaScript sigue realizando el trabajo, así que el riesgo es un error que nadie captura. Rust y Python omiten el trabajo por completo y te lo advierten.

JavaScript. El cuerpo se ejecuta, pero si se rechaza y nada gestiona el rechazo, Node lo trata como un error. El modo predeterminado de --unhandled-rejections es throw, que lanza el rechazo como una excepción no capturada y finaliza el proceso, salvo que tengas registrado un handler:

async function save() { throw new Error("disk full"); }
save(); // no await, no .catch()

Rust. El compilador emite una advertencia en tiempo de compilación mediante el lint unused_must_use:

warning: unused implementer of `Future` that must be used
  = note: futures do nothing unless you `.await` or poll them

Python. Cuando el recolector de basura elimina la corrutina que no se esperó, CPython imprime una advertencia en stderr. La guía de asyncio lo trata en el apartado detecting never-awaited coroutines:

RuntimeWarning: coroutine 'work' was never awaited

La solución es la misma en cada lenguaje: await work("a") en JavaScript y Python, y work("a").await en Rust. Con ese cambio, los tres imprimen a: start, a: end, main: after call.

¿Cómo se ejecuta trabajo asíncrono de forma concurrente?

En JavaScript, basta con llamar a las funciones para obtener concurrencia. En Rust y Python, tienes que solicitarla explícitamente mediante un combinador o una tarea.

El patrón de crear y después esperar muestra claramente la diferencia:

const pa = work("a"), pb = work("b");
await pa; await pb;          // JS: a: start, b: start, a: end, b: end
let fa = work("a"); let fb = work("b");
fa.await; fb.await;          // Rust: a: start, a: end, b: start, b: end
ca = work("a"); cb = work("b")
await ca; await cb           # Python: a: start, a: end, b: start, b: end

Ni el future de Rust ni la corrutina de Python se inician hasta que se esperan, por lo que cada llamada solo se ejecuta cuando la anterior ha terminado. Las soluciones:

LenguajeEsperar a todasIniciar ahora, esperar después
JavaScriptPromise.allBasta con llamar a la función
Rusttokio::join!tokio::spawn
Pythonasyncio.gather, asyncio.TaskGroupasyncio.create_task

tokio::join! ejecuta varios futures de forma concurrente dentro de la tarea actual. tokio::spawn, en cambio, entrega el future al runtime como una tarea independiente y devuelve un JoinHandle que puedes esperar más adelante. Un future lanzado con spawn debe ser Send + 'static, y por eso work recibe un &'static str.

asyncio.TaskGroup se añadió en Python 3.11. Espera a que terminen todas las tareas cuando se sale de su bloque async with y, si una tarea lanza una excepción, cancela las demás:

async with asyncio.TaskGroup() as tg:
    tg.create_task(work("a"))
    tg.create_task(work("b"))

Ni siquiera lanzar una tarea es tan inmediato como una llamada en JavaScript. El cuerpo de una tarea lanzada no se ejecuta hasta que el código llamante cede el control:

let h = tokio::spawn(work("a"));
println!("main: after spawn");
h.await.unwrap();
t = asyncio.create_task(work("a"))
print("main: after create_task")
await t

Ambos imprimen primero main: after ... y después a: start y a: end. JavaScript imprime a: start antes de la siguiente línea del código llamante. Una salvedad: el orden en Rust se mantiene con current_thread. En un runtime multihilo, otro worker puede recoger la tarea lanzada de inmediato, por lo que las líneas pueden intercalarse de otra forma.

¿De dónde sale el event loop?

Los runtimes de JavaScript traen un event loop integrado, mientras que Rust y Python te obligan a iniciar uno: Rust mediante un executor como #[tokio::main], y Python mediante asyncio.run().

  • JavaScript: Node.js y los navegadores ya tienen un event loop en marcha antes de que se ejecute tu código. El artículo de OpenReplay sobre el event loop y la call stack de JavaScript explica cómo funciona. En Node, el await de nivel superior funciona en módulos ES (archivos .mjs, o "type": "module" en package.json).
  • Rust: la biblioteca estándar no incluye ningún executor. #[tokio::main] envuelve async fn main en un runtime de Tokio. Por defecto utiliza el runtime multihilo (que requiere la feature rt-multi-thread), y flavor = "current_thread" lo cambia por uno de un solo hilo.
  • Python: async def forma parte del lenguaje y no necesita ningún import. Sin embargo, para ejecutar una corrutina necesitas un loop, y asyncio.run() crea uno, ejecuta tu corrutina de nivel superior y después cierra el loop.

Resumen comparativo

JavaScriptRust (Tokio)Python (asyncio)
Llamar a una función asyncSe ejecuta hasta el primer awaitDevuelve un Future inerteDevuelve una corrutina inerte
await olvidadoEl trabajo se ejecuta; un rechazo puede tumbar NodeAdvertencia del compilador, no se ejecuta nadaRuntimeWarning, no se ejecuta nada
ConcurrenciaPromise.alljoin! / spawngather / TaskGroup / create_task
RuntimeIntegradoHay que elegir un executorasyncio.run()

Conclusión

La sintaxis de async/await parece la misma en JavaScript, Rust y Python, pero los tres lenguajes discrepan sobre cuándo comienza el trabajo. JavaScript lo inicia en el momento de la llamada. Rust y Python solo lo inician cuando esperas el resultado o lo planificas explícitamente. Al portar código asíncrono, revisa cada punto de llamada cuyo resultado no se espere de inmediato. En Rust, sustituye esas llamadas por tokio::join! o tokio::spawn. En Python, utiliza asyncio.gather, TaskGroup o create_task.

Preguntas frecuentes

¿Pueden las tareas de asyncio en Python iniciarse de forma inmediata, como las funciones async de JavaScript?

Sí, de forma opcional. Python 3.12 añadió asyncio.eager_task_factory, que se instala con loop.set_task_factory(asyncio.eager_task_factory). A partir de ese momento, una nueva tarea empieza a ejecutar su corrutina de inmediato dentro de la llamada a create_task, hasta el primer punto en el que se bloquee. Si la corrutina termina sin bloquearse, nunca llega a pasar por el event loop. La planificación diferida sigue siendo el comportamiento predeterminado, y cambiar a tareas inmediatas puede alterar el orden de ejecución.

¿Qué ocurre con una tarea de tokio::spawn si descartas su JoinHandle?

La tarea sigue ejecutándose. Al descartar un JoinHandle, la tarea queda desvinculada y continúa en segundo plano, pero su valor de retorno se pierde y nada puede hacer join sobre ella. Esto es distinto de un future no esperado devuelto por la llamada a un async fn, que nunca llega a ejecutarse. Para detener una tarea lanzada, llama a abort() sobre el JoinHandle. Esperar una tarea abortada devuelve un JoinError que indica la cancelación.

¿Cuál es la diferencia entre asyncio.gather y asyncio.TaskGroup cuando falla una tarea?

asyncio.gather no cancela las demás tareas cuando una lanza una excepción. Con el valor predeterminado return_exceptions=False, la primera excepción llega de inmediato al código que espera, mientras las tareas restantes siguen ejecutándose. Con return_exceptions=True, las excepciones se devuelven en la lista de resultados. asyncio.TaskGroup, añadido en Python 3.11, cancela las tareas restantes cuando una falla, de modo que el trabajo fallido no sigue ejecutándose sin que nadie lo advierta.

¿Por qué una tarea de asyncio lanzada sin esperar su resultado (fire-and-forget) puede desaparecer antes de terminar?

El event loop de asyncio solo mantiene referencias débiles a las tareas, por lo que el recolector de basura puede eliminar a mitad de ejecución una tarea creada con asyncio.create_task si tu código nunca la almacena. La documentación de Python recomienda conservar una referencia, por ejemplo añadiendo cada tarea a un set y llamando a task.add_done_callback(background_tasks.discard). Un asyncio.TaskGroup evita el problema porque mantiene una referencia fuerte a cada tarea y las espera.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.