wandres.dev
SEND Y SYNC · la seguridad entre hilos

Send y Sync: los dos marcadores de la concurrencia

Rust convierte las condiciones de carrera en errores de compilación mediante dos traits marcadores sin métodos, Send y Sync. Son auto-traits que el compilador deduce de la estructura de cada tipo para razonar, en compilación y a coste cero, sobre qué es seguro mover o compartir entre hilos.

⏱ 16 min

La promesa más audaz de Rust no es la seguridad de memoria en un solo hilo —eso ya lo garantiza el ownership— sino extender esa misma garantía a la concurrencia: prometer que un programa multihilo que compila no tiene condiciones de carrera. El instrumento con el que cumple esa promesa cabe en dos nombres, Send y Sync, dos traits sin un solo método cuyo trabajo entero es servir de predicado al compilador. No hacen nada en ejecución; existen para que el sistema de tipos pueda demostrar, antes de emitir el binario, qué datos es lícito mover a otro hilo y cuáles compartir entre varios.

🎯 Al terminar esta lección sabrás
  • Distinguir las dos preguntas que responden Send y Sync: mover frente a compartir.
  • Entender qué es un trait marcador sin métodos y por qué basta para razonar.
  • Ver por qué son auto-traits y qué significa que el compilador los deduzca solo.
  • Situar Send y Sync como los axiomas de la “concurrencia sin miedo”.

Dos preguntas sobre la memoria compartida

La condición de carrera es el fallo más traicionero de la programación concurrente: dos hilos acceden a la misma posición de memoria, al menos uno escribe, y no hay sincronización que ordene esos accesos. El resultado es comportamiento indefinido —lecturas rasgadas, invariantes rotos, corrupción— y, para colmo, no determinista: aparece una vez de cada mil ejecuciones, nunca bajo el depurador, siempre en producción. Rust decide atacar esta clase entera de fallos no con una biblioteca ni con una convención, sino con el sistema de tipos, y para ello descompone “seguridad entre hilos” en dos preguntas precisas.

  • Send responde: ¿es seguro mover un valor de tipo T a otro hilo, cediéndole la propiedad?
  • Sync responde: ¿es seguro compartir una referencia &T entre varios hilos a la vez?
📦

Send — mover

Pregunta si es seguro trasladar la propiedad de un valor a otro hilo. Lo cumple casi todo; lo incumplen Rc y los punteros crudos.

🔗

Sync — compartir

Pregunta si es seguro que varios hilos tengan a la vez una referencia &T al valor. Equivale a que &T sea Send.

Que sean dos preguntas y no una es un acierto de diseño: existen tipos seguros de mover pero no de compartir —lo veremos con RefCell— y separar los dos ejes deja al compilador conceder a cada tipo exactamente el permiso que merece, sin redondear a un genérico “apto para hilos” que sería o demasiado laxo o demasiado estricto.

Toda la maquinaria de concurrencia de la biblioteca estándar está anotada con estas dos preguntas. La firma de thread::spawn es la aduana donde se cobra el peaje:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
    F: FnOnce() -> T + Send + 'static,
    T: Send + 'static,

El límite F: Send sobre la clausura significa que todo lo que la clausura capture y traslade al hilo nuevo debe ser seguro de mover. Si intentas capturar algo que no es Send, el programa no compila: la condición de carrera se convierte en un error de tipos, detectado en tu máquina, no en la del cliente.

Traits marcadores: cero métodos, todo el significado

Send y Sync son traits marcadores: su declaración en la biblioteca estándar tiene el cuerpo vacío.

pub unsafe auto trait Send {}
pub unsafe auto trait Sync {}

Ni un método, ni un tipo asociado, ni una constante. Esto desconcierta al principiante —¿de qué sirve un trait que no exige implementar nada?— pero es justo el punto. Un trait marcador no describe un comportamiento; expresa un predicado sobre el tipo. Toda su información reside en un único bit: o tu tipo lo implementa, o no. Send no dice “así se mueve T entre hilos”; dice, categóricamente, “T puede moverse entre hilos con seguridad”. El compilador no llama a ningún método: lee ese bit en los límites Send y Sync que salpican la biblioteca estándar y acepta o rechaza tu código en consecuencia.

De ahí una consecuencia decisiva: no cuestan nada en ejecución. No hay una tabla, ni una etiqueta, ni un campo oculto que represente “este valor es Send”. Son puro andamiaje de compilación que se evapora en el binario. El coste de la concurrencia sin miedo se paga entero al compilar, y nunca más.

El bit se consulta exactamente en los límites de trait, y comparar dos capturas lo vuelve tangible:

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

fn main() {
    let n = 5;                                    // i32 es Send
    thread::spawn(move || println!("{}", n + 1)); // OK: cruza la aduana

    let cuenta = Rc::new(5);                       // Rc<i32> no es Send
    thread::spawn(move || {                        // ERROR E0277 aqui
        println!("{cuenta}");
    });
}

La primera clausura mueve un i32, que es Send, y pasa. La segunda intenta mover un Rc, que no lo es, y el límite F: Send de thread::spawn la rechaza. El compilador no ha ejecutado nada: ha leído un bit en el tipo de lo capturado y ha decidido en compilación. Ese es todo el mecanismo.

ℹ️
unsafe auto trait no es una contradiccion

Que la declaración lleve unsafe no significa que usar un tipo Send sea peligroso —al contrario, es la marca de que es seguro—. El unsafe se refiere a implementar el trait: prometer que un tipo es Send o Sync es una afirmación sobre el modelo de memoria que, si es falsa, reintroduce las condiciones de carrera. Por eso implementarlos a mano exige unsafe. La palabra auto, por su parte, es la que explica por qué casi nunca los escribes tú: el compilador los deduce.

Auto-traits: el compilador los deduce por ti

La palabra clave auto convierte a Send y Sync en auto-traits. No los derivas con #[derive(...)] ni los implementas: el compilador los concede automáticamente a un tipo si y solo si todos sus componentes ya los tienen. Un struct es Send cuando cada uno de sus campos es Send; se vuelve Sync cuando cada campo es Sync. La propiedad se propaga por la estructura del tipo como una demostración por inducción sobre el árbol de campos.

struct Registro {
    id: u64,        // Send + Sync
    nombre: String, // Send + Sync
} // Registro es Send + Sync: se deduce, no se declara

Basta con incrustar un único componente que no sea Send para que el tipo entero deje de serlo, sin que tú digas nada. Esa contaminación estructural —que veremos en detalle en la última lección de este nivel— es lo que hace que el sistema sea a la vez automático e infalible: no hay que acordarse de nada, porque el compilador nunca olvida recorrer los campos.

flowchart TD
T[Un tipo T] --> Q1[Se puede mover a otro hilo con seguridad]
T --> Q2[Se puede compartir ref T entre hilos]
Q1 -->|si| S[T es Send]
Q1 -->|no| NS[T no es Send]
Q2 -->|si| SY[T es Sync]
Q2 -->|no| NSY[T no es Sync]
style S fill:#a6e3a1,color:#11111b
style SY fill:#a6e3a1,color:#11111b
style NS fill:#f38ba8,color:#11111b
style NSY fill:#f38ba8,color:#11111b
La condicion de carrera, degradada a error de tipos

Detente en la magnitud del truco. La condición de carrera es, históricamente, el fallo más caro de la ingeniería de software: no determinista, irreproducible, invisible al depurador, capaz de sobrevivir años en producción manifestándose una vez cada millón de ejecuciones. Lenguajes enteros conviven con ella y solo ofrecen paliativos —disciplina, revisiones, detectores en ejecución que ralentizan el programa y aun así no la garantizan ausente—. Rust hace algo distinto en naturaleza, no en grado: la elimina por construcción. Y el aparato con el que lo consigue no es un motor sofisticado en ejecución, sino dos traits vacíos. Send y Sync no contienen lógica; son dos predicados que el compilador propaga por el grafo de tipos y consulta en cada frontera entre hilos. La seguridad de la concurrencia deja de ser una propiedad dinámica que se reza por que se cumpla y pasa a ser una propiedad estática que se demuestra. Interioriza que estos dos marcadores son los axiomas de todo lo que viene —threads, canales, Arc, mutex, async— y la concurrencia en Rust dejará de parecer magia: es la deducción lógica de dos bits que el compilador nunca deja de comprobar.

📝
Lo esencial de este nivel

Send = seguro de mover entre hilos. Sync = seguro de compartir &T entre hilos, lo que equivale a que &T sea Send. Ambos son traits marcadores sin métodos, auto-traits que el compilador deduce recorriendo los campos, y de coste cero en ejecución. Todo lo demás del nivel —Rc frente a Arc, RefCell frente a Mutex, el unsafe impl— son corolarios de estas dos definiciones.

⚔️ Reconoce las dos preguntas
  1. Enuncia con tus palabras la diferencia entre la pregunta que responde Send y la que responde Sync.
  2. Lee la firma real de std::thread::spawn en la documentación y localiza los tres límites: Send, 'static y FnOnce. Explica qué protege cada uno.
  3. Argumenta por qué un trait sin métodos puede, aun así, hacer que un programa no compile.
  4. Declara un struct con dos campos u64 y String y razona, sin ejecutar nada, por qué el compilador lo considera Send y Sync.
  5. Anticipa la última lección: si añadieras a ese struct un campo que no fuera Send, ¿qué le pasaría al tipo entero y por qué no tendrías que declararlo?