El Modelo Mental de Async

En Rust, una async fn compila a una máquina de estados que implementa el trait Future:

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

Cada punto .await se convierte en una transición de estado. El compilador genera algo aproximadamente así para una función async simple:

async fn fetch_and_parse(url: &str) -> Result<Data> {
 let response = reqwest::get(url).await?; // estado 0 → estado 1
 let text = response.text().await?; // estado 1 → estado 2
 serde_json::from_str(&text).map_err(Into::into)
}

// Conceptualmente compila a:
enum FetchAndParse {
 State0 { url: String },
 State1 { response: Response },
 State2 { text: String },
}

impl Future for FetchAndParse {
 type Output = Result<Data>;

 fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
 loop {
 match self.state {
 State0 { ref url } => {
 match self.get_future.poll(cx) {
 Poll::Ready(resp) => self.transition_to(State1 { response: resp? }),
 Poll::Pending => return Poll::Pending,
 }
 }
 State1 { ref response } => {
 match self.text_future.poll(cx) {
 Poll::Ready(text) => self.transition_to(State2 { text: text? }),
 Poll::Pending => return Poll::Pending,
 }
 }
 State2 { ref text } => {
 return Poll::Ready(serde_json::from_str(text).map_err(Into::into));
 }
 }
 }
 }
}

El insight crítico: cuando poll devuelve Poll::Pending, el future se duerme. Nada lo está chequeando. El Waker guardado en Context es el mecanismo por el cual el sistema de I/O dice "ey, este socket tiene datos ahora, polleá ese future otra vez."

La Arquitectura de Tokio

Tokio tiene cuatro subsistemas principales:

┌──────────────────────────────────────────┐
│ Tokio Runtime │
├──────────┬──────────┬──────────┬─────────┤
│ Scheduler│ I/O │ Timer │ Blocking│
│ (work- │ Driver │ Driver │ Thread │
│ stealing)│ (epoll/ │ (timer │ Pool │
│ │ kqueue/ │ wheel) │ │
│ │ io_uring)│ │ │
└──────────┴──────────┴──────────┴─────────┘

El Scheduler Work-Stealing

Tokio corre N worker threads (default: cantidad de cores CPU). Cada worker tiene una cola local (un ring buffer de tamaño fijo de 256 tasks) más acceso a una cola global de inyección.

Cuando un worker termina de pollear una task:

  1. Chequea su cola local por la siguiente task
  2. Si está vacía, chequea la cola global de inyección
  3. Si está vacía, roba de la cola local de otro worker (agarra la mitad de sus tasks)
  4. Si todo está vacío, estaciona el thread
// Loop work-stealing simplificado
fn worker_loop(worker: &Worker, shared: &Shared) {
 loop {
 // 1. Cola local
 if let Some(task) = worker.local_queue.pop() {
 task.poll();
 continue;
 }

 // 2. Cola global
 if let Some(task) = shared.global_queue.pop() {
 // También agarrar un batch para amortizar el overhead del lock
 while let Some(extra) = shared.global_queue.try_pop() {
 worker.local_queue.push(extra);
 }
 task.poll();
 continue;
 }

 // 3. Robar de un worker random
 let victim = random_worker(shared.workers.len());
 if let Some(stolen) = shared.workers[victim].local_queue.steal_half() {
 stolen.first().poll();
 for task in stolen.rest() {
 worker.local_queue.push(task);
 }
 continue;
 }

 // 4. Nada que hacer, estacionar
 worker.park();
 }
}

La estrategia steal-half es importante. Si el worker A tiene 100 tasks en cola y el worker B está ocioso, B roba 50 tasks de una. Esto amortiza el costo de sincronización y balancea la carga rápidamente.

Profileé esto con tokio-console en un servicio de producción manejando 50K req/s. La tasa de robo se estabilizó en alrededor de 3% de las ejecuciones de tasks, significando que los workers estaban bien balanceados y raramente necesitaban robar. Cuando introduje una task CPU-pesada en un endpoint, la tasa de robo saltó a 18%, y la latencia en otros endpoints aumentó. Ahí fue cuando moví el trabajo de CPU a spawn_blocking.

El I/O Driver

El I/O driver de Tokio envuelve el sistema de notificación de eventos del OS, epoll en Linux, kqueue en macOS, io_uring en kernels Linux más nuevos (detrás de un feature flag). El driver corre en un thread dedicado (o comparte tiempo con worker threads, dependiendo de la configuración).

// Loop del I/O driver simplificado
fn io_driver_loop(driver: &mut IoDriver) {
 let mut events = Events::with_capacity(1024);

 loop {
 // Bloquear hasta que lleguen eventos de I/O o timeout
 driver.poll.poll(&mut events, Some(Duration::from_millis(61)))?;

 for event in &events {
 let token = event.token();
 if let Some(waker) = driver.registry.get_waker(token) {
 // Despertar el future que estaba esperando este I/O
 waker.wake();
 }
 }
 }
}

Cuando llamás a TcpStream::read, Tokio:

  1. Intenta una lectura no-bloqueante
  2. Si WouldBlock, registra interés con epoll vía el I/O driver
  3. Devuelve Poll::Pending con el waker guardado en el registry
  4. Cuando llegan datos, epoll dispara, el driver despierta la task
  5. El scheduler re-pollea el future, y esta vez la lectura tiene éxito

La Timer Wheel

El sistema de timers de Tokio usa una timer wheel jerárquica, la misma estructura de datos usada en los timers del kernel Linux. Es un array multinivel donde cada nivel cubre una granularidad de tiempo diferente:

Nivel 0: 64 slots × 1ms = rango de 64ms
Nivel 1: 64 slots × 64ms = rango de ~4s
Nivel 2: 64 slots × 4s = rango de ~4min
Nivel 3: 64 slots × 4min = rango de ~4.5hr
Nivel 4: 64 slots × 4.5hr = rango de ~12 días
Nivel 5: 64 slots × 12d = rango de ~2 años

Insertar y cancelar un timer es O(1). Avanzar la wheel para disparar timers expirados es O(1) amortizado por timer disparado. Esto es dramáticamente mejor que un binary heap (O(log n)) cuando teners miles de timers activos, que es común en manejo de timeout de conexiones.

// Cómo funciona tokio::time::sleep bajo el capó
pub async fn sleep(duration: Duration) {
 let deadline = Instant::now() + duration;
 let entry = timer_wheel.insert(deadline);

 poll_fn(|cx| {
 if Instant::now() >= deadline {
 Poll::Ready(())
 } else {
 entry.set_waker(cx.waker().clone());
 Poll::Pending
 }
 }).await;
}

El Mecanismo Waker

El Waker es el pegamento que conecta la readiness de I/O con el scheduling de tasks. Cuando un future devuelve Poll::Pending, guarda el waker. Cuando el evento pasa, se llama al waker, que pushea la task de vuelta a la cola del scheduler.

// Implementación del waker de Tokio (simplificada)
impl Wake for TaskHarness {
 fn wake(self: Arc<Self>) {
 // Setear el flag NOTIFIED
 self.state.fetch_or(NOTIFIED, Ordering::AcqRel);

 // Pushear task al scheduler
 self.scheduler.schedule(self.clone());
 }
}

El Arc<TaskHarness> es crítico, significa que el waker mantiene la task viva y puede clonarse barato. Múltiples fuentes de I/O pueden tener wakers para la misma task (considerar en select!), y la que dispare primero despierta la task.

Construcción de un Runtime Mínimo

A continuación se presenta el runtime async más simple posible para consolidar los conceptos. Sin work-stealing, sin I/O driver, solo un executor single-threaded que puede correr futures hasta completar.

use std::collections::VecDeque;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Wake, Waker};

type BoxFuture = Pin<Box<dyn Future<Output = ()> + Send>>;

struct MiniRuntime {
 queue: Arc<Mutex<VecDeque<Arc<Task>>>>,
}

struct Task {
 future: Mutex<Option<BoxFuture>>,
 queue: Arc<Mutex<VecDeque<Arc<Task>>>>,
}

impl Wake for Task {
 fn wake(self: Arc<Self>) {
 // Re-encolar la task cuando se despierta
 self.queue.lock().unwrap().push_back(self.clone());
 }
}

impl MiniRuntime {
 fn new() -> Self {
 MiniRuntime {
 queue: Arc::new(Mutex::new(VecDeque::new())),
 }
 }

 fn spawn(&self, future: impl Future<Output = ()> + Send + 'static) {
 let task = Arc::new(Task {
 future: Mutex::new(Some(Box::pin(future))),
 queue: self.queue.clone(),
 });
 self.queue.lock().unwrap().push_back(task);
 }

 fn run(&self) {
 loop {
 let task = {
 let mut queue = self.queue.lock().unwrap();
 match queue.pop_front() {
 Some(task) => task,
 None => break, // No hay más tasks
 }
 };

 let waker = Waker::from(task.clone());
 let mut cx = Context::from_waker(&waker);

 let mut future_slot = task.future.lock().unwrap();
 if let Some(mut future) = future_slot.take() {
 match future.as_mut().poll(&mut cx) {
 Poll::Ready(()) => {} // Task completa, soltarla
 Poll::Pending => {
 // Devolver el future, será re-encolado cuando se despierte
 *future_slot = Some(future);
 }
 }
 }
 }
 }
}

Son como 50 líneas y realmente funciona para futures que se despiertan a sí mismos sincrónicamente. Para hacerlo útil con I/O real, necesitarías agregar un loop de epoll que llame a los wakers apropiados cuando los file descriptors estén listos, esencialmente el I/O driver.

fn main() {
 let rt = MiniRuntime::new();

 rt.spawn(async {
 println!("Task 1: inicio");
 // yield_now re-encolaría vía waker
 println!("Task 1: fin");
 });

 rt.spawn(async {
 println!("Task 2: inicio");
 println!("Task 2: fin");
 });

 rt.run();
}

Problemas Comunes en Producción

Los siguientes problemas surgen frecuentemente de una comprensión insuficiente de la arquitectura interna:

Mantener un MutexGuard a través de .await: Esto bloquea el worker thread, y como Tokio es cooperativo, ninguna otra task en ese worker puede correr hasta que se libere el lock. Utilizar tokio::sync::Mutex si se necesita mantener un lock a través de puntos await.

// MAL, bloquea el worker thread
let data = std_mutex.lock().unwrap();
some_async_operation(&data).await;
drop(data);

// BIEN, mutex async-aware
let data = tokio_mutex.lock().await;
some_async_operation(&data).await;
drop(data);

Demasiadas tasks con polls cortos: Cada task tiene overhead, el Arc<TaskHarness>, la alocación del waker, el scheduling. Si spawneás 100.000 tasks que cada una hace una operación chiquita, el overhead de scheduling domina. Batchealas.

Olvidarse de spawn_blocking para I/O de archivos: Las operaciones de std::fs son sincrónicas y bloquean el worker thread. El tokio::fs de Tokio es solo un wrapper alrededor de spawn_blocking + std::fs. Usalo, o utilizar spawn_blocking directamente.

// Esto bloquea un worker thread, no hagas esto en contexto async
let data = std::fs::read_to_string("config.json")?;

// Esto corre en el thread pool bloqueante
let data = tokio::fs::read_to_string("config.json").await?;

Resumen

El diseño de Tokio es una clase magistral en trade-offs de programación de sistemas. El scheduler work-stealing te da eficiencia de CPU. El I/O driver te da escalabilidad. La timer wheel te da performance con miles de timeouts concurrentes. Y todo está construido sobre el modelo de ownership de Rust, que significa que las data races se atrapan en tiempo de compilación en vez de a las 3 de la mañana en producción.

Entender estos internals no va a cambiar cómo escribirs código async básico. Pero va a cambiar cómo lo debuggeás, cómo estructurás tu jerarquía de tasks, y cómo evitás los acantilados de performance que eventualmente atrapan a todos.