El trait Future en Rust: poll, Context, Waker e implementar un Future a mano

Un Future en Rust representa un valor que todavía no está listo, pero que estará disponible en algún momento. A diferencia de los callbacks o las promesas de otros lenguajes, los futures de Rust son lazy: no hacen nada hasta que alguien los ejecuta.

// Este future no hace nada hasta que alguien lo ejecute:
let f = alguna_funcion_async();

// Solo aquí empieza a ejecutarse:
let resultado = f.await;

El trait Future es la interfaz que define este comportamiento:

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

pub enum Poll<T> {
    Ready(T),
    Pending,
}

El método poll hace una pregunta al future: "¿ya tienes el resultado?" Si lo tiene, devuelve Poll::Ready(valor). Si no, devuelve Poll::Pending.

El Context y el Waker

Cuando un future devuelve Poll::Pending, necesita alguna forma de decirle al runtime cuándo volver a intentarlo. Para eso existe el Waker, que se pasa a través del Context.

fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
    // Si el resultado no está listo...
    if !datos_disponibles() {
        // Guardar el waker para llamarlo cuando los datos estén listos
        let waker = cx.waker().clone();
        registrar_callback(move || waker.wake());
        return Poll::Pending;
    }

    Poll::Ready(obtener_datos())
}

El flujo es:

  1. El runtime llama a poll con un Context que contiene un Waker.
  2. Si el future no está listo, guarda el Waker y devuelve Pending.
  3. Cuando el evento que esperaba ocurre (datos de red, timer, etc.), llama a waker.wake().
  4. El runtime vuelve a llamar a poll en el future.
  5. Si ahora sí está listo, devuelve Ready.

Implementar un Future a mano

Implementemos un future simple que cuenta hasta N de forma async, cediendo el control cada iteración:

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

struct Contador {
    valor_actual: u64,
    maximo: u64,
}

impl Contador {
    pub fn nuevo(maximo: u64) -> Self {
        Contador { valor_actual: 0, maximo }
    }
}

impl Future for Contador {
    type Output = u64;

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<u64> {
        self.valor_actual += 1;

        if self.valor_actual >= self.maximo {
            Poll::Ready(self.valor_actual)
        } else {
            // Decirle al runtime que nos llame de nuevo inmediatamente
            cx.waker().wake_by_ref();
            Poll::Pending
        }
    }
}

// Uso:
#[tokio::main]
async fn main() {
    let resultado = Contador::nuevo(5).await;
    println!("Contador llegó a: {}", resultado);  // 5
}

Este future llama a waker.wake_by_ref() antes de devolver Pending, lo que hace que el runtime lo vuelva a ejecutar inmediatamente. En un caso real, el waker se llamaría desde un callback de I/O o un timer.

El desugar de async fn

Cuando escribes una función async fn, el compilador la transforma en una función que devuelve una implementación de Future. Esta transformación se llama desugar.

// Lo que escribes:
async fn suma(a: u32, b: u32) -> u32 {
    a + b
}

// Lo que el compilador genera (aproximadamente):
fn suma(a: u32, b: u32) -> impl Future<Output = u32> {
    struct SumaFuture { a: u32, b: u32, estado: u8 }

    impl Future for SumaFuture {
        type Output = u32;
        fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<u32> {
            // Esta función no tiene puntos .await, así que siempre termina inmediatamente
            Poll::Ready(self.a + self.b)
        }
    }

    SumaFuture { a, b, estado: 0 }
}

Para funciones con puntos .await, el compilador genera una máquina de estados donde cada punto .await es una transición de estado:

// Lo que escribes:
async fn secuencial() {
    let a = operacion_1().await;
    let b = operacion_2(a).await;
    println!("{}", b);
}

// Lo que el compilador genera (simplificado):
enum SecuencialState {
    Estado0,
    Estado1 { future_1: Operacion1Future },
    Estado2 { a: Resultado1, future_2: Operacion2Future },
    Estado3 { b: Resultado2 },
    Terminado,
}

impl Future for SecuencialFuture {
    type Output = ();
    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
        loop {
            match self.estado {
                Estado0 => {
                    self.estado = Estado1 { future_1: operacion_1() };
                }
                Estado1 { future_1 } => {
                    match Pin::new(future_1).poll(cx) {
                        Poll::Ready(a) => {
                            self.estado = Estado2 { a, future_2: operacion_2(a) };
                        }
                        Poll::Pending => return Poll::Pending,
                    }
                }
                // ... etc.
            }
        }
    }
}

Esta máquina de estados es lo que hace que los futures sean auto-referenciales (pueden contener referencias a datos en la misma struct) y por qué necesitan Pin.

Por qué los futures son lazy

Un future no hace nada hasta que alguien llama a poll. Esto tiene consecuencias importantes:

// Este código NO hace ninguna petición de red:
let request = reqwest::get("https://ejemplo.com");

// La petición empieza aquí:
let respuesta = request.await?;

Si tienes un future y lo descartas sin ejecutarlo, no ocurre nada. Esto contrasta con los Promises de JavaScript, que empiezan a ejecutarse cuando se crean.

La lazyness permite también la composición: puedes crear futures que envuelven otros futures y controlan exactamente cuándo y cómo se ejecutan:

// timeout envuelve el future y lo cancela si tarda demasiado
let resultado = tokio::time::timeout(
    std::time::Duration::from_secs(5),
    operacion_lenta()
).await?;

El executor: quién llama a poll

Los futures no se ejecutan solos. Necesitan un executor (o runtime) que los ejecute. En Rust, el executor más usado es Tokio:

// El atributo #[tokio::main] configura el executor
#[tokio::main]
async fn main() {
    // Dentro de aquí, puedes usar .await
    let resultado = mi_future().await;
}

Tokio mantiene una cola de futures listos para ejecutar, los llama con poll, y cuando devuelven Pending, los pone a dormir hasta que su Waker los despierte. Ese es todo el modelo de ejecución async de Rust.

Entender este modelo ayuda a diagnosticar problemas reales: si un future no progresa, es porque su Waker nunca se llama. Si el sistema va lento, puede ser porque un future que debería devolver Pending está bloqueando el hilo en un loop.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP