<!-- Canonical: https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design
Language: es
Content-SHA256: 2f8bc798b8172eb99db157da9dad24c32b638fba7ddf658932d24d492c0ed584
-->

Rust

# Arquitectura Interna del Runtime Tokio: Scheduler Work-Stealing, I/O Driver y Construcción de un Runtime Mínimo

2025-11-159 min lectura

Lucio Durán
Engineering Manager & AI Solutions Architect

También disponible en: [English](<https://lucioduran.com/blog/async-rust-tokio-internals-runtime-design>)

## El Modelo Mental de Async

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

```rust
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:

```rust
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

```rust
// 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).

```rust
// 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.

```rust
// 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.

```rust
// 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.

```rust
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.

```rust
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.

```rust
// 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.

```rust
// 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.

## Preguntas frecuentes

### ¿Por qué Rust necesita un runtime externo para async en vez de construir uno en el lenguaje?

La filosofía de diseño de Rust separa las primitivas del lenguaje (trait Future, sintaxis async/await, Pin) del executor que las maneja. Esto significa que es posible usar Tokio para servidores de red, smol para tareas livianas, o Embassy para sistemas embebidos, todo con la misma sintaxis async/await. Meter un runtime en el lenguaje forzaría compromise es que no le sirven a nadie.

### ¿Cómo difiere el scheduler work-stealing de Tokio del scheduling de threads del OS?

Los schedulers del OS manejan multitasking preemptivo, pueden interrumpir threads en cualquier punto. El scheduler de Tokio es cooperativo, las tasks deben ceder en puntos .await. Work-stealing significa que worker threads ociose es pueden agarrar tasks de las colas de workers ocupados, mejorando la utilización de CPU. La combinación te da threading M:N, muchas tasks en menos threads del OS, sin overhead de preemption.

### ¿Cuándo debería usar tokio::spawn vs tokio::spawn\_blocking?

Utilizar tokio::spawn para trabajo async que va a hacer .await en I/O u otros futures. Utilizar spawn\_blocking para código sincrónico que bloquearía el thread, computación CPU-intensiva, I/O de archivos sincrónico, o llamadas a bibliotecas C. spawn\_blocking corre en un thread pool separado así que no mata de hambre a los workers async.

## Tips

1. **LuchoTip:** No se debe invocar ` .await ` dentro de un closure de ` spawn_blocking `, ni ejecutar código bloqueante dentro de una task async sin ` spawn_blocking `. Mezclar estos dos mundos es la causa principal de deadlocks de Tokio en producción.
2. **LuchoTip:** Si la latencia de una aplicación Tokio presenta picos bajo carga, conviene verificar ` tokio_unstable ` + ` tokio-console ` primero. En la mayoría de los casos se trata de una task que mantiene un ` MutexGuard ` a través de un punto ` .await `, privando de recursos al scheduler work-stealing.
3. **LuchoTip:** Se recomienda configurar ` worker_threads ` igual a la cantidad de CPUs, no más. Más threads que cores implica que el overhead de context-switching consume las ganancias de concurrencia. En pruebas con una máquina de 16 cores, 32 workers resultó 11% *más lento* que 16.

- rust
- async
- tokio
- runtime
- work-stealing
- futures
- waker
- io-driver
- concurrencia
- programacion-de-sistemas

[Volver al Blog](<https://lucioduran.com/es/blog>)
