Tokio en 2026: el runtime async de Rust que debes conocer

Cuando escribes async fn en Rust, el compilador transforma esa función en una máquina de estados que implementa el trait Future. El lenguaje define cómo se construyen esos Futures, pero no los ejecuta. Alguien tiene que hacerlo.

Ese alguien es el runtime. Su trabajo es decidir cuándo pollear cada tarea, gestionar los wakers que notifican que un Future está listo para continuar, y hablar con el sistema operativo para la I/O asíncrona (epoll en Linux, kqueue en macOS, IOCP en Windows). Sin un runtime, async fn main() no hace absolutamente nada por sí solo.

En 2026 hay varias opciones: async-std existe pero su desarrollo es bastante más lento que el de Tokio, smol apuesta por ser minimalista y embassy está pensado para microcontroladores. Para servidores, APIs y servicios web, Tokio lleva años siendo la opción mayoritaria y eso no ha cambiado.

Configurar Tokio en tu proyecto

Añade la dependencia en Cargo.toml:

[dependencies]
tokio = { version = "1", features = ["full"] }

features = ["full"] activa todo: el scheduler multi-hilo, timers, I/O de red, canales de sincronización, sistema de archivos async... Para producción con control del tamaño del binario conviene activar solo lo que uses: rt, rt-multi-thread, net, io-util, time, etc.

Para que main pueda ser async, añade el macro de Tokio:

#[tokio::main]
async fn main() {
    println!("Hola desde un contexto async");
}

En tests funciona exactamente igual con #[tokio::test]:

#[tokio::test]
async fn mi_test() {
    // aquí puedes usar .await normalmente
}

Tareas con tokio::spawn

La forma más directa de ejecutar trabajo concurrente en Tokio es tokio::spawn. Lanza una tarea en el threadpool y devuelve un JoinHandle<T> que puedes esperar para obtener el resultado:

#[tokio::main]
async fn main() {
    let handle = tokio::spawn(async {
        // trabajo que puede tardar
        42
    });

    let resultado = handle.await.unwrap();
    println!("La tarea devolvió: {}", resultado);
}

Crear tareas en Tokio es barato, del orden de microsegundos. Puedes lanzar miles sin problema. Eso sí, las tareas deben implementar Send, porque el scheduler puede moverlas entre threads. Si tienes datos que no son Send (como un Rc<T>), usa tokio::task::spawn_local junto con un LocalSet.

tokio::select!: varios futuros a la vez

El macro select! ejecuta varios Futures concurrentemente y completa cuando uno de ellos termina. El resto se cancelan. Viene muy bien para imponer timeouts o esperar la primera respuesta de varias operaciones:

use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    tokio::select! {
        _ = sleep(Duration::from_secs(5)) => {
            println!("Timeout: la operación tardó demasiado");
        }
        resultado = operacion_de_red() => {
            println!("Resultado: {:?}", resultado);
        }
    }
}

Para el caso concreto de añadir un timeout a un Future, hay una alternativa más sencilla:

use tokio::time::{timeout, Duration};

let resultado = timeout(Duration::from_secs(5), operacion_de_red()).await;
match resultado {
    Ok(valor) => println!("OK: {:?}", valor),
    Err(_) => println!("Timeout"),
}

Ten cuidado con los Futures que hacen trabajo a medias antes de ser cancelados por select!. Si tu Future modifica estado compartido antes del punto de cancelación, puede dejarlo inconsistente. En esos casos vale la pena envolver la operación en un spawn separado y cancelar el handle, que da más control.

Channels en Tokio

Tokio incluye cuatro tipos de canales para comunicar tareas entre sí, cada uno con un caso de uso distinto.

mpsc: múltiples productores, un consumidor

El más habitual. Varios productores mandan mensajes a un único consumidor:

use tokio::sync::mpsc;

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel(32); // buffer de 32 mensajes

    let tx2 = tx.clone();
    tokio::spawn(async move {
        tx.send("mensaje desde tarea 1").await.unwrap();
    });
    tokio::spawn(async move {
        tx2.send("mensaje desde tarea 2").await.unwrap();
    });

    while let Some(msg) = rx.recv().await {
        println!("Recibido: {}", msg);
    }
}

broadcast: un productor, múltiples consumidores

Cada consumidor suscrito recibe todos los mensajes. Perfecto para notificaciones o eventos que interesan a varias partes del sistema:

use tokio::sync::broadcast;

let (tx, mut rx1) = broadcast::channel(16);
let mut rx2 = tx.subscribe();

tx.send("evento").unwrap();
println!("{}", rx1.recv().await.unwrap());
println!("{}", rx2.recv().await.unwrap());

oneshot: canal de un solo uso

Un productor, un consumidor, un solo mensaje. Ideal para el patrón request-response entre tareas:

use tokio::sync::oneshot;

let (tx, rx) = oneshot::channel();
tokio::spawn(async move {
    tx.send(42).unwrap();
});
let respuesta = rx.await.unwrap();

watch: valor compartido con notificación de cambio

Mantiene un valor y notifica a todos los observadores cuando cambia. Útil para configuración dinámica o estado compartido que varios consumidores necesitan leer:

use tokio::sync::watch;

let (tx, mut rx) = watch::channel("estado inicial");
tokio::spawn(async move {
    tx.send("estado actualizado").unwrap();
});
rx.changed().await.unwrap();
println!("Nuevo valor: {}", *rx.borrow());

I/O async con Tokio

Si trabajas con red o archivos dentro de Tokio, nunca uses los tipos de std. std::net::TcpStream bloquea el thread, y si lo haces dentro del runtime de Tokio, impides que ese thread gestione otras tareas mientras espera.

En su lugar, usa los equivalentes async de Tokio:

  • tokio::net::TcpListener y TcpStream para I/O de red sin bloquear
  • tokio::fs para operaciones de sistema de archivos async
  • Los traits AsyncReadExt y AsyncWriteExt de tokio::io para leer y escribir con .await

Un servidor TCP mínimo con Tokio tiene esta pinta:

use tokio::net::TcpListener;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

#[tokio::main]
async fn main() {
    let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap();

    loop {
        let (mut socket, _) = listener.accept().await.unwrap();
        tokio::spawn(async move {
            let mut buf = vec![0; 1024];
            let n = socket.read(&mut buf).await.unwrap();
            socket.write_all(&buf[..n]).await.unwrap();
        });
    }
}

Si necesitas hacer una operación bloqueante de verdad (una librería C, una consulta síncrona), usa tokio::task::spawn_blocking. Mueve la llamada bloqueante a un threadpool separado para no atascar el scheduler de Tokio.

El scheduler multi-hilo

Por defecto, #[tokio::main] arranca un runtime con tantos threads como núcleos tiene la CPU. Cada thread ejecuta tareas del mismo pool global y, cuando un thread termina su trabajo antes que otro, le roba tareas (work-stealing). El resultado es que los núcleos se mantienen ocupados sin que tengas que repartir el trabajo a mano.

Para workloads de I/O (APIs HTTP, microservicios, clientes de base de datos) este scheduler funciona muy bien. Si en cambio tienes un programa de un solo hilo o quieres controlar exactamente el runtime, puedes construirlo a mano:

use tokio::runtime::Builder;

fn main() {
    // Runtime de un solo hilo (útil en tests o CLI simples)
    let rt = Builder::new_current_thread()
        .enable_all()
        .build()
        .unwrap();

    rt.block_on(async {
        println!("Ejecutando en single-thread runtime");
    });
}

Para la mayoría de los servicios web, el runtime multi-hilo que crea #[tokio::main] es lo que quieres sin tocar nada más.

Tokio en la práctica: Axum, reqwest y sqlx

Las tres librerías más habituales para construir servicios en Rust están construidas sobre Tokio. Eso tiene una implicación práctica: si usas las tres juntas, asegúrate de que todas dependen de la misma versión de Tokio (en la práctica, Tokio 1.x es compatible a nivel de API, así que las versiones de parche no suelen dar problemas).

Con Axum arrancar un servidor HTTP es directo:

use axum::{Router, routing::get};

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(|| async { "Hola" }));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Con reqwest, las peticiones HTTP son async y no bloquean el thread:

let respuesta = reqwest::get("https://httpbin.org/get")
    .await?
    .text()
    .await?;

Con sqlx, las queries a base de datos también son async. El pool de conexiones de sqlx funciona directamente sobre el runtime de Tokio:

let pool = sqlx::PgPool::connect("postgres://usuario:pass@localhost/db").await?;
let fila = sqlx::query!("SELECT id, nombre FROM usuarios WHERE id = $1", 1)
    .fetch_one(&pool)
    .await?;
println!("{}: {}", fila.id, fila.nombre);

Si vienes de otros lenguajes donde el servidor HTTP y la base de datos funcionan en threads propios, en Rust con Tokio todo convive en el mismo runtime. Las tareas se multiplexan sobre los threads disponibles sin que tengas que gestionar pools de threads a mano.

Para profundizar en por qué Rust ha ganado tanto terreno en backend y sistemas, echa un vistazo a Rust en el backend y cloud en 2025. Y si estás evaluando entre Rust y Go para tu próximo proyecto, la comparativa Go vs Rust en concurrencia tiene bastante detalle sobre cómo se comporta cada modelo async.

Imagen: Pexels / Stanislav Kondratiev

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP