async fn y .await: la sintaxis de una función perezosa
Una async fn devuelve un Future en lugar de su valor, y ese Future es perezoso: no ejecuta ni una línea de su cuerpo hasta que algo lo avanza. El operador postfijo .await conduce el Future y, cuando no está listo, cede el control. Las dos ideas —pereza y cesión— que sostienen todo el modelo.
A primera vista una async fn parece una función normal con una palabra extra delante. La diferencia es profunda: no devuelve su resultado, devuelve un Future que promete ese resultado más adelante, y ese Future nace inerte. Llamar a la función no ejecuta su cuerpo; construye un valor que no ha hecho nada todavía. Solo cuando lo conduces con .await empieza a avanzar, y en cada .await el código puede pausarse y devolver el control a quien lo ejecuta. Dos ideas, y solo dos, bastan para entender la sintaxis entera: los futures son perezosos, y .await es el punto donde una función cede mientras espera.
- Escribir una
async fny reconocer que su tipo de retorno esimpl Future. - Entender que llamarla no ejecuta su cuerpo: produce un
Futureperezoso. - Usar
.awaitpara avanzar unFuturey ceder el control mientras espera. - Componer futures con bloques
asyncy ver que el.awaites secuencial por defecto.
La sintaxis: async fn devuelve un Future
La palabra async delante de una fn transforma su firma. El tipo que escribes como retorno se convierte en el Output de un Future anónimo:
async fn leer_usuario(id: u64) -> String {
format!("usuario-{id}")
}
// El compilador la reescribe, en esencia, a esto:
fn leer_usuario(id: u64) -> impl Future<Output = String> {
async move { format!("usuario-{id}") }
}
Donde antes leías “devuelve un String”, ahora lees “devuelve algo que, al avanzarlo hasta el final, producirá un String”. Ese “algo” es un tipo anónimo que el compilador fabrica y que implementa el trait Future; su tipo asociado Output es el valor prometido. No puedes nombrarlo, pero sí referirte a él con impl Future<Output = String>.
Perezoso: llamar no es ejecutar
Aquí está el giro mental más importante del nivel. En la mayoría de los lenguajes, invocar una función empieza su trabajo; una promesa de JavaScript arranca en el instante en que la creas. En Rust es al revés: un Future es perezoso. Construirlo no corre ni una línea de su cuerpo.
let futuro = leer_usuario(7); // NO se ejecuta nada; futuro esta inerte
// ... el cuerpo de leer_usuario aun no ha corrido ...
let nombre = futuro.await; // AHORA empieza a avanzar hasta terminar
Esta pereza tiene una consecuencia práctica inmediata: un Future que construyes y no avanzas no hace absolutamente nada. El compilador lo sabe y marca Future como #[must_use], avisándote si descartas uno. El error de principiante clásico es llamar a una async fn y olvidar el .await:
async fn registrar(evento: &str) { /* escribe en un log ... */ }
async fn demo() {
registrar("inicio"); // BUG: crea un Future y lo tira; no registra nada
registrar("inicio").await; // correcto: lo avanza hasta el final
}
En una base de código grande este olvido es de los bugs más insidiosos: compila, no lanza ningún error en tiempo de ejecución, y simplemente no hace lo que creías. El compilador te cubre las espaldas con el aviso must_use, pero solo si lo escuchas en vez de silenciarlo.
Si ves warning: unused implementor of Future that must be used o “futures do nothing unless you .await or poll them”, el compilador te está diciendo que fabricaste una promesa y la tiraste a la basura. Casi siempre falta un .await. No lo silencies: en código async, un future ignorado es trabajo que creías hacer y no hiciste.
.await: ceder el control mientras se espera
El operador .await hace dos cosas a la vez. Primero, avanza el future hacia su resultado. Segundo, y decisivo, si el future todavía no está listo —el socket no tiene datos, el temporizador no venció—, .await cede: devuelve el control a quien conduce la tarea (el runtime), que puede dedicar el hilo a otras tareas listas. Cuando el recurso esperado por fin está disponible, la ejecución se reanuda exactamente donde se pausó.
Dos detalles de sintaxis importan. .await es postfijo —se escribe x.await, no await x—, lo que encadena de maravilla con métodos y con el operador ?. Y solo puede aparecer dentro de un contexto async: una async fn o un bloque async.
use tokio::fs;
async fn tamano_total(a: &str, b: &str) -> std::io::Result<usize> {
let uno = fs::read(a).await?; // cede mientras el disco responde
let dos = fs::read(b).await?; // solo EMPIEZA cuando el primero termino
Ok(uno.len() + dos.len())
}
Observa un matiz que decide el rendimiento: los .await son secuenciales por defecto. dos no arranca hasta que uno terminó. Eso es correcto cuando hay dependencia real, pero desperdicia la ocasión de solapar dos esperas independientes —para eso están join! y spawn, que verás en la lección cinco—. Entre dos puntos de .await, el código corre de un tirón, sin interrupción; el .await marca los únicos lugares donde la función puede pausarse.
Que .await se escriba después del valor, y no antes, no es capricho de sintaxis: permite encadenarlo con métodos y con ? en una sola línea fluida, como cliente.get(url).await?.json().await?. Un await prefijo, al estilo de otros lenguajes, obligaría a paréntesis y a leer de dentro hacia afuera. La ergonomía está pensada para que el flujo asíncrono se lea tan lineal como el síncrono, con las costuras de suspensión visibles pero no estridentes.
flowchart TD
A[Llamas a una async fn] --> B[Devuelve un Future inerte]
B --> C[No se ejecuta nada de su cuerpo]
C --> D[Haces await sobre el Future]
D --> E[El runtime empieza a avanzarlo]
E --> F{El valor esta listo}
F -->|no| G[await cede el control al runtime]
G --> E
F -->|si| H[await devuelve el valor y sigue]
style B fill:#89b4fa,color:#11111b
style C fill:#f38ba8,color:#11111b
style G fill:#fab387,color:#11111b
style H fill:#a6e3a1,color:#11111bBloques async y la forma del valor
No solo las fn pueden ser async. Un bloque async { ... } es una expresión que produce un Future, ideal para fabricar trabajo al vuelo o preparar una tarea para spawn. Como un closure, captura su entorno por referencia o, con async move, por valor.
let trabajo = async move {
let x = paso_uno().await;
paso_dos(x).await
};
// trabajo: impl Future; sigue inerte hasta que alguien lo avance
El bloque se lleva consigo todo lo que necesita recordar a través de sus .await, igual que la async fn. Sea función o bloque, el resultado es siempre lo mismo: un valor perezoso que representa un cómputo suspendible, listo para pasarse, componerse o entregarse a un runtime.
La elección entre capturar por referencia o con async move no es cosmética. Un bloque que se va a spawn como tarea independiente casi siempre necesita async move, porque debe poseer sus datos: la tarea puede sobrevivir al ámbito donde nació, y una referencia prestada a ese ámbito sería inválida.
let nombre = String::from("Ada");
// Sin move: el bloque toma prestado nombre; solo vale mientras el prestamo dure.
let saludo = async { format!("hola {nombre}") };
// Con move: el bloque se lleva nombre; puede vivir por su cuenta, apto para spawn.
let saludo = async move { format!("hola {nombre}") };
Es la misma disciplina de ownership que ya conoces de los closures, aplicada ahora a un valor que representa un cómputo aún sin ejecutar.
Perezoso
Construir el future no ejecuta nada. Un future ignorado es trabajo no hecho. Contrasta con las promesas eager de otros lenguajes.
Cede en cada await
Si el valor no está listo, .await devuelve el control al runtime, que corre otras tareas. Al estar listo, se reanuda donde paró.
Postfijo y componible
x.await encadena con ? y con métodos. Un bloque async { ... } es otro future más, apto para anidar o spawn.
Secuencial por defecto
Dos .await seguidos esperan uno tras otro. Solapar esperas independientes exige join! o spawn (lección 5).
Que un Future no haga nada hasta que lo avanzas parece una excentricidad, incluso una molestia —¿por qué olvidar un .await debería equivaler a no hacer el trabajo?—. Pero esa pereza es exactamente la propiedad que da a Rust un control sobre la ejecución que los modelos eager no pueden ofrecer. Un future perezoso es un valor de primera clase que representa un cómputo aún no empezado: puedes guardarlo, moverlo, meterlo en una colección, envolverlo en un timeout, correr varios en carrera y quedarte con el primero, o simplemente soltarlo sin ejecutarlo —y eso último es, ni más ni menos, la cancelación en Rust: dejar de hacer .await sobre un future, o dejarlo caer, aborta el trabajo que representaba sin ninguna maquinaria especial—. Una promesa que arranca al crearse, como en JavaScript, ya está en marcha antes de que decidas qué hacer con ella; cancelarla limpiamente es un rompecabezas, y su mera creación ya costó una asignación y un turno en la cola de eventos. La versión perezosa invierte quién manda: el que conduce —al final, el runtime— decide cuándo, si acaso, y cómo se avanza cada future. Y esa misma propiedad es la que habilita el coste cero de la lección cuatro: como el compilador ve todos los puntos de .await de antemano —son las únicas costuras donde el flujo se suspende—, puede compilar la función entera a una máquina de estados plana, sin asignar en cada espera, sin una pila por tarea. La pereza, la componibilidad, la cancelación por drop y el coste cero no son cuatro rasgos sueltos: son cuatro caras de una sola decisión de diseño. Un future es una descripción de trabajo, no el trabajo en curso; .await es el verbo que convierte la descripción en ejecución, cediendo cada vez que la realidad todavía no ha respondido.
Una async fn devuelve impl Future<Output = T>, no T. Ese future es perezoso: construirlo no ejecuta ni una línea, y un future ignorado es trabajo no hecho —de ahí el aviso must_use—. El operador postfijo .await lo avanza y, si el valor aún no está listo, cede el control al runtime para que corra otras tareas, reanudando luego donde paró. Los .await son secuenciales por defecto; solapar esperas independientes exige join! o spawn. Un bloque async { ... } es otro future más, con captura por referencia o, con async move, por valor.
- Escribe
async fn saludo() -> String, llámala sin.awaity observa el avisomust_use; luego añádelo y compara. - Guarda el future de una
async fnen una variable, imprime algo entre la llamada y el.await, y demuestra que el cuerpo de la función no corrió hasta el.await. - Reescribe una
async fna su forma desazucaradafn ... -> impl Future<Output = T>con un bloqueasync moveen el cuerpo. - Encadena tres
.awaitdependientes en una función y explica por qué se ejecutan en orden; luego identifica cuáles podrían solaparse si fueran independientes. - Argumenta, con la cancelación como ejemplo, por qué un future perezoso es más controlable que una promesa que arranca al crearse.