wandres.dev
CONCURRENCIA CON THREADS · fearless concurrency

Compartir datos mutables entre hilos: por qué el compilador lo bloquea

Un dato mutable compartido entre hilos exige a la vez alias y mutacion, justo la combinacion que la regla nuclear de Rust prohibe. move da posesion exclusiva a un solo hilo, no comparte; Rc no cruza la frontera porque su contador no es atomico. Se ve por qué la carrera de datos se detecta en compilacion y se anticipa Send, Sync y Arc del nivel 30.

⏱ 18 min

Llegamos al corazón del asunto. Lanzar un hilo con datos propios es fácil; el verdadero problema de la concurrencia es compartir estado mutable: que dos hilos vean el mismo dato y al menos uno lo modifique. Ahí es donde otros lenguajes dejan la puerta abierta a la carrera de datos —dos hilos tocando la misma memoria sin coordinación, uno escribiendo—, el error más traicionero de la programación de sistemas, porque no falla siempre y no deja rastro. Rust cierra esa puerta en tiempo de compilación, y lo hace sin inventar reglas nuevas: reutiliza la regla que ya conoces, alias o mutación pero nunca ambas, elevada a la frontera entre hilos. Esta lección muestra por qué cada intento ingenuo de compartir y mutar entre hilos no compila, y deja servido el mecanismo —Send, Sync, Arc— que el nivel 30 formaliza.

🎯 Al terminar esta lección sabrás
  • Ver que compartir estado mutable exige alias más mutación, la combinación que Rust prohíbe.
  • Comprobar que move da posesión exclusiva a un hilo: copia o transfiere, pero no comparte.
  • Entender por qué Rc no puede cruzar a otro hilo: su contador no es atómico y es !Send.
  • Anticipar que Send, Sync y Arc<Mutex<T>> (nivel 30) reintroducen el compartir con seguridad.

Compartir y mutar: dos deseos que chocan

La regla nuclear del borrow checker, la que gobierna todo el lenguaje, es alias XOR mutación: en cualquier instante un dato puede tener muchos lectores (&T) o un único escritor (&mut T), nunca las dos cosas. Compartir estado mutable entre hilos pide justo lo prohibido: varios hilos que aliasan el dato y alguno que lo muta. La colisión no es un accidente de implementación; es lógica, y por eso el compilador la detiene antes de ejecutar nada.

Una carrera de datos es, con precisión, ese cruce: dos o más hilos acceden a la misma dirección, al menos uno escribe, y no hay sincronización que ordene los accesos. Es exactamente alias + mutación sin coordinación. La regla que Rust inventó para impedir un uso-tras-liberación dentro de un hilo resulta ser, palabra por palabra, la que impide una carrera de datos entre hilos.

move da posesión exclusiva, no compartida

El primer instinto es usar move. Pero move no comparte: da posesión, y la posesión es de uno solo. Con tipos Copy el efecto sorprende —copia, y cada hilo muta su propia copia sin tocar al original—:

use std::thread;

fn main() {
    let mut contador = 0;                       // i32 es Copy

    let h1 = thread::spawn(move || contador + 1);   // copia contador dentro
    let h2 = thread::spawn(move || contador + 10);  // otra copia, independiente

    println!("suman: {}", h1.join().unwrap() + h2.join().unwrap());
    println!("el original sigue en {contador}");    // 0: nadie lo compartio
}

Creíste compartir y en realidad duplicaste: los hilos operan sobre copias aisladas y el contador de main no cambia. Con un tipo no-Copy el compilador es aún más tajante, porque mover al primer hilo agota el valor:

use std::thread;

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

    let h1 = thread::spawn(move || datos.len());
    // let h2 = thread::spawn(move || datos.len());  // ERROR: use of moved value
    println!("{}", h1.join().unwrap());
}

datos se movió al primer hilo; el segundo ya no tiene nada que mover. La moraleja es estructural: move expresa transferencia, no reparto. O bien copia (y entonces no hay estado común), o bien entrega a un único dueño (y entonces el otro hilo queda fuera). En ninguno de los dos casos existe el peligro, y por eso ninguno necesita ser bloqueado: la ausencia de compartición es la ausencia de carrera.

Rc no cruza: el contador no es atómico

Para tener varios dueños de un mismo dato ya conoces Rc. El reflejo natural es clonar un Rc y mover una copia a cada hilo. El compilador lo prohíbe en seco:

use std::rc::Rc;
use std::thread;

fn main() {
    let compartido = Rc::new(vec![1, 2, 3]);
    let clon = Rc::clone(&compartido);

    let h = thread::spawn(move || {
        println!("{clon:?}");   // ERROR: `Rc<Vec<i32>>` cannot be sent between threads
    });
    h.join().unwrap();
}

El motivo es preciso. Un Rc mantiene su recuento de referencias con enteros no atómicos: clonar hace contador += 1, soltar hace contador -= 1, operaciones que no son indivisibles. Si dos hilos clonaran o soltaran el mismo Rc a la vez, esos incrementos podrían pisarse —una carrera sobre el propio contador— y acabar liberando la memoria mientras alguien aún la usa, o no liberándola nunca. Rust codifica esa fragilidad marcando Rc como !Send: no es transportable a otro hilo. El compilador, al ver que spawn exige F: Send y que la clausura captura un Rc, deduce que la clausura no es Send y rechaza el programa. La carrera nunca llega a existir porque el tipo que la haría posible no cruza la frontera.

ℹ️
Send y Sync: los dos marcadores que el nivel 30 formaliza

Send significa “este tipo puede transferirse a otro hilo”; Sync significa “este tipo puede compartirse por referencia entre hilos” (formalmente, T: Sync sii &T: Send). Son auto traits: el compilador los deriva solo, y un tipo los pierde en cuanto contiene algo que no los tiene —Rc es !Send y !Sync, y contamina a todo lo que lo envuelve—. La respuesta segura, que verás en el nivel 30, es Arc: un Rc con contador atómico, que sí es Send + Sync, combinado con Mutex o RwLock para serializar la mutación. Arc<Mutex<T>> reintroduce alias y mutación a la vez, pero con la sincronización que convierte la carrera en imposible.

👯

move con tipo Copy

Copia el valor a cada hilo. No hay estado común: cada hilo muta su propia copia y el original no cambia. Seguro por aislamiento.

📦

move con tipo no-Copy

Transfiere la posesión a un único hilo. El segundo ya no tiene nada que mover —“use of moved value”—. Seguro por exclusividad.

🚫

&mut compartido

Exigiría alias más mutación, y además viola 'static. El préstamo exclusivo no se duplica entre hilos. Rechazado en compilación.

🔗

Rc clonado

Es !Send: su contador no atómico se corrompería en una carrera. El compilador impide moverlo a otro hilo. Rechazado.

flowchart TD
D[Dato mutable compartido entre dos hilos] --> A[Exige alias los dos hilos lo ven]
D --> M[Exige mutacion al menos uno escribe]
A --> R[Regla de Rust alias o mutacion nunca ambas]
M --> R
R --> B[El compilador lo bloquea en compilacion sin sincronizacion]
B --> N[Arc mas Mutex reintroduce ambas con seguridad en el nivel 30]
style D fill:#f38ba8,color:#11111b
style R fill:#cba6f7,color:#11111b
style B fill:#89b4fa,color:#11111b
style N fill:#a6e3a1,color:#11111b
La seguridad entre hilos no es una función nueva: es la regla de siempre leída a través de la frontera

Lo asombroso de cómo Rust previene las carreras de datos es que no hay un subsistema dedicado a ello. No existe un “verificador de concurrencia” aparte del borrow checker; existe el mismo borrow checker de siempre, cuya única regla —alias exclusivo o mutación exclusiva, jamás ambas— resulta ser, cuando la miras con los ojos de la concurrencia, la definición misma de ausencia de carrera de datos. Piénsalo: una carrera de datos es dos accesos a la misma memoria, uno de ellos escritura, sin orden entre ellos. Eso es alias más mutación sin sincronización. Y la prohibición de alias más mutación es precisamente el axioma que Rust adoptó, años antes de pensar en hilos, para garantizar que un &mut nunca conviviera con otro préstamo y así erradicar el uso-tras-liberación en código secuencial. La misma invariante que impide que un push a un Vec invalide una referencia que apunta a su interior, impide que dos hilos escriban ese Vec a la vez. Un solo principio, dos catástrofes evitadas. Lo que Send y Sync aportan es el puente que lleva esa invariante a través de la frontera del hilo, donde el borrow checker clásico no alcanza porque los dos flujos ya no comparten una línea temporal que él pueda dibujar. Send marca los tipos que pueden migrar de flujo sin romper sus invariantes internas; Sync, los que pueden ser observados desde varios flujos a la vez. Y como son auto traits, el conocimiento se propaga solo: Rc declara “mi contador es frágil, no me lleves a otro hilo”, y esa única verdad contamina automáticamente cualquier struct, cualquier clausura, cualquier Vec<Rc<...>> que lo contenga, sin que nadie escriba una línea de comprobación. Por eso la concurrencia en Rust se siente distinta: no estás pidiendo permiso a una biblioteca ni recordando disciplinas de bloqueo, estás dejando que el sistema de tipos propague una verdad local hasta sus últimas consecuencias globales. La carrera de datos no se “detecta”; se vuelve inexpresable, porque el tipo que la permitiría no cabe en el hueco que la firma del hilo reserva. Antes de tocar Arc y Mutex, interioriza esto: el compilador no aprendió a razonar sobre hilos, aprendió a razonar sobre alias y mutación, y los hilos resultaron ser solo otro lugar donde esos dos no pueden coincidir.

📝
Lo esencial de compartir mutable entre hilos

Compartir estado mutable exige alias más mutación, la combinación que el borrow checker prohíbe; una carrera de datos es exactamente ese cruce sin sincronización. move no comparte: con tipos Copy duplica (cada hilo muta su copia), con no-Copy transfiere a un único dueño (el otro no puede usarlo). Rc es !Send porque su contador no es atómico, así que el compilador impide moverlo a un hilo y la carrera nunca existe. La solución segura del nivel 30 es Arc (contador atómico, Send + Sync) con Mutex/RwLock para serializar la escritura. La seguridad entre hilos es la regla alias-XOR-mutación leída a través de la frontera, con Send/Sync como puente.

⚔️ Choca con el muro a propósito
  1. Lanza dos hilos que capturen por move un mismo i32, hazlos “incrementarlo” y comprueba que el original no cambia; explica por qué duplicaron en vez de compartir.
  2. Mueve un Vec a un primer hilo y trata de moverlo también a un segundo; lee “use of moved value” y relaciónalo con posesión exclusiva.
  3. Clona un Rc y muévelo a un hilo; lee el error sobre Send y explica qué carrera sobre el contador previene.
  4. Intenta capturar &mut contador (un local) en una clausura de hilo; identifica los dos motivos por los que falla: el bound 'static y el alias mutable.
  5. Enuncia con tus palabras la definición de carrera de datos y muestra cómo la regla “alias o mutación, nunca ambas” la vuelve imposible sin ninguna comprobación en ejecución.