wandres.dev
ASYNC/AWAIT · fundamentos

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.

⏱ 18 min

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.

🎯 Al terminar esta lección sabrás
  • Enumerar lo que el lenguaje y core/std sí 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/.await y la transformación del compilador a una máquina de estados.
  • El trait Future (en core::future), con su método poll.
  • Los tipos Poll, Context y Waker (en core::task), y Pin para 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 poll una y otra vez sobre las tareas hasta que devuelven Ready. Sin executor, un future es un valor que nadie avanza.
  • Reactor o driver de E/S: integra con epoll, kqueue, IOCP o io_uring; despierta las tareas cuando un socket o fichero está listo, y fabrica los Waker reales.
  • 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.

ℹ️
El contraste con Go

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.

💡
No busques el mejor runtime, busca el adecuado

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.

⚠️
Una librería no debería imponer un runtime

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.

Mecanismo en el lenguaje, política en la biblioteca

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.

📝
Lo esencial

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.

⚔️ Traza la frontera entre lenguaje y runtime
  1. Intenta compilar un async fn main sin ningún runtime y sin #[tokio::main]; lee el error y explica qué pieza falta exactamente.
  2. Clasifica cada elemento como “del lenguaje/std” o “del runtime”: el trait Future, tokio::spawn, la palabra .await, epoll, Poll::Pending, un temporizador sleep.
  3. 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.
  4. Argumenta por qué integrar el runtime en std, al estilo de Go, haría imposible el objetivo no_std/embebido de Rust.
  5. 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.