wandres.dev
FUTURES Y POLL · el modelo de Rust

Escribir un Future a mano: poll sin magia, con un temporizador

El modelo se entiende de verdad implementándolo. Escribimos tres futures a mano —uno listo al instante, un contador que cede reencolándose, y un temporizador real respaldado por un hilo— para ver poll, Poll::Pending y el Waker funcionando sin la azúcar de async. Un future hoja es donde la asincronía toca el mundo, y es solo un poll y una llamada a wake.

⏱ 18 min

Has visto el trait, el protocolo de poll, el Waker y la máquina que el compilador fabrica. Ahora toca cerrar el nivel de la única forma que fija de verdad un modelo: implementándolo a mano. Vamos a escribir tres futures sin una sola línea de async, empezando por uno trivial que está listo al instante, pasando por un contador que cede reencolándose, y culminando en un temporizador real respaldado por un hilo que, al vencer, llama a wake(). Al terminar habrás construido con tus manos un future hoja —de esos que en la lección anterior eran los únicos que tocan el mundo— y comprobado que debajo de toda la magia async no hay más que una función, poll, y un timbre, wake.

🎯 Al terminar esta lección sabrás
  • Implementar Future a mano definiendo type Output y un poll correcto.
  • Escribir un future que ceda con Pending y se reactive con el Waker.
  • Construir un future hoja real: un temporizador respaldado por un hilo y estado compartido.
  • Reconocer el patrón universal de todo poll de hoja: comprobar, registrar waker, ceder.

El future más simple: Ready a la primera

Empecemos por el caso degenerado: un future que ya tiene el valor y nunca cede. Su poll devuelve Ready en la primera llamada:

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

struct Listo(u32);

impl Future for Listo {
    type Output = u32;

    fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<u32> {
        Poll::Ready(self.0) // termina de inmediato; ni mira el Context
    }
}

Es un future de pleno derecho: cumple el contrato —completa una vez, y como nunca devuelve Pending, no necesita waker—. Ignora cx a propósito: solo quien cede tiene algo que registrar. Esto es, en esencia, lo que hace std::future::ready(x) de la biblioteca. Trivial, pero ya revela la forma: implementar Future es rellenar type Output y escribir un poll.

Un contador que cede: Pending y auto-wake

Subamos un peldaño a un future que cede. Un contador que, en cada poll, decrementa y devuelve Pending hasta llegar a cero. Para que lo vuelvan a sondear, se despierta a sí mismo con wake_by_ref():

struct CuentaAtras {
    restantes: u32,
}

impl Future for CuentaAtras {
    type Output = ();

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
        let este = self.get_mut(); // valido: CuentaAtras es Unpin
        if este.restantes == 0 {
            Poll::Ready(())
        } else {
            println!("faltan {}", este.restantes);
            este.restantes -= 1;
            cx.waker().wake_by_ref(); // pide ser reencolada de inmediato
            Poll::Pending
        }
    }
}

Aquí ves el ciclo completo latir: el executor sondea, obtiene Pending, y como el future ya llamó a wake_by_ref(), la tarea vuelve enseguida a la cola; el siguiente poll decrementa otra vez, y así hasta el Ready. self.get_mut() nos da el &mut Self para mutar restantes, permitido porque CuentaAtras no es autorreferencial y por tanto es Unpin. Es el esqueleto de un yield: ceder el hilo un instante para que otras tareas avancen, sin esperar nada externo.

⚠️
Auto-despertarse es ceder, no esperar

Este contador se reencola de inmediato, así que en la práctica gira casi tan rápido como un bucle: sirve para ceder el turno —un yield_now—, no para esperar de verdad. Un future que espera algo real —red, disco, un temporizador— no debe auto-despertarse: debe guardar el waker y devolver Pending sin dispararlo, dejando que sea el mundo exterior quien llame a wake() cuando de verdad haya progreso. Auto-despertarse en un future que aguarda E/S recrea el busy-poll que el modelo existe para eliminar. Cede con auto-wake solo cuando quieres ceder, no cuando quieres esperar.

Un temporizador de verdad: un hilo y un Waker

Ahora el ejemplo canónico, un future hoja que espera algo externo sin girar. Un temporizador que se completa cuando pasa una duración. La espera la hace un hilo aparte que duerme y, al despertar, marca el estado como completo y dispara el waker que poll dejó guardado. El puente entre el hilo y el future es un Arc<Mutex<...>> compartido:

use std::sync::{Arc, Mutex};
use std::task::Waker;
use std::thread;
use std::time::Duration;

struct Estado {
    completado: bool,
    waker: Option<Waker>,
}

struct Temporizador {
    estado: Arc<Mutex<Estado>>,
}

impl Temporizador {
    fn nuevo(duracion: Duration) -> Self {
        let estado = Arc::new(Mutex::new(Estado { completado: false, waker: None }));
        let compartido = Arc::clone(&estado);
        thread::spawn(move || {
            thread::sleep(duracion);                 // la espera real, fuera del executor
            let mut guardia = compartido.lock().unwrap();
            guardia.completado = true;
            if let Some(waker) = guardia.waker.take() {
                waker.wake();                        // avisa: ya puede avanzar
            }
        });
        Temporizador { estado }
    }
}

impl Future for Temporizador {
    type Output = ();

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
        let mut guardia = self.estado.lock().unwrap();
        if guardia.completado {
            Poll::Ready(())
        } else {
            // Guarda SIEMPRE el waker mas reciente antes de ceder.
            guardia.waker = Some(cx.waker().clone());
            Poll::Pending
        }
    }
}

Sigue el baile completo. El primer poll encuentra completado == false, clona y guarda el waker, y devuelve Pending; el executor aparca la tarea y se duerme. En paralelo, el hilo cumple su sleep, pone completado = true y llama a wake(), que reencola la tarea. El executor la sondea otra vez; ahora completado es true y sale Ready(()). Ningún hilo del executor estuvo esperando el tiempo: la espera vivió en un hilo aparte, y el executor solo hizo dos poll baratos separados por un sueño. Cambia ese sleep por un epoll sobre un socket y tienes, en miniatura, cómo funciona un future de red de verdad.

Para conducirlo necesitas un runtime que haga el primer poll; el más simple es block_on:

fn main() {
    // futures::executor::block_on conduce el future hasta Ready.
    futures::executor::block_on(async {
        Temporizador::nuevo(Duration::from_secs(1)).await;
        println!("un segundo despues");
    });
}

Comprobar

Todo poll de hoja empieza mirando si el resultado ya está. Si sí, Poll::Ready(v) y fin.

🔔

Registrar

Si no está, clona cx.waker() y guárdalo donde el productor lo alcance. Siempre el más reciente.

⏸️

Ceder

Devuelve Poll::Pending. El executor aparca la tarea; nadie gira esperando.

🌍

El mundo avisa

Un hilo, un temporizador o epoll completan la operación y llaman a wake. La tarea vuelve a la cola.

flowchart TD
P1[Primer poll completado es false] --> G[Clona y guarda el waker devuelve Pending]
G --> S[El executor aparca la tarea y duerme]
H[Hilo aparte duerme la duracion] --> M[Marca completado true y llama a wake]
M --> Q[El executor reencola la tarea]
Q --> P2[Segundo poll completado es true devuelve Ready]
style P1 fill:#cba6f7,color:#11111b
style G fill:#fab387,color:#11111b
style M fill:#89b4fa,color:#11111b
style P2 fill:#a6e3a1,color:#11111b
Un future hoja es donde la abstracción toca el suelo

Escribir este temporizador a mano disuelve el último resto de misterio del modelo async, y lo hace mostrando dónde vive la frontera entre la abstracción y la realidad. Todo el edificio de una aplicación asíncrona —los cientos de futures que compones con .await, la máquina de estados que el compilador teje, el .await que parece detener el tiempo— es pura propagación: cada future intermedio se limita a sondear al de dentro y pasar hacia arriba su veredicto, sin saber nada del mundo. Ese castillo entero se sostiene sobre unas pocas hojas como la que acabas de escribir, y solo en ellas la asincronía deja de ser un juego de tipos y toca el suelo: un descriptor real, un reloj real, un hilo real. Y fíjate en lo poco que hace falta para ser una hoja. No hay una API secreta, ni un gancho privilegiado del compilador, ni una bendición del runtime: hay un struct, una implementación de Future, y dentro de poll el mismo patrón de tres pasos que se repite en cada future hoja del ecosistema, desde el temporizador de Tokio hasta el socket de async-std —comprobar si el resultado ya está, y si no, guardar el waker y ceder—. La pieza que lo cierra todo es el Waker, y por eso es la más honda: es el punto donde el productor del suceso, que corre por completo fuera del mundo async —un hilo del sistema, una interrupción de hardware, una notificación del kernel—, reengancha con el mundo async llamando a una función que no sabe qué hará. El hilo del sleep no conoce al executor, no sabe de tareas ni de colas; solo tiene un Waker y lo dispara, y esa indiferencia mutua es justo lo que hace el modelo tan componible: cualquier fuente de eventos capaz de sostener un waker y llamarlo puede convertirse en un future, y cualquier future así construido se compone con todos los demás sin fricción. Cuando escribes tu propio poll y ves que la “magia” era clonar un waker y que otro hilo lo tocara, la asincronía de Rust termina de dejar de ser un dialecto arcano: es una interfaz de tres líneas que cualquiera puede implementar para casar su pedazo del mundo real con el resto del programa. Ese es el regalo de bajar hasta la hoja: descubrir que en el fondo no había magia, solo un contrato pequeño y honesto que ahora sabes firmar.

📝
Lo esencial

Implementar Future a mano es definir type Output y un poll que respete el contrato. Un future ya resuelto devuelve Ready e ignora el Context. Un future que cede con auto-wake_by_ref() recrea un yield: cede el turno, no espera. Un future hoja real —el temporizador— delega la espera a un hilo aparte y comparte estado por Arc<Mutex<...>>: poll comprueba si terminó, y si no, clona y guarda el waker y devuelve Pending; el hilo, al vencer, marca el estado y llama a wake(), que reencola la tarea. El patrón universal de toda hoja es comprobar, registrar el waker y ceder. Debajo de async no hay más que esto.

⚔️ Construye tus propias hojas
  1. Implementa Listo y condúcelo con block_on; añade un println! dentro de poll y confirma que solo se sondea una vez.
  2. Escribe CuentaAtras y sondéalo hasta el final imprimiendo cada paso; explica por qué gira casi como un bucle y por qué eso está bien para ceder pero mal para esperar E/S.
  3. Reproduce el Temporizador completo y espera dos de ellos con .await seguidos; razona cuántos hilos aparte se crean y por qué el executor no se bloquea durante la espera.
  4. Introduce el fallo del nivel 2 a propósito: en el poll del temporizador, devuelve Pending sin guardar el waker. Ejecuta y observa que la tarea se cuelga; explica por qué no hay panic.
  5. Enuncia el patrón de tres pasos de todo poll de hoja y localiza cada paso en el código del temporizador. Esboza cómo cambiaría si en vez de un hilo con sleep usaras un registro en un reactor con epoll.