Ir al contenido

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.

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:

Terminal window
cargo new async_demo
cd async_demo
cargo add tokio --features full

Eso 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.

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é
▶ Pruébalo tú mismo

Aquí hay tres cosas nuevas:

  • async fn crea una función que puede pausarse a la mitad y continuar después.
  • .await significa “espera a que esto termine y, mientras esperamos, deja que se ejecuten otras tareas”. Solo puedes usar .await dentro de una función async.
  • #[tokio::main] arranca el runtime de tokio y ejecuta en él tu async fn main. Sin esto, main no puede ser async.

tokio::time::sleep es la versión async de “espera un poco”. Pausa solo esta tarea, no el hilo entero.

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!
Listo
▶ Pruébalo tú mismo

Piensa en un future como en la comanda escrita de un pedido. Escribir la comanda no cocina la comida. Entregarla en la cocina (.await), sí.

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 pedidos
▶ Pruébalo tú mismo

Si 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.

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 descargado
archivo 2 descargado
archivo 3 descargado
▶ Pruébalo tú mismo

Fí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.

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 them

La 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).

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.

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).await para 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.

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é
▶ Pruébalo tú mismo

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.

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 = 30
▶ Pruébalo tú mismo

Las cuatro tareas empiezan enseguida y esperan al mismo tiempo, así que esto tarda unos 40 ms, no 10 + 20 + 30 + 40 = 100 ms.

  • 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 full y marca main con #[tokio::main].
  • Una async fn devuelve un future. Los futures son perezosos: no se ejecuta nada hasta que haces .await.
  • Usa tokio::join! para esperar varios futures a la vez, y tokio::spawn para lanzar tareas y obtener un JoinHandle.
  • Nunca uses std::thread::sleep en código async: usa tokio::time::sleep(...).await.
  • Async sirve para esperar, no para trabajo pesado de CPU. Para eso, usa hilos.

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