{"schemaVersion":1,"id":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design","url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design","title":"Arquitectura Interna del Runtime Tokio: Scheduler Work-Stealing, I/O Driver y Construcción de un Runtime Mínimo","description":"Exploración técnica del funcionamiento interno de Tokio: el scheduler work-stealing, el I/O driver, la timer wheel, el mecanismo waker y Pin. Incluye la construcción de un runtime async mínimo para consolidar el modelo mental.","language":"es","datePublished":"2025-11-15","dateModified":null,"contentFormat":"text/markdown","content":"Rust\n\n# Arquitectura Interna del Runtime Tokio: Scheduler Work-Stealing, I/O Driver y Construcción de un Runtime Mínimo\n\n2025-11-159 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/async-rust-tokio-internals-runtime-design>)\n\n## El Modelo Mental de Async\n\nEn Rust, una ` async fn ` compila a una máquina de estados que implementa el trait ` Future `:\n\n```rust\ntrait Future {\n type Output;\n fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;\n}\n```\n\nCada punto ` .await ` se convierte en una transición de estado. El compilador genera algo aproximadamente así para una función async simple:\n\n```rust\nasync fn fetch_and_parse(url: &str) -> Result<Data> {\n let response = reqwest::get(url).await?; // estado 0 → estado 1\n let text = response.text().await?; // estado 1 → estado 2\n serde_json::from_str(&text).map_err(Into::into)\n}\n\n// Conceptualmente compila a:\nenum FetchAndParse {\n State0 { url: String },\n State1 { response: Response },\n State2 { text: String },\n}\n\nimpl Future for FetchAndParse {\n type Output = Result<Data>;\n\n fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {\n loop {\n match self.state {\n State0 { ref url } => {\n match self.get_future.poll(cx) {\n Poll::Ready(resp) => self.transition_to(State1 { response: resp? }),\n Poll::Pending => return Poll::Pending,\n }\n }\n State1 { ref response } => {\n match self.text_future.poll(cx) {\n Poll::Ready(text) => self.transition_to(State2 { text: text? }),\n Poll::Pending => return Poll::Pending,\n }\n }\n State2 { ref text } => {\n return Poll::Ready(serde_json::from_str(text).map_err(Into::into));\n }\n }\n }\n }\n}\n```\n\nEl 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.\"\n\n## La Arquitectura de Tokio\n\nTokio tiene cuatro subsistemas principales:\n\n```\n┌──────────────────────────────────────────┐\n│ Tokio Runtime │\n├──────────┬──────────┬──────────┬─────────┤\n│ Scheduler│ I/O │ Timer │ Blocking│\n│ (work- │ Driver │ Driver │ Thread │\n│ stealing)│ (epoll/ │ (timer │ Pool │\n│ │ kqueue/ │ wheel) │ │\n│ │ io_uring)│ │ │\n└──────────┴──────────┴──────────┴─────────┘\n```\n\n### El Scheduler Work-Stealing\n\nTokio 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.\n\nCuando un worker termina de pollear una task:\n\n1. Chequea su cola local por la siguiente task\n2. Si está vacía, chequea la cola global de inyección\n3. Si está vacía, roba de la cola local de otro worker (agarra la mitad de sus tasks)\n4. Si todo está vacío, estaciona el thread\n\n```rust\n// Loop work-stealing simplificado\nfn worker_loop(worker: &Worker, shared: &Shared) {\n loop {\n // 1. Cola local\n if let Some(task) = worker.local_queue.pop() {\n task.poll();\n continue;\n }\n\n // 2. Cola global\n if let Some(task) = shared.global_queue.pop() {\n // También agarrar un batch para amortizar el overhead del lock\n while let Some(extra) = shared.global_queue.try_pop() {\n worker.local_queue.push(extra);\n }\n task.poll();\n continue;\n }\n\n // 3. Robar de un worker random\n let victim = random_worker(shared.workers.len());\n if let Some(stolen) = shared.workers[victim].local_queue.steal_half() {\n stolen.first().poll();\n for task in stolen.rest() {\n worker.local_queue.push(task);\n }\n continue;\n }\n\n // 4. Nada que hacer, estacionar\n worker.park();\n }\n}\n```\n\nLa 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.\n\nProfileé 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 `.\n\n### El I/O Driver\n\nEl 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).\n\n```rust\n// Loop del I/O driver simplificado\nfn io_driver_loop(driver: &mut IoDriver) {\n let mut events = Events::with_capacity(1024);\n\n loop {\n // Bloquear hasta que lleguen eventos de I/O o timeout\n driver.poll.poll(&mut events, Some(Duration::from_millis(61)))?;\n\n for event in &events {\n let token = event.token();\n if let Some(waker) = driver.registry.get_waker(token) {\n // Despertar el future que estaba esperando este I/O\n waker.wake();\n }\n }\n }\n}\n```\n\nCuando llamás a ` TcpStream::read `, Tokio:\n\n1. Intenta una lectura no-bloqueante\n2. Si ` WouldBlock `, registra interés con epoll vía el I/O driver\n3. Devuelve ` Poll::Pending ` con el waker guardado en el registry\n4. Cuando llegan datos, epoll dispara, el driver despierta la task\n5. El scheduler re-pollea el future, y esta vez la lectura tiene éxito\n\n### La Timer Wheel\n\nEl 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:\n\n```\nNivel 0: 64 slots × 1ms = rango de 64ms\nNivel 1: 64 slots × 64ms = rango de ~4s\nNivel 2: 64 slots × 4s = rango de ~4min\nNivel 3: 64 slots × 4min = rango de ~4.5hr\nNivel 4: 64 slots × 4.5hr = rango de ~12 días\nNivel 5: 64 slots × 12d = rango de ~2 años\n```\n\nInsertar 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.\n\n```rust\n// Cómo funciona tokio::time::sleep bajo el capó\npub async fn sleep(duration: Duration) {\n let deadline = Instant::now() + duration;\n let entry = timer_wheel.insert(deadline);\n\n poll_fn(|cx| {\n if Instant::now() >= deadline {\n Poll::Ready(())\n } else {\n entry.set_waker(cx.waker().clone());\n Poll::Pending\n }\n }).await;\n}\n```\n\n### El Mecanismo Waker\n\nEl ` 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.\n\n```rust\n// Implementación del waker de Tokio (simplificada)\nimpl Wake for TaskHarness {\n fn wake(self: Arc<Self>) {\n // Setear el flag NOTIFIED\n self.state.fetch_or(NOTIFIED, Ordering::AcqRel);\n\n // Pushear task al scheduler\n self.scheduler.schedule(self.clone());\n }\n}\n```\n\nEl ` 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.\n\n## Construcción de un Runtime Mínimo\n\nA 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.\n\n```rust\nuse std::collections::VecDeque;\nuse std::future::Future;\nuse std::pin::Pin;\nuse std::sync::{Arc, Mutex};\nuse std::task::{Context, Poll, Wake, Waker};\n\ntype BoxFuture = Pin<Box<dyn Future<Output = ()> + Send>>;\n\nstruct MiniRuntime {\n queue: Arc<Mutex<VecDeque<Arc<Task>>>>,\n}\n\nstruct Task {\n future: Mutex<Option<BoxFuture>>,\n queue: Arc<Mutex<VecDeque<Arc<Task>>>>,\n}\n\nimpl Wake for Task {\n fn wake(self: Arc<Self>) {\n // Re-encolar la task cuando se despierta\n self.queue.lock().unwrap().push_back(self.clone());\n }\n}\n\nimpl MiniRuntime {\n fn new() -> Self {\n MiniRuntime {\n queue: Arc::new(Mutex::new(VecDeque::new())),\n }\n }\n\n fn spawn(&self, future: impl Future<Output = ()> + Send + 'static) {\n let task = Arc::new(Task {\n future: Mutex::new(Some(Box::pin(future))),\n queue: self.queue.clone(),\n });\n self.queue.lock().unwrap().push_back(task);\n }\n\n fn run(&self) {\n loop {\n let task = {\n let mut queue = self.queue.lock().unwrap();\n match queue.pop_front() {\n Some(task) => task,\n None => break, // No hay más tasks\n }\n };\n\n let waker = Waker::from(task.clone());\n let mut cx = Context::from_waker(&waker);\n\n let mut future_slot = task.future.lock().unwrap();\n if let Some(mut future) = future_slot.take() {\n match future.as_mut().poll(&mut cx) {\n Poll::Ready(()) => {} // Task completa, soltarla\n Poll::Pending => {\n // Devolver el future, será re-encolado cuando se despierte\n *future_slot = Some(future);\n }\n }\n }\n }\n }\n}\n```\n\nSon 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.\n\n```rust\nfn main() {\n let rt = MiniRuntime::new();\n\n rt.spawn(async {\n println!(\"Task 1: inicio\");\n // yield_now re-encolaría vía waker\n println!(\"Task 1: fin\");\n });\n\n rt.spawn(async {\n println!(\"Task 2: inicio\");\n println!(\"Task 2: fin\");\n });\n\n rt.run();\n}\n```\n\n## Problemas Comunes en Producción\n\nLos siguientes problemas surgen frecuentemente de una comprensión insuficiente de la arquitectura interna:\n\n**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.\n\n```rust\n// MAL, bloquea el worker thread\nlet data = std_mutex.lock().unwrap();\nsome_async_operation(&data).await;\ndrop(data);\n\n// BIEN, mutex async-aware\nlet data = tokio_mutex.lock().await;\nsome_async_operation(&data).await;\ndrop(data);\n```\n\n**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.\n\n**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.\n\n```rust\n// Esto bloquea un worker thread, no hagas esto en contexto async\nlet data = std::fs::read_to_string(\"config.json\")?;\n\n// Esto corre en el thread pool bloqueante\nlet data = tokio::fs::read_to_string(\"config.json\").await?;\n```\n\n## Resumen\n\nEl 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.\n\nEntender 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.\n\n## Preguntas frecuentes\n\n### ¿Por qué Rust necesita un runtime externo para async en vez de construir uno en el lenguaje?\n\nLa 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.\n\n### ¿Cómo difiere el scheduler work-stealing de Tokio del scheduling de threads del OS?\n\nLos 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.\n\n### ¿Cuándo debería usar tokio::spawn vs tokio::spawn\\_blocking?\n\nUtilizar 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.\n\n## Tips\n\n1. **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.\n2. **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.\n3. **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.\n\n- rust\n- async\n- tokio\n- runtime\n- work-stealing\n- futures\n- waker\n- io-driver\n- concurrencia\n- programacion-de-sistemas\n\n[Volver al Blog](<https://lucioduran.com/es/blog>)","headings":[{"level":1,"text":"Arquitectura Interna del Runtime Tokio: Scheduler Work-Stealing, I/O Driver y Construcción de un Runtime Mínimo","url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#title"},{"level":2,"text":"El Modelo Mental de Async","url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#el-modelo-mental-de-async"},{"level":2,"text":"La Arquitectura de Tokio","url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#la-arquitectura-de-tokio"},{"level":3,"text":"El Scheduler Work-Stealing","url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#el-scheduler-work-stealing"},{"level":3,"text":"El I/O Driver","url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#el-io-driver"},{"level":3,"text":"La Timer Wheel","url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#la-timer-wheel"},{"level":3,"text":"El Mecanismo Waker","url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#el-mecanismo-waker"},{"level":2,"text":"Construcción de un Runtime Mínimo","url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#construccin-de-un-runtime-mnimo"},{"level":2,"text":"Problemas Comunes en Producción","url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#problemas-comunes-en-produccin"},{"level":2,"text":"Resumen","url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#resumen"},{"level":2,"text":"Preguntas frecuentes","url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#questions"},{"level":3,"text":"¿Por qué Rust necesita un runtime externo para async en vez de construir uno en el lenguaje?","url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#question-1"},{"level":3,"text":"¿Cómo difiere el scheduler work-stealing de Tokio del scheduling de threads del OS?","url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#question-2"},{"level":3,"text":"¿Cuándo debería usar tokio::spawn vs tokio::spawn_blocking?","url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#article-tips"}],"sections":[{"heading":"Arquitectura Interna del Runtime Tokio: Scheduler Work-Stealing, I/O Driver y Construcción de un Runtime Mínimo","level":1,"url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design","content":"Rust"},{"heading":"Arquitectura Interna del Runtime Tokio: Scheduler Work-Stealing, I/O Driver y Construcción de un Runtime Mínimo","level":1,"url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#title","content":"2025-11-159 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/async-rust-tokio-internals-runtime-design>)"},{"heading":"El Modelo Mental de Async","level":2,"url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#el-modelo-mental-de-async","content":"En Rust, una ` async fn ` compila a una máquina de estados que implementa el trait ` Future `:\n\n```rust\ntrait Future {\n type Output;\n fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;\n}\n```\n\nCada punto ` .await ` se convierte en una transición de estado. El compilador genera algo aproximadamente así para una función async simple:\n\n```rust\nasync fn fetch_and_parse(url: &str) -> Result<Data> {\n let response = reqwest::get(url).await?; // estado 0 → estado 1\n let text = response.text().await?; // estado 1 → estado 2\n serde_json::from_str(&text).map_err(Into::into)\n}\n\n// Conceptualmente compila a:\nenum FetchAndParse {\n State0 { url: String },\n State1 { response: Response },\n State2 { text: String },\n}\n\nimpl Future for FetchAndParse {\n type Output = Result<Data>;\n\n fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {\n loop {\n match self.state {\n State0 { ref url } => {\n match self.get_future.poll(cx) {\n Poll::Ready(resp) => self.transition_to(State1 { response: resp? }),\n Poll::Pending => return Poll::Pending,\n }\n }\n State1 { ref response } => {\n match self.text_future.poll(cx) {\n Poll::Ready(text) => self.transition_to(State2 { text: text? }),\n Poll::Pending => return Poll::Pending,\n }\n }\n State2 { ref text } => {\n return Poll::Ready(serde_json::from_str(text).map_err(Into::into));\n }\n }\n }\n }\n}\n```\n\nEl 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.\""},{"heading":"La Arquitectura de Tokio","level":2,"url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#la-arquitectura-de-tokio","content":"Tokio tiene cuatro subsistemas principales:\n\n```\n┌──────────────────────────────────────────┐\n│ Tokio Runtime │\n├──────────┬──────────┬──────────┬─────────┤\n│ Scheduler│ I/O │ Timer │ Blocking│\n│ (work- │ Driver │ Driver │ Thread │\n│ stealing)│ (epoll/ │ (timer │ Pool │\n│ │ kqueue/ │ wheel) │ │\n│ │ io_uring)│ │ │\n└──────────┴──────────┴──────────┴─────────┘\n```"},{"heading":"El Scheduler Work-Stealing","level":3,"url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#el-scheduler-work-stealing","content":"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.\n\nCuando un worker termina de pollear una task:\n\n1. Chequea su cola local por la siguiente task\n2. Si está vacía, chequea la cola global de inyección\n3. Si está vacía, roba de la cola local de otro worker (agarra la mitad de sus tasks)\n4. Si todo está vacío, estaciona el thread\n\n```rust\n// Loop work-stealing simplificado\nfn worker_loop(worker: &Worker, shared: &Shared) {\n loop {\n // 1. Cola local\n if let Some(task) = worker.local_queue.pop() {\n task.poll();\n continue;\n }\n\n // 2. Cola global\n if let Some(task) = shared.global_queue.pop() {\n // También agarrar un batch para amortizar el overhead del lock\n while let Some(extra) = shared.global_queue.try_pop() {\n worker.local_queue.push(extra);\n }\n task.poll();\n continue;\n }\n\n // 3. Robar de un worker random\n let victim = random_worker(shared.workers.len());\n if let Some(stolen) = shared.workers[victim].local_queue.steal_half() {\n stolen.first().poll();\n for task in stolen.rest() {\n worker.local_queue.push(task);\n }\n continue;\n }\n\n // 4. Nada que hacer, estacionar\n worker.park();\n }\n}\n```\n\nLa 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.\n\nProfileé 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 `."},{"heading":"El I/O Driver","level":3,"url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#el-io-driver","content":"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).\n\n```rust\n// Loop del I/O driver simplificado\nfn io_driver_loop(driver: &mut IoDriver) {\n let mut events = Events::with_capacity(1024);\n\n loop {\n // Bloquear hasta que lleguen eventos de I/O o timeout\n driver.poll.poll(&mut events, Some(Duration::from_millis(61)))?;\n\n for event in &events {\n let token = event.token();\n if let Some(waker) = driver.registry.get_waker(token) {\n // Despertar el future que estaba esperando este I/O\n waker.wake();\n }\n }\n }\n}\n```\n\nCuando llamás a ` TcpStream::read `, Tokio:\n\n1. Intenta una lectura no-bloqueante\n2. Si ` WouldBlock `, registra interés con epoll vía el I/O driver\n3. Devuelve ` Poll::Pending ` con el waker guardado en el registry\n4. Cuando llegan datos, epoll dispara, el driver despierta la task\n5. El scheduler re-pollea el future, y esta vez la lectura tiene éxito"},{"heading":"La Timer Wheel","level":3,"url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#la-timer-wheel","content":"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:\n\n```\nNivel 0: 64 slots × 1ms = rango de 64ms\nNivel 1: 64 slots × 64ms = rango de ~4s\nNivel 2: 64 slots × 4s = rango de ~4min\nNivel 3: 64 slots × 4min = rango de ~4.5hr\nNivel 4: 64 slots × 4.5hr = rango de ~12 días\nNivel 5: 64 slots × 12d = rango de ~2 años\n```\n\nInsertar 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.\n\n```rust\n// Cómo funciona tokio::time::sleep bajo el capó\npub async fn sleep(duration: Duration) {\n let deadline = Instant::now() + duration;\n let entry = timer_wheel.insert(deadline);\n\n poll_fn(|cx| {\n if Instant::now() >= deadline {\n Poll::Ready(())\n } else {\n entry.set_waker(cx.waker().clone());\n Poll::Pending\n }\n }).await;\n}\n```"},{"heading":"El Mecanismo Waker","level":3,"url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#el-mecanismo-waker","content":"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.\n\n```rust\n// Implementación del waker de Tokio (simplificada)\nimpl Wake for TaskHarness {\n fn wake(self: Arc<Self>) {\n // Setear el flag NOTIFIED\n self.state.fetch_or(NOTIFIED, Ordering::AcqRel);\n\n // Pushear task al scheduler\n self.scheduler.schedule(self.clone());\n }\n}\n```\n\nEl ` 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."},{"heading":"Construcción de un Runtime Mínimo","level":2,"url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#construccin-de-un-runtime-mnimo","content":"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.\n\n```rust\nuse std::collections::VecDeque;\nuse std::future::Future;\nuse std::pin::Pin;\nuse std::sync::{Arc, Mutex};\nuse std::task::{Context, Poll, Wake, Waker};\n\ntype BoxFuture = Pin<Box<dyn Future<Output = ()> + Send>>;\n\nstruct MiniRuntime {\n queue: Arc<Mutex<VecDeque<Arc<Task>>>>,\n}\n\nstruct Task {\n future: Mutex<Option<BoxFuture>>,\n queue: Arc<Mutex<VecDeque<Arc<Task>>>>,\n}\n\nimpl Wake for Task {\n fn wake(self: Arc<Self>) {\n // Re-encolar la task cuando se despierta\n self.queue.lock().unwrap().push_back(self.clone());\n }\n}\n\nimpl MiniRuntime {\n fn new() -> Self {\n MiniRuntime {\n queue: Arc::new(Mutex::new(VecDeque::new())),\n }\n }\n\n fn spawn(&self, future: impl Future<Output = ()> + Send + 'static) {\n let task = Arc::new(Task {\n future: Mutex::new(Some(Box::pin(future))),\n queue: self.queue.clone(),\n });\n self.queue.lock().unwrap().push_back(task);\n }\n\n fn run(&self) {\n loop {\n let task = {\n let mut queue = self.queue.lock().unwrap();\n match queue.pop_front() {\n Some(task) => task,\n None => break, // No hay más tasks\n }\n };\n\n let waker = Waker::from(task.clone());\n let mut cx = Context::from_waker(&waker);\n\n let mut future_slot = task.future.lock().unwrap();\n if let Some(mut future) = future_slot.take() {\n match future.as_mut().poll(&mut cx) {\n Poll::Ready(()) => {} // Task completa, soltarla\n Poll::Pending => {\n // Devolver el future, será re-encolado cuando se despierte\n *future_slot = Some(future);\n }\n }\n }\n }\n }\n}\n```\n\nSon 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.\n\n```rust\nfn main() {\n let rt = MiniRuntime::new();\n\n rt.spawn(async {\n println!(\"Task 1: inicio\");\n // yield_now re-encolaría vía waker\n println!(\"Task 1: fin\");\n });\n\n rt.spawn(async {\n println!(\"Task 2: inicio\");\n println!(\"Task 2: fin\");\n });\n\n rt.run();\n}\n```"},{"heading":"Problemas Comunes en Producción","level":2,"url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#problemas-comunes-en-produccin","content":"Los siguientes problemas surgen frecuentemente de una comprensión insuficiente de la arquitectura interna:\n\n**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.\n\n```rust\n// MAL, bloquea el worker thread\nlet data = std_mutex.lock().unwrap();\nsome_async_operation(&data).await;\ndrop(data);\n\n// BIEN, mutex async-aware\nlet data = tokio_mutex.lock().await;\nsome_async_operation(&data).await;\ndrop(data);\n```\n\n**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.\n\n**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.\n\n```rust\n// Esto bloquea un worker thread, no hagas esto en contexto async\nlet data = std::fs::read_to_string(\"config.json\")?;\n\n// Esto corre en el thread pool bloqueante\nlet data = tokio::fs::read_to_string(\"config.json\").await?;\n```"},{"heading":"Resumen","level":2,"url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#resumen","content":"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.\n\nEntender 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."},{"heading":"Preguntas frecuentes","level":2,"url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#questions","content":""},{"heading":"¿Por qué Rust necesita un runtime externo para async en vez de construir uno en el lenguaje?","level":3,"url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#question-1","content":"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."},{"heading":"¿Cómo difiere el scheduler work-stealing de Tokio del scheduling de threads del OS?","level":3,"url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#question-2","content":"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."},{"heading":"¿Cuándo debería usar tokio::spawn vs tokio::spawn_blocking?","level":3,"url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#question-3","content":"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."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#article-tips","content":"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.\n2. **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.\n3. **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.\n\n- rust\n- async\n- tokio\n- runtime\n- work-stealing\n- futures\n- waker\n- io-driver\n- concurrencia\n- programacion-de-sistemas\n\n[Volver al Blog](<https://lucioduran.com/es/blog>)"}],"links":[{"text":"English","url":"https://lucioduran.com/blog/async-rust-tokio-internals-runtime-design"},{"text":"Volver al Blog","url":"https://lucioduran.com/es/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design"},"headline":"Arquitectura Interna del Runtime Tokio: Scheduler Work-Stealing, I/O Driver y Construcción de un Runtime Mínimo","description":"Exploración técnica del funcionamiento interno de Tokio: el scheduler work-stealing, el I/O driver, la timer wheel, el mecanismo waker y Pin. Incluye la construcción de un runtime async mínimo para consolidar el modelo mental.","datePublished":"2025-11-15","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design","inLanguage":"es","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Rust","keywords":"rust, async, tokio, runtime, work-stealing, futures, waker, io-driver, concurrencia, programacion-de-sistemas","wordCount":1613,"timeRequired":"PT9M","workTranslation":[{"@type":"BlogPosting","inLanguage":"en","url":"https://lucioduran.com/blog/async-rust-tokio-internals-runtime-design"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Inicio","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/es/blog"},{"@type":"ListItem","position":3,"name":"Arquitectura Interna del Runtime Tokio: Scheduler Work-Stealing, I/O Driver y Construcción de un Runtime Mínimo","item":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"¿Por qué Rust necesita un runtime externo para async en vez de construir uno en el lenguaje?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"¿Cómo difiere el scheduler work-stealing de Tokio del scheduling de threads del OS?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"¿Cuándo debería usar tokio::spawn vs tokio::spawn_blocking?","acceptedAnswer":{"@type":"Answer","text":"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."}}]}]}],"contentHash":"2f8bc798b8172eb99db157da9dad24c32b638fba7ddf658932d24d492c0ed584","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/es/blog/async-rust-tokio-internals-runtime-design","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":68712,"contentBytes":14517}}
