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.
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 eseawaitse ejecuta de inmediato. - Al llamar a un
async fnde Rust se obtiene unFuture, y no se ejecuta nada del cuerpo hasta que se espera el future con.awaito se entrega a un runtime como Tokio. - Al llamar a una función
async defde Python se obtiene un objeto corrutina que no ejecuta nada hasta que se espera conawaito 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 conasyncio.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 functionse ejecuta de forma síncrona hasta su primerawait. La Promise que recibes pertenece a un cuerpo que ya se está ejecutando. - Rust: un
async fndevuelveimpl Future. El traitFuturedescribe un valor que solo avanza cuando se le hace polling. No se ejecuta nada del cuerpo hasta que escribes.awaito pasas el future a algo comotokio::spawn. - Python: llamar a una función
async defte 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:
| Lenguaje | Esperar a todas | Iniciar ahora, esperar después |
|---|---|---|
| JavaScript | Promise.all | Basta con llamar a la función |
| Rust | tokio::join! | tokio::spawn |
| Python | asyncio.gather, asyncio.TaskGroup | asyncio.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
awaitde nivel superior funciona en módulos ES (archivos.mjs, o"type": "module"enpackage.json). - Rust: la biblioteca estándar no incluye ningún executor.
#[tokio::main]envuelveasync fn mainen un runtime de Tokio. Por defecto utiliza el runtime multihilo (que requiere la featurert-multi-thread), yflavor = "current_thread"lo cambia por uno de un solo hilo. - Python:
async defforma parte del lenguaje y no necesita ningún import. Sin embargo, para ejecutar una corrutina necesitas un loop, yasyncio.run()crea uno, ejecuta tu corrutina de nivel superior y después cierra el loop.
Resumen comparativo
| JavaScript | Rust (Tokio) | Python (asyncio) | |
|---|---|---|---|
| Llamar a una función async | Se ejecuta hasta el primer await | Devuelve un Future inerte | Devuelve una corrutina inerte |
await olvidado | El trabajo se ejecuta; un rechazo puede tumbar Node | Advertencia del compilador, no se ejecuta nada | RuntimeWarning, no se ejecuta nada |
| Concurrencia | Promise.all | join! / spawn | gather / TaskGroup / create_task |
| Runtime | Integrado | Hay que elegir un executor | asyncio.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.
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