wandres.dev
FUTURES Y POLL · el modelo de Rust

El trait Future: un cálculo que aún no ha terminado

Un future no es una tarea corriendo en segundo plano: es un valor inerte que describe un cómputo pendiente y no hace nada hasta que alguien lo sondea. El trait cabe en dos líneas —un tipo asociado Output y un método poll— y esa parquedad esconde una decisión radical: separar la descripción del cálculo de su ejecución.

⏱ 17 min

La palabra future engaña a quien llega de otros lenguajes. En JavaScript, en Python, en Java, un future o promise es un asa a algo que ya está corriendo en segundo plano: lo creas y la maquinaria arranca sola. En Rust es exactamente lo contrario. Un Future es un valor inerte, la descripción de un cálculo que todavía no ha terminado y que, de hecho, ni siquiera ha empezado. No es una tarea en marcha: es la receta de una tarea, un dato de tamaño conocido en compilación que no ejecuta ni una instrucción hasta que alguien lo sondea. Entender esa inversión —el future como sustantivo perezoso, no como verbo en curso— es la puerta a todo el modelo poll que este nivel abre en canal.

🎯 Al terminar esta lección sabrás
  • Leer la definición del trait Future y el papel del tipo asociado Output.
  • Comprender que un future es un valor perezoso e inerte, no una tarea en ejecución.
  • Contrastar el future de Rust con la promise ansiosa de otros lenguajes.
  • Situar poll como el único método del contrato, cuyo detalle abre la lección siguiente.

Un future describe; no ejecuta

La primera intuición que hay que desmontar es que llamar a una async fn hace algo. No lo hace. Llamarla construye y devuelve un future, y no corre ni una línea de su cuerpo:

async fn calcular() -> u32 {
    println!("calculando..."); // este println NO se ejecuta al llamar
    40 + 2
}

fn main() {
    let fut = calcular(); // no imprime nada: solo fabrica el future
    // El cuerpo de calcular sigue congelado. Nada ha "empezado".
    let _ = fut; // si nunca lo sondeamos, ese println jamas correra
}

Compáralo con lo que ocurriría en un lenguaje de promises ansiosas, donde escribir calcular() dispararía la ejecución de inmediato y el println aparecería en pantalla. En Rust, calcular() es tan pasivo como escribir vec![1, 2, 3]: has creado un valor, y ahí se queda hasta que alguien lo use. Ese valor tiene un tipo —anónimo, generado por el compilador— que implementa el trait Future, y su cuerpo es código en pausa esperando que lo hagan avanzar.

Da igual de dónde salga el future: siempre es perezoso. Hay tres fuentes, y las tres entregan valores inertes.

async fn una_funcion() -> u32 { 42 }   // 1) una async fn devuelve un future
let un_bloque = async { 40 + 2 };      // 2) un bloque async: igual de perezoso
// 3) implementar Future a mano para un tipo propio (leccion 5 de este nivel)

Ninguna de las tres ejecuta una sola instrucción de su cuerpo al construirse.

ℹ️
Un future es #[must_use] por una razón

El compilador marca todo future con #[must_use]: si lo construyes y lo descartas sin sondearlo, salta un aviso. No es pedantería. Descartar un future significa tirar a la basura un cálculo que nunca se ejecutó: no hay efecto secundario, no hay red tocada, no hay fichero leído. En un lenguaje ansioso, olvidar un await deja una tarea corriendo huérfana; en Rust, olvidar un .await deja una tarea que no hace absolutamente nada. Dos fallos opuestos que nacen de la misma raíz: quién ejecuta el future.

La definición del trait: dos líneas, una idea

Despojado de detalles de estabilidad, el trait completo es asombrosamente pequeño:

use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

Dos miembros, y cada uno codifica media teoría. El tipo asociado Output declara qué valor producirá el cálculo cuando termine: para el future de calcular sería u32, para una lectura de socket sería Result<Vec<u8>, io::Error>. Es un tipo asociado, no un genérico, porque cada future concreto produce un tipo de resultado fijo, no una familia: no tiene sentido un future que sea a la vez Future<Output = u32> y Future<Output = String>.

El único método, poll, es el motor: es la pregunta “¿ya has terminado?” que el runtime le hace al future una y otra vez. Su firma tiene tres piezas que este nivel irá abriendo —el receptor Pin<&mut Self>, el Context que transporta el Waker, y el retorno Poll<Self::Output>—; por ahora basta con leer su forma: recibe una referencia a sí mismo y un contexto, y devuelve un Poll, que es un enum con dos variantes, “listo con el valor” o “todavía no”. La lección siguiente lo disecciona símbolo a símbolo.

💡
El tipo del future no tiene nombre

El valor que devuelve calcular() es de un tipo anónimo que el compilador generó y que tú no puedes escribir. Por eso una función que devuelve un future usa impl Future<Output = u32> en la posición de retorno: nombrar el tipo exacto es imposible, solo describir el contrato que cumple. Ese anonimato es coherente con lo que un future es —una máquina de estados fabricada para tu función concreta, sin analogía con ningún tipo previo—, y lo verás de cerca en la lección cuatro.

🧾

Future de Rust

Valor inerte y perezoso. No corre hasta que lo sondean. Es la descripción de un cómputo, un dato de tamaño fijo.

🏃

Promise ansiosa

En otros lenguajes, un asa a algo ya en ejecución. Nace corriendo; el await solo espera un resultado en marcha.

🎯

type Output

El tipo del valor que el future entregará al completarse. Fijo por future: un tipo asociado, no un genérico.

🔁

poll

El único método. La pregunta “¿ya?” que el runtime repite hasta obtener el valor. Su firma se abre en la lección 2.

Perezoso hasta el primer poll

Si el future no ejecuta nada por sí mismo, ¿quién lo mueve? Alguien tiene que conducirlo llamando a poll hasta que devuelva el valor. Ese alguien es el executor del runtime cuando haces spawn, o el .await que lo envuelve: .await es, en esencia, sondear un sub-future en bucle y ceder mientras diga “todavía no”. Un future al que nadie sondea es código muerto.

async fn tarea() {
    let a = paso_uno().await;  // .await sondea el future de paso_uno hasta Ready
    paso_dos(a).await;         // y luego el de paso_dos
}
// Pero tarea() en si tampoco corre sola: hay que entregarsela a un runtime.
// #[tokio::main] o un block_on son quienes hacen el primer poll de todo.

Esta pereza tiene una consecuencia enorme y hermosa: componer futures no ejecuta nada. Cuando encadenas .await, o cuando un combinador construye un future a partir de otros, solo estás anidando descripciones dentro de una descripción mayor, sin tocar el mundo. El árbol entero de llamadas async es un único valor compuesto, inerte, hasta que el runtime hace el primer poll en la raíz. Por eso el modelo escala y por eso la biblioteca estándar puede definir Future sin traer ningún motor: el lenguaje solo fija la forma del cálculo pendiente; ejecutarlo es tarea de una biblioteca externa.

async fn exterior() -> u32 {
    let a = interior_a().await;   // sin runtime que sondee, nada de esto corre
    let b = interior_b().await;
    a + b
}
// exterior() solo CONSTRUYE una maquina que anida las de interior_a e interior_b.
// Ninguna ha empezado: todo aguarda al primer poll de la raiz.
flowchart LR
C[async fn construye el future inerte] --> P[El runtime o un await llama a poll]
P --> Q{El calculo ha terminado}
Q -->|no| PEND[Devuelve Pending la tarea se aparca]
PEND --> P
Q -->|si| R[Devuelve Ready con el valor de tipo Output]
style C fill:#cba6f7,color:#11111b
style PEND fill:#fab387,color:#11111b
style R fill:#a6e3a1,color:#11111b
Reificar la espera: el future como dato, no como proceso

La decisión de fondo de este nivel es una sola, y es de las que reorganizan un lenguaje entero: convertir “un cálculo que aún no ha terminado” en un valor de primera clase en vez de un proceso en curso. En el modelo ansioso —promesas de JavaScript, futures de Java, corrutinas que arrancan al crearse— la concurrencia es un efecto: invocas algo y, como efecto lateral, nace una actividad que vive fuera de tu control, en una cola del runtime, avanzando por su cuenta. Rust rehúsa ese efecto. Aquí el cálculo pendiente es un struct anónimo que el compilador fabrica, con sus variables locales convertidas en campos y su punto de avance codificado en un discriminante; es tan dato como un entero o un vector, y lo puedes almacenar, mover, meter en otro future o descartar sin que ocurra nada, porque no es nada hasta que lo sondean. Esa reificación es la que hace posible todo lo demás. Como el future es un valor, componerlo es barato: anidar máquinas de estados, no lanzar tareas. Como es un valor de tamaño conocido, cabe donde quieras —el heap de un servidor, la RAM minúscula de un microcontrolador— sin pila crecible que gestionar. Como no ejecuta hasta el poll, el lenguaje puede definir el trait Future en la biblioteca estándar y no incluir ningún runtime: separa la descripción del cómputo, que es del lenguaje, de su ejecución, que es de una biblioteca intercambiable. Y como la pereza es total, el compilador puede ver todos los puntos de suspensión de una async fn antes de correr nada y construir con ellos una única máquina plana. El precio de esta elegancia es que alguien debe conducir el future explícitamente —de ahí que un .await olvidado sea código muerto y no una tarea fugada—, pero a cambio obtienes un modelo donde la concurrencia no es magia que ocurre a tus espaldas, sino un valor que tienes en la mano y decides cuándo hacer girar. Reificar la espera: esa es la idea madre de la que cuelgan poll, el Waker y la máquina de estados.

📝
Lo esencial

Un Future es un valor inerte y perezoso que describe un cálculo aún no terminado; llamar a una async fn lo construye pero no ejecuta su cuerpo. El trait tiene dos miembros: el tipo asociado Output, que fija el tipo del resultado final, y el método poll, la pregunta “¿ya?” que el runtime repite. Nada corre hasta el primer poll, que hace el executor al hacer spawn o el .await que envuelve el future. Componer futures solo anida descripciones sin tocar el mundo; por eso el lenguaje define Future sin traer runtime. La lección siguiente disecciona la firma de poll.

⚔️ Palpa la inercia del future
  1. Escribe una async fn que imprima un mensaje y devuelva un u32; llámala sin .await y comprueba que el mensaje no aparece. Añade luego el .await dentro de un runtime y observa la diferencia.
  2. Provoca el aviso de #[must_use]: construye un future y descártalo con let _ =. Lee la advertencia del compilador y explica qué se pierde exactamente al tirar un future sin sondearlo.
  3. Declara a mano un struct vacío e impleméntale Future con type Output = u32 y un poll que devuelva siempre Poll::Ready(42); razona por qué ya es un future válido pese a no esperar nada.
  4. Explica con tus palabras por qué Output es un tipo asociado y no un parámetro genérico del trait. Qué tendría de raro un tipo que fuera Future<Output = u32> y Future<Output = String> a la vez.
  5. Argumenta por qué la pereza total de los futures permite que la biblioteca estándar defina Future sin incluir ningún executor.