Por qué Rust no trae runtime: async es del lenguaje, ejecutarlo no
Rust define la sintaxis async/await, el trait Future y toda la maquinaria de tipos, pero no incluye nada que ejecute futures: ni executor, ni reactor de E/S, ni temporizadores, ni spawn. Debes elegir un runtime externo como Tokio. No es una carencia, es una decisión de diseño que va al corazón de la filosofía de Rust.
Aprendes async y .await, escribes tu primera función asíncrona, la llamas desde main… y no compila, o no ejecuta nada. La razón desconcierta a todo el que viene de otros lenguajes: la librería estándar de Rust no incluye un runtime. Define la sintaxis async/.await, el trait Future y toda la maquinaria de tipos, pero no trae la pieza que ejecuta un future hasta el final, ni la que integra con la red, ni la que reparte tareas entre hilos. Para eso tienes que traer una crate externa —Tokio, smol, Embassy—. Esto no es un olvido ni una obra a medias: es una negativa deliberada, y entender por qué ilumina toda la filosofía de Rust.
- Enumerar lo que el lenguaje y
core/stdsí proveen para async. - Identificar lo que falta: executor, reactor de E/S, temporizadores y
spawn. - Explicar por qué la ausencia de runtime es deliberada:
no_std, embebido, flexibilidad y coste cero. - Situar los runtimes del ecosistema y por qué debes elegir uno.
Lo que el lenguaje sí define
Rust aporta la forma de la computación asíncrona, y la aporta en core —no solo en std—, lo que significa que async funciona incluso sin sistema operativo. En concreto:
- La sintaxis
async/.awaity la transformación del compilador a una máquina de estados. - El trait
Future(encore::future), con su métodopoll. - Los tipos
Poll,ContextyWaker(encore::task), yPinpara futures auto-referenciales (nivel 33).
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
Esto es una interfaz, no un motor. Define qué significa “ser un future” y cómo se le pide que avance un paso —poll—, pero no dice quién llama a poll, ni cuándo, ni desde qué hilo. Es un contrato esperando a que alguien lo cumpla.
Lo que falta: el runtime
Un Future inerte necesita que algo lo conduzca, y ese algo —el runtime— reúne varias piezas que std deliberadamente no incluye:
- Executor: el bucle que llama a
polluna y otra vez sobre las tareas hasta que devuelvenReady. Sin executor, un future es un valor que nadie avanza. - Reactor o driver de E/S: integra con
epoll,kqueue, IOCP oio_uring; despierta las tareas cuando un socket o fichero está listo, y fabrica losWakerreales. - Planificador de tareas: reparte las tareas entre hilos trabajadores e implementa
spawn. - Temporizadores:
sleep, timeouts y todo lo que dependa del reloj.
Ninguna de estas piezas vive en la librería estándar. Por eso este código, aparentemente razonable, no llega a ninguna parte por sí solo:
// Esto NO arranca: main no puede ser async a secas,
// y nadie conduciria el Future que produce.
async fn main() {
hacer_algo().await; // no hay executor que avance este await
}
Falta el motor. En cuanto traes uno —con #[tokio::main], que verás en la lección cinco—, el mismo código cobra vida.
¿Qué hace ese motor, en esencia? “Conducir” un future no es misterioso: es hacerle poll una y otra vez hasta que termina. Un executor mínimo cabe en un comentario:
// Un executor de juguete, en esencia, no es mas que esto:
// 1. toma un future y lo fija en su sitio
// 2. lo avanza con poll
// 3. si dice Pending, se duerme hasta que un waker avise
// 4. al despertar, vuelve a 2
// 5. si dice Ready, entrega el valor y termina
// Tokio hace justo esto, pero con robo de trabajo entre hilos,
// un reactor de E/S sobre epoll/kqueue y temporizadores.
La lección cuatro abre esa mecánica del poll. Lo que importa ahora es que ese bucle —trivial de enunciar, delicado de hacer eficiente y multihilo— es precisamente lo que std decidió no incluir.
Por qué es deliberado
La ausencia no es pereza de los diseñadores; es coherencia con lo que Rust promete. Cuatro razones lo sostienen.
Primero, no_std y embebido. Rust corre en microcontroladores sin heap ni sistema operativo. Un runtime cosido a std sería inservible ahí. Que el runtime sea elegible es lo que permite a Embassy ofrecer async en bare metal, sobre el mismo trait Future que usa un servidor:
#![no_std]
// En un microcontrolador no hay std, pero el trait Future vive en core:
use core::future::Future;
// La sintaxis async y .await funciona igual; solo cambia el motor que la conduce.
Que la sintaxis y el trait vivan en core y no en std no es un tecnicismo: es lo que hace que el mismo idioma async sirva desde la nube hasta un chip de kilobytes.
Segundo, no hay un modelo único correcto. Multihilo con robo de trabajo (Tokio), un hilo por núcleo sobre io_uring (Glommio, monoio), un solo hilo para tareas que no cruzan hilos… cada carga real prefiere uno distinto. Cocer uno en el lenguaje petrificaría esa decisión para todos.
Tercero, coste cero. El principio de Rust es “no pagas por lo que no usas”. El mecanismo async del lenguaje no impone ni una asignación ni un hilo; el coste llega solo con el runtime que tú elijas, y en su justa medida.
Cuarto, evolución. Dejar el runtime como biblioteca permite que compita, itere y mejore sin atarse al ritmo lento y conservador de la estabilización del lenguaje. Tokio ha reescrito su planificador varias veces; de haber estado en std, cada mejora habría requerido una edición del lenguaje y una promesa de estabilidad eterna.
Ninguna de estas cuatro razones es un accidente histórico que una crate oficial pudiera enmendar: son consecuencias directas de dos compromisos que Rust no piensa romper —correr en todas partes, del servidor al metal desnudo, y no cobrarte por lo que no pides—. Un runtime cosido a std traicionaría los dos a la vez.
Go integra su runtime en el lenguaje: goroutines, planificador y netpoller vienen de fábrica. La ergonomía es magnífica —lanzas una goroutine y ya está—, pero el precio es que no puedes quitarlo, no sirve para no_std ni bare metal, y todo binario Go lo carga siempre. Rust eligió lo opuesto: el lenguaje define la forma —el trait Future— y te deja escoger el motor. Más fricción al empezar, a cambio de correr desde un servidor de un millón de conexiones hasta un chip de ocho kilobytes de RAM.
flowchart TD L[El lenguaje y core definen la forma] --> L1[Sintaxis async y await] L --> L2[El trait Future y su metodo poll] L --> L3[Poll Context Waker y Pin] R[El runtime externo aporta el motor] --> R1[Executor que hace poll en bucle] R --> R2[Reactor de entrada salida sobre epoll kqueue] R --> R3[Temporizadores planificador y spawn] L2 -. no se conduce solo .-> R1 style L fill:#cba6f7,color:#11111b style R fill:#fab387,color:#11111b style L2 fill:#89b4fa,color:#11111b style R1 fill:#a6e3a1,color:#11111b
El ecosistema de runtimes
Como el runtime es una decisión tuya, conviene conocer el mapa. No hace falta que los domines ahora; basta saber que existen y para qué sirve cada uno.
El grueso del ecosistema orbita Tokio, pero la propia variedad es la prueba de que la decisión de diseño acertó: cada nicho —red, embebido, latencia extrema— encontró o construyó su motor sobre el mismo Future.
Tokio
El runtime de facto para servidores y red. Multihilo con robo de trabajo, reactor sobre epoll/kqueue/IOCP, y un ecosistema enorme. Es el que usarás en el nivel 34.
smol / async-std
Minimalistas y ligeros. smol es un núcleo pequeño y componible; buenos para binarios que no quieren el peso de Tokio.
Embassy
Async para microcontroladores, en no_std y sin heap. Prueba viva de que el runtime es intercambiable sobre el mismo Future.
Glommio / monoio
Un hilo por núcleo sobre io_uring, para latencia extrema. Cambian el modelo de planificación por completo.
Para la inmensa mayoría de servicios de red la respuesta es Tokio: su ecosistema —hyper, axum, tonic, sqlx— es tan vasto que salirse de él te deja sin piezas. Reserva las alternativas para cuando tengas una razón concreta: no_std y embebido piden Embassy; una latencia de cola extrema puede justificar el diseño de un hilo por núcleo sobre io_uring. Empezar por Tokio y migrar solo si un requisito real lo exige es la ruta sensata; elegir runtime no es un examen de pureza, es ajustar la herramienta a la carga.
Elegir runtime es prerrogativa de la aplicación, no de las librerías que consume. Si escribes una crate reutilizable, no la ates a Tokio salvo que sea su razón de ser: exponer futures agnósticos deja que quien te use elija su motor. Mezclar dos runtimes en un mismo proceso, o arrastrar Tokio a la fuerza dentro de una app que eligió smol, es fuente de errores sutiles en tiempo de ejecución. La flexibilidad tiene como reverso una fragmentación que se gestiona con disciplina.
La decisión de no traer runtime es la aplicación más pura de un principio que atraviesa todo Rust: el lenguaje provee mecanismo, no política. Un mecanismo es una capacidad neutra —aquí, un vocabulario para describir cómputos suspendibles: el trait Future, la pregunta poll, la promesa de que una función async se compila a una máquina de estados—. Una política es una decisión concreta sobre cómo usar esa capacidad: cuántos hilos, con qué planificador, sobre qué API del sistema operativo, con qué estrategia de temporizadores. Otros lenguajes fusionan ambas: te dan async y el único runtime que existirá jamás, cómodo y a la vez inamovible. Rust las separa quirúrgicamente, y de esa separación brotan sus superpoderes. Porque el mecanismo vive en core y no arrastra política, el mismo async/.await que impulsa un servidor de red impulsa también un sensor alimentado por una pila de botón sin sistema operativo debajo: solo cambias el motor, no el idioma. Porque la política es una biblioteca, puede evolucionar, competir y especializarse —robo de trabajo para servicios, un hilo por núcleo para baja latencia, cooperativo puro para embebido— sin pedir permiso al comité del lenguaje ni romper la estabilidad de nadie. Y porque el mecanismo es coste cero, no pagas por una decisión de política que no tomaste: no hay un planificador dormido en tu binario si no lo invitaste. El coste de esta elegancia es real y honesto: friccción para el principiante, que debe elegir y traer un runtime antes de que .await haga algo, y una fragmentación del ecosistema que exige disciplina. Pero a cambio Rust logra lo que ningún lenguaje con runtime integrado puede: un solo modelo de concurrencia asíncrona que se extiende, sin fisuras, desde la nube hasta el metal desnudo. Cuando entiendes que el lenguaje define un interfaz para computación suspendible y se detiene ahí con toda intención, dejas de ver la falta de runtime como un hueco y empiezas a verla como la firma de Rust: darte las garantías y el vocabulario, dejarte elegir la máquina, y cobrarte solo por lo que decidas usar.
El lenguaje y core definen la forma de async: la sintaxis async/.await, el trait Future con poll, y Poll/Context/Waker/Pin. No definen el motor: executor, reactor de E/S, temporizadores ni spawn. Por eso async fn main a secas no arranca y necesitas un runtime externo —Tokio, smol, Embassy—. La omisión es deliberada: habilita no_std y embebido, no petrifica un único modelo de planificación, respeta el coste cero y deja evolucionar los runtimes como bibliotecas. Es el principio “mecanismo en el lenguaje, política en la biblioteca” en su forma más pura.
- Intenta compilar un
async fn mainsin ningún runtime y sin#[tokio::main]; lee el error y explica qué pieza falta exactamente. - Clasifica cada elemento como “del lenguaje/std” o “del runtime”: el trait
Future,tokio::spawn, la palabra.await,epoll,Poll::Pending, un temporizadorsleep. - Añade Tokio a un proyecto vacío y haz que el mismo cuerpo async que antes no arrancaba, ahora corra. Identifica qué aportó la crate.
- Argumenta por qué integrar el runtime en
std, al estilo de Go, haría imposible el objetivono_std/embebido de Rust. - Explica, con el principio “mecanismo frente a política”, por qué una librería reutilizable no debería depender de un runtime concreto y qué debería exponer en su lugar.