wandres.dev
CONCURRENCIA CON THREADS · fearless concurrency

Scoped threads: prestar de la pila del padre sin miedo

thread::scope, estable desde Rust 1.63, crea hilos que pueden tomar prestados datos locales del padre porque garantiza que todos terminan antes de que scope retorne. Ese cierre estructural aporta la garantia temporal que faltaba, y el bound 'static de spawn se relaja al preciso 'scope. Compartir de solo lectura sin Arc y repartir mutacion disjunta entre hilos, todo verificado en compilacion.

⏱ 18 min

El bound 'static de thread::spawn era pesimista por necesidad: como spawn no puede prometer cuándo terminará el hilo, exige el máximo, que nada prestado de la pila del padre cruce la frontera. Pero muchísima concurrencia real es más disciplinada: lanzas unos hilos, esperas a que todos acaben, y solo entonces continúas. Si el patrón garantiza que ningún hilo sobrevive al ámbito que lo lanzó, entonces prestar de la pila del padre es perfectamente seguro, porque la pila sigue viva mientras los hilos corren. thread::scope —estable desde Rust 1.63— captura exactamente ese patrón: abre un ámbito, deja que sus hilos tomen prestados los datos locales, y no retorna hasta que todos han terminado. La garantía temporal que a spawn le faltaba, aquí es estructural, y el compilador la convierte en un lifetime en el que confiar.

🎯 Al terminar esta lección sabrás
  • Comprender la garantía de thread::scope: todos sus hilos terminan antes de que el ámbito retorne.
  • Ver cómo esa garantía relaja el bound 'static de spawn al más preciso 'scope.
  • Compartir datos de solo lectura entre varios hilos por préstamo, sin Arc ni clonado.
  • Repartir mutación disjunta entregando fragmentos &mut no solapados a hilos distintos.

El precio del ’static y cómo scope lo evita

Recuerda por qué spawn pedía 'static: el hilo hijo podía sobrevivir a la función que lo lanzó, así que prestar de su pila daba una referencia colgante. Pero ese peligro solo existe si el hilo puede sobrevivir al padre. thread::scope elimina esa posibilidad de raíz con una promesa: la llamada no retorna hasta que todos los hilos lanzados en el ámbito han terminado (se les hace join automáticamente al cerrar). Si el padre no puede avanzar más allá del scope mientras haya hilos vivos, entonces la pila del padre está garantizada viva durante toda la vida de esos hilos, y prestar de ella es sólido.

La firma del spawn de un ámbito refleja el cambio en una sola palabra:

// Metodo de Scope<'scope, 'env>, conceptualmente:
pub fn spawn<F, T>(&'scope self, f: F) -> ScopedJoinHandle<'scope, T>
where
    F: FnOnce() -> T + Send + 'scope,   // 'scope, no 'static
    T: Send + 'scope,

Donde thread::spawn exigía 'static, Scope::spawn exige 'scope: los datos prestados solo tienen que vivir tanto como el ámbito, no tanto como el programa. Esa rebaja quirúrgica es todo lo que hace falta.

thread::scope: prestar de la pila del padre

El ejemplo que spawn rechazaba —dos hilos leyendo un Vec local— aquí compila sin move, sin Arc y sin clonar, porque los hilos simplemente prestan &datos:

use std::thread;

fn main() {
    let datos = vec![1, 2, 3, 4, 5];
    let objetivo = 3;

    thread::scope(|s| {
        s.spawn(|| {
            let suma: i32 = datos.iter().sum();       // presta &datos
            println!("suma: {suma}");
        });
        s.spawn(|| {
            let hay = datos.contains(&objetivo);      // presta &datos y &objetivo
            println!("contiene {objetivo}: {hay}");
        });
    }); // <- aqui se hace join de ambos hilos automaticamente

    println!("datos sigue vivo: {datos:?}");   // no se movio, solo se presto
}

Dos hilos comparten &datos a la vez —muchos lectores, ningún escritor: alias sin mutación, permitido—. Y como scope no retorna hasta que ambos acaban, el println! final vuelve a usar datos intacto: nunca se movió, solo se prestó. Compara con la lección uno, donde compartir un Vec de solo lectura obligaba a clonar o a envolver en Arc; el ámbito lo vuelve innecesario.

💡
ScopedJoinHandle: join manual para recoger valores, o cierre automático

s.spawn devuelve un ScopedJoinHandle cuyo join funciona igual que el de siempre y sirve para recoger el valor de un hilo antes de que el ámbito cierre. Si no lo llamas, no pasa nada: al final del scope se hace join de todos los hilos pendientes automáticamente. Un pánico en cualquier hilo del ámbito se propaga al padre cuando el scope cierra, de modo que un fallo en un trabajador no se pierde en silencio.

Reparto mutable disjunto

Lo verdaderamente potente llega con la mutación. Compartir un &mut entre hilos sigue prohibido —sería alias más mutación—, pero se pueden partir los datos en fragmentos que no se solapan y dar a cada hilo el suyo. Métodos como split_at_mut o chunks_mut producen varios &mut disjuntos, y como no se solapan, cada uno tiene un único escritor y la regla se respeta:

use std::thread;

fn main() {
    let mut v = vec![0i32; 8];
    let (izq, der) = v.split_at_mut(4);   // dos &mut que no se solapan

    thread::scope(|s| {
        s.spawn(move || {
            for x in izq.iter_mut() { *x += 1; }   // este hilo posee la mitad izquierda
        });
        s.spawn(move || {
            for x in der.iter_mut() { *x += 2; }   // el otro, la derecha
        });
    });

    println!("{v:?}");   // [1, 1, 1, 1, 2, 2, 2, 2]
}

Cada hilo recibe por move un &mut [i32] distinto —una referencia mutable no estática, que spawn habría rechazado y que scope acepta porque solo vive dentro del ámbito—. No hay alias: izq y der cubren zonas de memoria ajenas, así que dos escritores simultáneos jamás tocan la misma dirección. El compilador verifica la disyunción a través de split_at_mut (cuya firma promete devolver mitades que no se solapan) y deja correr la mutación paralela sin Mutex ni sincronización de ninguna clase. Paralelismo de datos real, comprobado en compilación.

flowchart TD
E[Variables en la pila del padre] --> SC[thread scope abre un ambito]
SC --> T1[Hilo 1 presta datos del padre]
SC --> T2[Hilo 2 presta datos del padre]
T1 --> B[scope no retorna hasta que ambos terminan]
T2 --> B
B --> R[Recien entonces el padre continua]
R --> V[Ningun prestamo sobrevive a los datos prestados]
style SC fill:#89b4fa,color:#11111b
style B fill:#cba6f7,color:#11111b
style V fill:#a6e3a1,color:#11111b
Un ámbito convierte una garantía temporal en un lifetime que el compilador sabe verificar

La lección de fondo es que 'static nunca fue el requisito verdadero; era solo el sustituto que spawn usaba ante su ignorancia. El requisito real de la seguridad es más débil y más exacto: un hilo no debe sobrevivir a los datos que toma prestados. thread::spawn no puede verificar ese enunciado preciso porque no sabe cuánto vivirá el hilo, así que se refugia en la única cota que lo implica con certeza —'static, “vive al menos tanto como el programa”—, pagando el precio de rechazar montañas de código perfectamente seguro. Lo que thread::scope aporta no es una excepción a las reglas, sino la pieza de información que faltaba: una garantía estructural de que los hilos mueren dentro del ámbito. Y aquí está la elegancia profunda: esa garantía en tiempo de ejecución —“el scope bloquea hasta el último join”— se codifica como una relación entre lifetimes en tiempo de compilación. El ámbito introduce dos vidas, 'scope (la de los hilos) y 'env (la de lo prestado), con 'env conteniendo a 'scope; los datos capturados deben vivir 'env, y como los hilos viven solo 'scope, el borrow checker concluye por pura álgebra de lifetimes que ningún préstamo puede colgar. El mismo verificador que rechazó el préstamo en spawn lo acepta en scope, no porque se hayan aflojado las reglas, sino porque ahora existe un lifetime concreto sobre el que razonar en vez de un futuro desconocido. Esto es concurrencia estructurada: la vida de los hilos se anida en el léxico del programa igual que la vida de una variable se anida en su bloque, y de esa anidación el compilador extrae seguridad gratis. La moraleja trasciende los hilos y toca el corazón del diseño de Rust: cuando una API pide una cota más fuerte de la necesaria —'static donde bastaría “no sobrevivas a esto”—, casi siempre es porque le falta una garantía que no puede exigir; dale esa garantía en forma de estructura, y podrás cobrar la cota exacta. scope no rompe el modelo de posesión y lifetimes para hacer posible el préstamo entre hilos: lo lleva a su conclusión más afilada, demostrando que la misma maquinaria que parecía prohibir la concurrencia compartida en realidad solo esperaba que le dijeras cuánto viven las cosas.

📝
Lo esencial de thread::scope

thread::scope(|s| ...) (Rust 1.63) crea un ámbito cuyos hilos, lanzados con s.spawn, pueden prestar datos locales del padre. La clave es que scope no retorna hasta que todos sus hilos terminan (join automático), garantía que relaja el bound de 'static a 'scope: lo prestado solo debe vivir tanto como el ámbito. Así se comparte de solo lectura sin Arc ni clonado (muchos &datos a la vez), y se reparte mutación disjunta con split_at_mut o chunks_mut, dando a cada hilo un &mut no solapado. ScopedJoinHandle::join recoge valores; un pánico se propaga al cerrar. Es concurrencia estructurada: la vida de los hilos se anida en el léxico.

⚔️ Presta con estructura
  1. Reescribe con thread::scope un caso que con thread::spawn exigía clonar un Vec; comprueba que el Vec sigue usable tras el ámbito porque solo se prestó.
  2. Lanza tres hilos en un ámbito que lean, cada uno, una porción distinta de un mismo slice compartido y sumen; recoge los tres subtotales con join y súmalos.
  3. Usa split_at_mut para partir un vector en dos mitades y muta cada una en un hilo distinto; explica por qué no hay carrera pese a la mutación simultánea.
  4. Prueba a compartir el mismo &mut en dos hilos del ámbito y lee el error; relaciónalo con alias más mutación, que scope no relaja.
  5. Provoca un panic! en uno de los hilos del ámbito y observa cómo se propaga al padre al cerrar el scope; contrástalo con recoger ese pánico vía join antes del cierre.