wandres.dev
TOKIO · el runtime

Tareas y robo de trabajo: por qué el scheduler escala

Una tarea de Tokio es un hilo verde: una unidad de ejecución de unos cientos de bytes, no los megabytes de pila de un hilo del sistema. tokio::spawn la lanza y devuelve un JoinHandle para recogerla, abortarla u observarla. Y el scheduler multihilo la reparte con robo de trabajo: colas locales por trabajador, una cola global y hurto entre pares, el diseño que hace que async escale casi lineal en muchos núcleos.

⏱ 19 min

Ya lanzaste tareas con tokio::spawn en el nivel 32; ahora toca entender qué es una tarea y por qué puedes tener cientos de miles sin que el sistema se ahogue. Una tarea es un hilo verde: una unidad de ejecución que vive en el heap y ocupa unos cientos de bytes —el tamaño de la máquina de estados de su future más una pequeña cabecera—, frente a los megabytes de pila que reserva un hilo del sistema operativo. tokio::spawn la entrega al runtime y devuelve un JoinHandle para recogerla, abortarla u observarla. Pero la joya de la corona es cómo el scheduler multihilo reparte esas tareas: el robo de trabajo (work-stealing), un diseño de colas locales y hurto entre pares que hace que async escale casi linealmente al añadir núcleos. Este es el motivo técnico de por qué Tokio corre servidores de millones de conexiones.

🎯 Al terminar esta lección sabrás
  • Entender una tarea como un hilo verde: cientos de bytes en el heap frente a la pila de un hilo del sistema.
  • Manejar el JoinHandle: recoger con await, cancelar con abort y observar con is_finished.
  • Explicar el scheduler de robo de trabajo: colas locales, cola global, ranura LIFO y hurto entre pares.
  • Justificar por qué ese diseño escala en muchos núcleos y minimiza la contención.

Una tarea es un hilo verde

La diferencia de coste entre una tarea y un hilo es de órdenes de magnitud, y de ahí sale todo lo demás. Un hilo del sistema reserva una pila de tamaño fijo —típicamente entre uno y ocho megabytes— y su planificación la arbitra el núcleo con cambios de contexto costosos. Una tarea de Tokio no tiene pila propia: es la máquina de estados que el compilador generó a partir de tu async fn (nivel 32.4), guardada en el heap, más una cabecera con su estado y su Waker. Suele ocupar cientos de bytes. Por eso puedes tener un millón de tareas donde no cabrían mil hilos.

Esto es lo que se llama planificación M:N: muchas tareas (M) multiplexadas sobre pocos hilos del sistema (N, del orden de tus núcleos). El núcleo solo ve y planifica los N hilos trabajadores; la multiplexación de las M tareas sobre ellos la hace Tokio en espacio de usuario, sin cambios de contexto del sistema operativo. Cambiar de una tarea a otra no cruza al núcleo ni salva registros de pila: es, en el fondo, llamar a poll sobre otra máquina de estados. De ahí que “cambiar de tarea” cueste nanosegundos donde “cambiar de hilo” cuesta microsegundos, y que la memoria escale con el estado vivo de cada tarea en vez de con una pila reservada por si acaso.

#[tokio::main]
async fn main() {
    // Cien mil tareas concurrentes: impensable con hilos del sistema,
    // trivial con hilos verdes. Cada una son unos cientos de bytes.
    let mut handles = Vec::new();
    for id in 0..100_000u32 {
        handles.push(tokio::spawn(async move {
            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
            id
        }));
    }
    let mut suma = 0u64;
    for h in handles {
        suma += h.await.unwrap() as u64;
    }
    println!("suma: {suma}");
}

La contrapartida ya la conoces: spawn exige Send + 'static. 'static porque la tarea puede vivir más que el ámbito que la creó, así que no puede sostener préstamos locales; Send porque el scheduler multihilo puede moverla de un hilo trabajador a otro entre dos poll. Esa movilidad no es un capricho: es el requisito que habilita el robo de trabajo.

Hay una distinción de vocabulario que evita confusiones. Una tarea es una unidad que el scheduler planifica de forma independiente —tiene su propio Waker, su sitio en las colas, su JoinHandle—. Los futures que combinas con join! o select! dentro de una misma tarea, en cambio, comparten esa única unidad de planificación: se solapan, sí, pero como una sola pieza que el scheduler avanza a la vez. Por eso spawn exige Send y join! no: cruzar de hilo solo lo hace lo que el scheduler planifica por separado. “Concurrencia” (varios futures progresando intercalados) y “paralelismo” (varias tareas corriendo a la vez en distintos núcleos) se separan aquí con nitidez: join! da lo primero; spawn en multihilo, ambos.

El JoinHandle: recoger, cancelar, observar

tokio::spawn devuelve un JoinHandle<T>, un mando a distancia sobre la tarea que no bloquea al crearse. Ofrece tres operaciones que conviene distinguir:

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

#[tokio::main]
async fn main() {
    let handle = tokio::spawn(async {
        sleep(Duration::from_secs(1)).await;
        "terminado"
    });

    // observar sin esperar
    if !handle.is_finished() {
        println!("aun corriendo");
    }

    // cancelar: pide el fin de la tarea en su proximo punto de suspension
    handle.abort();

    match handle.await {
        Ok(valor) => println!("produjo {valor}"),
        Err(e) if e.is_cancelled() => println!("fue cancelada"),
        Err(e) if e.is_panic() => println!("entro en panico"),
        Err(_) => println!("otro fallo de join"),
    }
}

Tres ideas clave. Hacer .await sobre el handle recoge el resultado, envuelto en un Result porque la tarea pudo entrar en pánico o ser cancelada —un pánico en una tarea no tumba el proceso, se captura y llega como Err—. abort solicita la cancelación: la tarea se detendrá en su próximo .await, un modelo cooperativo, no una interrupción brutal. Y is_finished observa sin consumir. Soltar el JoinHandle sin await no cancela la tarea: sigue corriendo como tarea desligada; para cancelar hay que abortar explícitamente.

Esa naturaleza cooperativa de la cancelación merece subrayarse porque contradice la intuición de quien viene de los hilos. abort no interrumpe la tarea a mitad de una instrucción ni le inyecta una excepción: marca la tarea para que, la próxima vez que llegue a un punto de suspensión —un .await—, no se reanude y se libere. Una tarea que hace cómputo puro sin puntos de await es, por tanto, inabortable hasta que llegue a uno; la cancelación en async siempre ocurre en las junturas donde la tarea ya cedía el control. Para gestionar colecciones dinámicas de tareas —abortarlas en bloque, esperar a la primera que termine— existe JoinSet, que verás en el nivel 35 junto a select!.

El scheduler con robo de trabajo

Aquí vive la ingeniería que hace escalar a Tokio. El robo de trabajo no lo inventó Tokio: es un resultado clásico de la investigación en paralelismo —el planificador de Cilk lo formalizó en los años noventa, con garantías demostrables sobre tiempo y memoria— que Tokio adapta al caso asíncrono. La idea central es que el scheduler multihilo no tiene una sola cola de tareas listas, porque eso obligaría a todos los hilos a competir por un mismo candado, y la contención mataría el rendimiento al crecer los núcleos. En su lugar, cada hilo trabajador tiene su propia cola local de tareas, y solo cuando la suya se vacía va a buscar trabajo a otra parte.

El reparto funciona por capas:

  • Cola local por trabajador: una cola de capacidad fija (256 tareas) que cada hilo consume sin candado en el caso común. La mayoría del trabajo nace y muere aquí.
  • Ranura LIFO: cuando una tarea despierta a otra —típico del paso de mensajes—, la despertada va a una ranura especial que se atiende la siguiente, preservando la localidad de caché entre productor y consumidor.
  • Cola global de inyección: donde caen las tareas creadas desde fuera de un trabajador o las que desbordan una cola local. Se consulta de vez en cuando para que nada muera de hambre.
  • Robo entre pares: cuando un trabajador vacía su cola, elige otro al azar y le roba la mitad de sus tareas pendientes. Así el trabajo se equilibra solo, sin un coordinador central.
flowchart TD
G[Cola global de inyeccion] --> W1[Trabajador 1 cola local]
G --> W2[Trabajador 2 cola local]
G --> W3[Trabajador 3 cola local]
W1 --> P1[poll de tareas del hilo 1]
W2 --> P2[poll de tareas del hilo 2]
W3 --> P3[poll de tareas del hilo 3]
P3 -->|su cola se vacio| R[Roba la mitad a un par al azar]
R -.-> W1
style G fill:#fab387,color:#11111b
style W1 fill:#89b4fa,color:#11111b
style W2 fill:#89b4fa,color:#11111b
style W3 fill:#89b4fa,color:#11111b
style R fill:#f38ba8,color:#11111b

Por qué escala en muchos núcleos

El robo de trabajo ataca de raíz el enemigo de la concurrencia: la contención. Con una cola global única, cada spawn y cada extracción tocaría el mismo candado; al pasar de 4 a 64 núcleos, ese punto caliente se vuelve un cuello de botella que anula el paralelismo añadido —el runtime pasaría más tiempo peleando por el candado que ejecutando tareas—. Las colas locales hacen que el caso común —un trabajador consumiendo su propia cola— sea libre de coordinación: ni candados ni comunicación entre hilos.

El robo solo entra en juego cuando un trabajador se queda ocioso, es decir, exactamente cuando hay desequilibrio que corregir y sobra capacidad para pagar el coste del hurto. El resultado es un sistema que se autoequilibra sin coordinador central y cuya contención crece muy despacio con los núcleos. Esa curva —rendimiento casi lineal al añadir núcleos— es la razón cuantitativa de que Tokio sostenga cargas que tumbarían a un modelo de hilo por conexión.

Queda un riesgo que el diseño también contempla: la inanición. Si los trabajadores solo miraran sus colas locales y las de sus vecinos, una tarea atrapada en la cola global de inyección podría no ejecutarse nunca. Por eso cada trabajador consulta la cola global cada cierto número de iteraciones, aunque su cola local tenga trabajo, garantizando que ninguna tarea espere indefinidamente. Es el mismo compromiso que atraviesa todo el scheduler: optimizar agresivamente el caso común —tu cola local— sin sacrificar la garantía de que toda tarea acabará avanzando. Rendimiento en el camino frecuente, equidad como red de seguridad.

💡
No bloquees un trabajador

Todo el diseño se apoya en una premisa: las tareas ceden rápido en cada .await, devolviendo el hilo al scheduler. Una tarea que hace trabajo CPU-bound largo sin puntos de await, o que llama a una API bloqueante, secuestra su hilo trabajador entero: mientras lo tenga, ese hilo no atiende a nadie más ni puede ser robado. Con worker_threads iguales a los núcleos, unas pocas tareas así paralizan el runtime. La regla de oro es no bloquear jamás un trabajador; para trabajo bloqueante o pesado, spawn_blocking (nivel 34.5).

El robo de trabajo es descentralización como estrategia de escala

El scheduler de robo de trabajo es una lección de arquitectura que trasciende async: enseña que la manera de escalar un sistema concurrente no es coordinar mejor, sino coordinar menos. La intuición ingenua diría que un buen planificador necesita una visión global —una cola central donde vea todas las tareas y las asigne con criterio al hilo más libre—. Esa intuición es exactamente la que no escala, porque toda visión global es un punto de sincronización, y todo punto de sincronización es un candado por el que compiten los núcleos, y esa competición crece con el número de núcleos hasta devorar la ganancia que esos núcleos aportaban. El robo de trabajo invierte la filosofía: en vez de un cerebro central que reparte, cada trabajador es autónomo y egoísta —consume su propia cola sin hablar con nadie— y solo coopera cuando se queda sin trabajo, momento en que roba de un vecino al azar. Fíjate en la asimetría temporal que lo hace brillante: el caso frecuente —hay trabajo local— es libre de coordinación y por tanto libre de contención; el caso raro —un trabajador ocioso— es el único que paga el coste de sincronizarse para robar, y lo paga justo cuando tiene ciclos de sobra porque no tiene nada mejor que hacer. El sistema gasta coordinación solo cuando hay desequilibrio que corregir y capacidad ociosa para corregirlo. La ranura LIFO añade una segunda sabiduría, la de la localidad: cuando una tarea despierta a otra —un productor que alimenta a un consumidor por un canal—, lo más probable es que los datos que el consumidor necesita estén calientes en la caché del núcleo que acaba de producirlos, así que ejecutarlo de inmediato en el mismo hilo, saltándose la cola, evita un viaje de los datos entre cachés de distintos núcleos. Cada decisión del diseño —colas locales, robo de la mitad, elección aleatoria de víctima, ranura LIFO— es una respuesta concreta a una física concreta: la de que sincronizar núcleos cuesta y mover datos entre cachés cuesta. Cuando entiendes que async escala no por hacer las tareas más rápidas sino por hacer su reparto casi libre de contención, comprendes por qué “cientos de miles de tareas sobre un puñado de hilos” no es una exageración de folleto, sino la consecuencia medible de una arquitectura que eligió la descentralización como principio.

📝
Lo esencial

Una tarea es un hilo verde: la máquina de estados de tu future en el heap más una cabecera, cientos de bytes frente a los megabytes de pila de un hilo del sistema. tokio::spawn la lanza (exige Send + 'static) y devuelve un JoinHandle: await recoge un Result —la tarea pudo entrar en pánico o ser cancelada—, abort cancela cooperativamente en el próximo .await, is_finished observa. El scheduler multihilo escala por robo de trabajo: colas locales sin candado por trabajador, una ranura LIFO para la localidad de caché, una cola global de inyección y hurto de la mitad a un par ocioso. El caso común es libre de coordinación; la contención solo aparece al robar. Nunca bloquees un trabajador.

⚔️ Mide y estresa el scheduler
  1. Lanza cien mil tareas que solo duerman 10 ms y confirma que el proceso ni se inmuta; estima la memoria total sabiendo que cada tarea son cientos de bytes, y compárala con cien mil hilos.
  2. Crea una tarea, obsérvala con is_finished, cancélala con abort y comprueba en el JoinHandle que el Err reporta is_cancelled.
  3. Provoca un panic! dentro de una tarea y demuestra que el proceso sobrevive y el pánico llega como Err con is_panic.
  4. Mete una tarea con un bucle CPU-bound sin await en un runtime de worker_threads(1) y observa cómo estrangula a las demás; explícalo en términos de “secuestrar el trabajador”.
  5. Explica con tus palabras por qué una única cola global de tareas escalaría peor que las colas locales con robo, y qué papel juega la ranura LIFO en la localidad de caché.