Tu primer programa async con Tokio
Suficiente teoría: hagámoslo correr. Tokio aporta el motor que el lenguaje se niega a traer. El atributo #[tokio::main] construye un runtime y bloquea sobre tu main async; tokio::spawn lanza tareas concurrentes; y con join! dos esperas independientes dejan de sumarse para pasar a solaparse. Aquí ves, por fin, el pago de todo el nivel.
Has entendido el porqué; toca ver el cómo. Tokio es el runtime que aporta justo lo que el lenguaje omite: el executor que conduce tus futures, el reactor que habla con la red, el planificador que reparte tareas entre hilos. Con tres piezas tienes un programa async real: #[tokio::main] para arrancar el runtime y bloquear sobre tu main asíncrono, tokio::spawn para lanzar tareas que corren a la vez, y join! para solapar esperas independientes. En este último paso se paga, contante y sonante, la promesa de la lección uno: dos esperas de cien milisegundos que juntas costaban doscientos pasan a costar cien.
- Arrancar un runtime con
#[tokio::main]y escribir unaasync fn main. - Lanzar tareas concurrentes con
tokio::spawny recoger su resultado con elJoinHandle. - Ejecutar dos operaciones de E/S a la vez con
tokio::join!. - Medir el ahorro: la concurrencia convierte una suma de esperas en su máximo.
Preparar el terreno: la dependencia y #[tokio::main]
Primero, la dependencia. El feature full activa todo lo que necesitas para empezar:
[dependencies]
tokio = { version = "1", features = ["full"] }
Y el “hola mundo” async:
#[tokio::main]
async fn main() {
println!("hola desde una tarea async");
}
#[tokio::main] es una macro que reescribe tu async fn main en una fn main síncrona normal que construye un runtime y llama a block_on sobre el cuerpo async. En esencia, expande a esto:
fn main() {
tokio::runtime::Runtime::new().unwrap().block_on(async {
println!("hola desde una tarea async");
});
}
block_on es el puente del mundo síncrono al asíncrono: conduce un future hasta el final sobre el hilo actual. Por defecto el runtime es multihilo, con tantos hilos trabajadores como núcleos; si prefieres uno de un solo hilo, #[tokio::main(flavor = "current_thread")].
async fn main y el primer await
Un .await de verdad necesita algo que espere. Para simular la latencia de E/S usamos tokio::time::sleep, que cede la tarea mientras el temporizador corre —a diferencia de std::thread::sleep, que bloquearía el hilo trabajador entero—:
use tokio::time::{sleep, Duration};
async fn descargar(nombre: &str) -> String {
sleep(Duration::from_millis(100)).await; // simula la latencia de la red
format!("datos de {nombre}")
}
#[tokio::main]
async fn main() {
let datos = descargar("informe").await;
println!("{datos}");
}
std::thread::sleep no cede: duerme el hilo trabajador completo, y con él todas las tareas que ese hilo debía atender. Dentro de async, cualquier pausa temporal va con tokio::time::sleep, que suspende solo la tarea. La misma regla vale para toda API bloqueante: dentro de una tarea, usa la variante async, o delega en spawn_blocking.
spawn: tareas concurrentes
tokio::spawn toma un future y lo entrega al runtime como una tarea independiente que empieza a correr enseguida, quizá en otro hilo trabajador. Devuelve un JoinHandle sobre el que puedes hacer .await para recoger su resultado:
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let mut handles = Vec::new();
for i in 0..3 {
let h = tokio::spawn(async move {
sleep(Duration::from_millis(100)).await;
i * 10
});
handles.push(h);
}
for h in handles {
let valor = h.await.unwrap(); // JoinHandle produce un Result
println!("tarea produjo {valor}");
}
}
Las tres tareas corren concurrentemente: sus sleep se solapan, así que el tiempo total de reloj es de unos cien milisegundos, no trescientos. Dos detalles importan. spawn exige que el future sea Send + 'static —debe poder moverse a otro hilo y no depender de préstamos locales—, un anticipo de Send/Sync del nivel 30. Y el JoinHandle entrega un Result porque la tarea podría entrar en pánico o ser cancelada.
Concurrencia real: join! y el ahorro
tokio::join! avanza varios futures a la vez en la misma tarea —sin spawn, sin requerir Send— y termina cuando todos han terminado, devolviendo sus resultados en una tupla:
use tokio::time::{sleep, Duration};
async fn descargar(nombre: &str) -> String {
sleep(Duration::from_millis(100)).await;
format!("datos de {nombre}")
}
#[tokio::main]
async fn main() {
// Secuencial: 100 + 100 = 200 ms
let a = descargar("A").await;
let b = descargar("B").await;
println!("secuencial: {a}, {b}");
// Concurrente: max(100, 100) = 100 ms
let (a, b) = tokio::join!(descargar("A"), descargar("B"));
println!("concurrente: {a}, {b}");
}
Aquí está el pago del nivel entero. Dos esperas independientes que sumaban doscientos milisegundos pasan a costar cien, porque el runtime las solapa: mientras una duerme, la otra avanza. Y escala sin límite práctico —diez descargas concurrentes siguen costando unos cien milisegundos en vez de un segundo—. La lección uno prometió “solapar la espera”; join! es esa promesa hecha código.
flowchart TD M[tokio main arranca el runtime] --> S[Camino secuencial] M --> C[Camino concurrente con join] S --> S1[await descargar A cien ms] S1 --> S2[await descargar B cien ms] S2 --> ST[Total doscientos ms] C --> C1[join avanza A y B a la vez] C1 --> CT[Total cien ms el maximo] style S fill:#89b4fa,color:#11111b style C fill:#cba6f7,color:#11111b style ST fill:#f38ba8,color:#11111b style CT fill:#a6e3a1,color:#11111b
#[tokio::main]
Reescribe tu main async en uno síncrono que crea el runtime y hace block_on. El puente del mundo síncrono al asíncrono.
tokio::spawn
Lanza una tarea de vida propia, quizá en otro hilo. Devuelve un JoinHandle. Exige Send + 'static.
tokio::join!
Solapa varios futures en la misma tarea y espera a todos. Sin spawn, sin Send. Ideal para un número fijo y conocido.
JoinSet / FuturesUnordered
Para colecciones dinámicas de tareas concurrentes. Los verás en el nivel 35, junto a select! y streams.
Usa join! cuando conoces de antemano los futures que quieres solapar y todos viven en la misma tarea; es ligero y no requiere Send. Usa spawn cuando la tarea debe tener vida propia —seguir corriendo aunque tú sigas a lo tuyo— o repartirse entre hilos. Para un número variable de tareas que nacen sobre la marcha, JoinSet (nivel 35). Elegir mal no rompe nada, pero spawn donde bastaba join! te obliga a satisfacer Send + 'static sin necesidad.
Ese 100 ms en lugar de 200 ms es todo el nivel condensado en un número. Detente en lo que no dice: async no ha hecho más rápida ninguna descarga individual —cada sleep sigue tardando sus cien milisegundos exactos—. Lo que ha hecho es dejar que las dos esperas convivan, transformando una suma en un máximo. Esa es la única cosa que la concurrencia de E/S sabe hacer, y resulta ser justo la que importa cuando el tiempo se va esperando: convertir “uno tras otro” en “todos a la vez” cuesta, por tarea, unos cientos de bytes en el heap en lugar de los megabytes de pila de un hilo, de modo que puedes solapar no dos esperas sino cien mil. Mira ahora cómo se ensamblan las cinco lecciones bajo este ejemplo. La lección uno diagnosticó que la E/S es espera y que un hilo por tarea no escala; el join! es el remedio a esa dolencia. La lección dos explicó que un future es perezoso; por eso descargar("A") dentro de join! no ha empezado hasta que la macro decide avanzarlo, y por eso se pueden pasar dos futures inertes y arrancarlos juntos. La lección tres advirtió que el lenguaje no trae motor; Tokio es exactamente ese motor, y #[tokio::main] la llave de contacto que std se negó a incluir. La lección cuatro reveló que cada descargar es una máquina de estados; lo que join! hace, por debajo, es sondear ambas máquinas por turnos hasta que las dos dicen Ready, aparcando la que aún duerme. Nada aquí es magia: es pereza conducida por un executor que hace poll sobre máquinas de estados que el compilador escribió por ti, despertadas por wakers cableados a un reactor. Cuando ejecutes este programa y veas los dos tiempos impresos, no estarás viendo un truco de biblioteca, sino la culminación de una cadena de decisiones de diseño que empieza en “la E/S es esperar” y termina en “entonces hagamos que las esperas se solapen barato”. Ese es el momento en que async deja de ser sintaxis extraña y se convierte en una herramienta que entiendes de arriba abajo.
- Ejecuta el programa de
join!cronometrando ambos caminos constd::time::Instant; confirma que el secuencial ronda los 200 ms y el concurrente los 100 ms. - Convierte el
join!de dos descargas en diez y verifica que el tiempo total apenas cambia. Explica por qué. - Reescribe la versión concurrente con
tokio::spawnyJoinHandleen lugar dejoin!, y razona qué requisito extra (Send + 'static) aparece y por qué. - Sustituye un
tokio::time::sleeppor unstd::thread::sleepdentro de una tarea sobre el runtimecurrent_thready observa cómo se rompe la concurrencia; explica qué hilo quedó bloqueado. - Provoca un
panic!dentro de una tareaspawny comprueba que elJoinHandlete devuelve unErren vez de tumbar el proceso; relaciónalo con por quéJoinHandleproduce unResult.