Async/Await en Rust
Imagina un restaurante. En la página de concurrencia aprendiste sobre los hilos (threads). Eso es como contratar un mesero por mesa. Funciona, pero los meseros cuestan caro y, la mayor parte del tiempo, solo están ahí parados esperando a la cocina.
Async es como tener un solo mesero que atiende muchos pedidos. El mesero toma el pedido de la mesa 1, se lo pasa a la cocina y, mientras se cocina la comida, va a la mesa 2. Nadie se queda parado sin hacer nada. Esa es la idea detrás de async y .await en Rust: mientras una tarea está esperando (a la red, a un archivo, a un temporizador), otra tarea puede ejecutarse.
Async brilla cuando tu programa pasa mucho tiempo esperando: servidores web, clientes de APIs, apps de chat, bots. Esta página usa tokio, el runtime async más popular de Rust.
Preparar tokio
Sección titulada «Preparar tokio»La biblioteca estándar de Rust te da las palabras clave async y .await, pero no el “motor” que de verdad ejecuta el código async. Ese motor se llama runtime (entorno de ejecución), y tokio es el que usan la mayoría de los proyectos. Añádelo a un proyecto nuevo:
cargo new async_democd async_democargo add tokio --features fullEso añade esta línea a tu Cargo.toml:
[dependencies]tokio = { version = "1", features = ["full"] }La feature full lo activa todo (temporizadores, tareas, red y más). Es la opción más sencilla mientras aprendes. Los botones “Pruébalo tú mismo” de esta página también funcionan: el Playground de Rust trae tokio incluido.
Tu primer programa async
Sección titulada «Tu primer programa async»use std::time::Duration;
async fn make_coffee() -> String { tokio::time::sleep(Duration::from_millis(20)).await; String::from("café")}
#[tokio::main]async fn main() { println!("Pidiendo..."); let drink = make_coffee().await; println!("Aquí tienes tu {}", drink);}Salida:
Pidiendo...Aquí tienes tu caféAquí hay tres cosas nuevas:
async fncrea una función que puede pausarse a la mitad y continuar después..awaitsignifica “espera a que esto termine y, mientras esperamos, deja que se ejecuten otras tareas”. Solo puedes usar.awaitdentro de una funciónasync.#[tokio::main]arranca el runtime de tokio y ejecuta en él tuasync fn main. Sin esto,mainno puede serasync.
tokio::time::sleep es la versión async de “espera un poco”. Pausa solo esta tarea, no el hilo entero.
Los futures son perezosos
Sección titulada «Los futures son perezosos»Esta es la parte que sorprende a casi todo el mundo. Llamar a una async fn no la ejecuta. Te devuelve un future (futuro): un valor que significa “trabajo que se hará más adelante”. No pasa nada hasta que le haces .await:
async fn say_hello() { println!("¡Hola desde dentro del future!");}
#[tokio::main]async fn main() { let future = say_hello(); // todavía no se ejecuta nada println!("Future creado, pero sin empezar");
future.await; // ahora sí se ejecuta println!("Listo");}Salida:
Future creado, pero sin empezar¡Hola desde dentro del future!ListoPiensa en un future como en la comanda escrita de un pedido. Escribir la comanda no cocina la comida. Entregarla en la cocina (.await), sí.
Ejecutar tareas a la vez con join!
Sección titulada «Ejecutar tareas a la vez con join!»Hasta ahora esperamos una cosa cada vez. La verdadera potencia está en esperar varias cosas a la vez. tokio::join! ejecuta varios futures juntos y espera hasta que todos terminen. Te devuelve sus resultados en una tupla:
use std::time::Duration;use tokio::time::sleep;
async fn fetch_user() -> String { sleep(Duration::from_millis(30)).await; // imagina que esto es una llamada de red String::from("Ana")}
async fn fetch_order_count() -> u32 { sleep(Duration::from_millis(20)).await; 3}
#[tokio::main]async fn main() { let (user, orders) = tokio::join!(fetch_user(), fetch_order_count()); println!("{} tiene {} pedidos", user, orders);}Salida:
Ana tiene 3 pedidosSi los esperaras uno detrás de otro, la espera total sería de unos 30 + 20 = 50 ms. Con join!, las dos esperas ocurren al mismo tiempo, así que tarda unos 30 ms: lo que tarda el más lento. Con llamadas de red reales, esa diferencia se nota enseguida.
Lanzar tareas con tokio::spawn
Sección titulada «Lanzar tareas con tokio::spawn»join! es ideal cuando tienes un número fijo de futures. Cuando tienes una lista de trabajos, usa tokio::spawn. Entrega un future al runtime como una tarea independiente, la arranca de inmediato y te devuelve un JoinHandle. Más tarde, le haces .await a ese handle para obtener el resultado:
use std::time::Duration;use tokio::time::sleep;
async fn download(id: u64) -> String { sleep(Duration::from_millis(50 - id * 10)).await; format!("archivo {} descargado", id)}
#[tokio::main]async fn main() { let mut handles = Vec::new();
for id in 1..=3 { let handle = tokio::spawn(download(id)); // empieza a ejecutarse ya handles.push(handle); }
for handle in handles { let result = handle.await.unwrap(); println!("{}", result); }}Salida:
archivo 1 descargadoarchivo 2 descargadoarchivo 3 descargadoFíjate bien: el archivo 3 tiene la espera más corta, así que en realidad termina primero. Pero imprimimos los resultados en el orden en que esperamos los handles, así que la salida siempre es 1, 2, 3. Aun así, las tres descargas se ejecutaron al mismo tiempo.
¿Y el .unwrap()? Hacer .await a un JoinHandle devuelve un Result, porque la tarea podría haber provocado un panic. Si eso pasa, recibes un Err en lugar de que se caiga todo el programa. Esto se parece mucho a thread::spawn y .join() de la página de concurrencia, y no es casualidad.
Errores comunes
Sección titulada «Errores comunes»Olvidar el .await. Como los futures son perezosos, si olvidas el .await el trabajo nunca se hace. Si usas el valor, el compilador te frena:
async fn get_number() -> u32 { 5}
#[tokio::main]async fn main() { let n = get_number(); // se olvidó el .await println!("{}", n + 1);}error[E0369]: cannot add `{integer}` to `impl Future<Output = u32>` --> src/main.rs:8:22 |8 | println!("{}", n + 1); | - ^ - {integer} | | | impl Future<Output = u32>El mensaje dice que no se puede sumar un entero a un future: n es un future que dará un u32, no un u32. La solución es let n = get_number().await;. Si la función no devuelve nada y simplemente la llamas, como save_file();, solo recibes una advertencia (warning), así que lee tus advertencias:
warning: unused implementer of `Future` that must be used |7 | save_file(); | ^^^^^^^^^^^ | = note: futures do nothing unless you `.await` or poll themLa nota lo deja claro: los futures no hacen nada a menos que les hagas .await (o los sondees con poll).
Usar std::thread::sleep dentro de código async. Este compila sin problemas, y por eso es traicionero:
async fn slow_task() { std::thread::sleep(std::time::Duration::from_secs(1)); // ¡bloquea el hilo entero!}std::thread::sleep congela todo el hilo. El runtime no puede cambiar a otras tareas, así que todo lo que hay en ese hilo se detiene: como un mesero que se queda dormido de pie junto a una mesa. Dentro de código async, usa siempre tokio::time::sleep(...).await. Lo mismo vale para otros trabajos lentos que bloquean, como leer archivos enormes con std::fs: tokio tiene versiones async (tokio::fs), o puedes usar tokio::task::spawn_blocking.
Esperar que async acelere el trabajo de CPU. Async ayuda cuando las tareas están esperando. No ayuda cuando están ocupadas, como al redimensionar imágenes o hacer muchos cálculos. Un mesero no cocina más rápido por tomar más pedidos. Para trabajo pesado de CPU, usa hilos (o un crate como rayon).
¿Async o hilos?
Sección titulada «¿Async o hilos?»| Usa async cuando… | Usa hilos cuando… |
|---|---|
| Casi todo el tiempo se va en esperar (red, base de datos, temporizadores) | El trabajo es cálculo pesado |
| Necesitas miles de tareas a la vez (como un servidor web) | Solo necesitas unas pocas cosas en paralelo |
| Usas crates async como reqwest o axum | Prefieres no añadir la dependencia de un runtime |
No tienes que elegir solo uno. Muchos programas reales usan async para la red e hilos para el trabajo pesado.
¿Necesitas que las tareas se pasen mensajes entre sí? Tokio tiene su propio canal, tokio::sync::mpsc. Funciona como el de la biblioteca estándar que ves en la página de canales (channels), salvo que escribes rx.recv().await, así que esperar un mensaje no bloquea el hilo.
Qué se construye con async
Sección titulada «Qué se construye con async»Casi todo el ecosistema async de Rust está construido sobre tokio. Dos crates que verás por todas partes:
- reqwest (en inglés): un cliente HTTP para llamar a APIs web. Escribes
reqwest::get(url).awaitpara descargar una página. - axum (en inglés): un framework web para construir servidores y APIs, creado por el equipo de tokio.
Los dos combinan muy bien con serde_json para leer y escribir JSON.
Práctica
Sección titulada «Práctica»1. Prepara el desayuno de una vez
Sección titulada «1. Prepara el desayuno de una vez»Escribe tres funciones async: make_toast (espera 30 ms), fry_eggs (espera 20 ms) y brew_coffee (espera 10 ms). Cada una devuelve un String con el nombre de la comida. Ejecuta las tres a la vez e imprime lo que está listo. Pista: tokio::join! recibe varios futures y te devuelve una tupla con sus resultados.
Ver solución
use std::time::Duration;use tokio::time::sleep;
async fn make_toast() -> String { sleep(Duration::from_millis(30)).await; String::from("tostadas")}
async fn fry_eggs() -> String { sleep(Duration::from_millis(20)).await; String::from("huevos")}
async fn brew_coffee() -> String { sleep(Duration::from_millis(10)).await; String::from("café")}
#[tokio::main]async fn main() { let (toast, eggs, coffee) = tokio::join!(make_toast(), fry_eggs(), brew_coffee()); println!("Desayuno: {}, {} y {}", toast, eggs, coffee);}Salida:
Desayuno: tostadas, huevos y caféEl café está listo primero, pero la tupla siempre vuelve en el orden en que escribiste los futures. El desayuno completo tarda unos 30 ms (lo que tarda el plato más lento), no 60 ms.
2. Cuadrados en segundo plano
Sección titulada «2. Cuadrados en segundo plano»Escribe async fn square_later(n: u64) -> u64, que espera 10 * n milisegundos y luego devuelve n * n. En main, lanza una tarea por cada número del 1 al 4, espera a todas e imprime el total. Pista: guarda cada handle de tokio::spawn(...) en un Vec y después recorre los handles sumando handle.await.unwrap().
Ver solución
use std::time::Duration;use tokio::time::sleep;
async fn square_later(n: u64) -> u64 { sleep(Duration::from_millis(10 * n)).await; n * n}
#[tokio::main]async fn main() { let mut handles = Vec::new(); for n in 1..=4 { handles.push(tokio::spawn(square_later(n))); }
let mut total = 0; for handle in handles { total += handle.await.unwrap(); } println!("total = {total}");}Salida:
total = 30Las cuatro tareas empiezan enseguida y esperan al mismo tiempo, así que esto tarda unos 40 ms, no 10 + 20 + 30 + 40 = 100 ms.
Resumen
Sección titulada «Resumen»- Async permite que un solo hilo atienda muchas tareas, cambiando entre ellas mientras esperan.
- Rust necesita un runtime para ejecutar código async. Añade tokio con
cargo add tokio --features fully marcamaincon#[tokio::main]. - Una
async fndevuelve un future. Los futures son perezosos: no se ejecuta nada hasta que haces.await. - Usa
tokio::join!para esperar varios futures a la vez, ytokio::spawnpara lanzar tareas y obtener unJoinHandle. - Nunca uses
std::thread::sleepen código async: usatokio::time::sleep(...).await. - Async sirve para esperar, no para trabajo pesado de CPU. Para eso, usa hilos.
Comprueba lo que aprendiste
Sección titulada «Comprueba lo que aprendiste»Comprueba lo aprendido
1. ¿Qué pasa cuando llamas a una async fn sin .await?
2. ¿Qué hace #[tokio::main]?
3. Dos futures esperan 30 ms y 20 ms. ¿Cuánto tarda, más o menos, tokio::join! con los dos?
4. ¿Por qué std::thread::sleep es un problema dentro de código async?
5. ¿Para qué trabajo encaja mejor async?
Puntuación: 0 / 5
Para leer más
Sección titulada «Para leer más»- Fundamentos de la programación asíncrona — El libro de Rust (en inglés): el capítulo oficial sobre futures,
async,.awaity streams. - El tutorial de tokio (en inglés): una guía práctica en la que construyes un pequeño servidor parecido a Redis, con tareas, estado compartido y canales.
- tokio en docs.rs (en inglés): la referencia de la API de
tokio::spawn,tokio::time,tokio::syncy lo demás. - Asynchronous Programming in Rust (el libro de async) (en inglés): una mirada más profunda a cómo funcionan por dentro los futures y los executors.