wandres.dev
SEND Y SYNC · la seguridad entre hilos

Send: mover un valor a otro hilo

Un tipo es Send si es seguro transferir su propiedad a otro hilo. La inmensa mayoría de los tipos lo son; las excepciones —Rc y los punteros crudos— fallan por razones concretas que revelan que Send codifica la ausencia de estado compartido sin sincronizar.

⏱ 17 min

Send es la más sencilla de las dos preguntas y la que gobierna el gesto más común de la concurrencia: arrancar un hilo y entregarle un dato. Un tipo es Send cuando mover un valor suyo a otro hilo —cederle la propiedad, para que el hilo original ya no lo toque— no puede provocar comportamiento indefinido. La lista de tipos que fallan esta condición es sorprendentemente corta, y estudiar por qué fallan enseña más sobre el modelo de memoria de Rust que cualquier definición abstracta.

🎯 Al terminar esta lección sabrás
  • Definir Send como la seguridad de transferir la propiedad de un valor entre hilos.
  • Ver cómo thread::spawn exige Send a todo lo que captura la clausura.
  • Entender por qué Rc no es Send y qué lo separa de un contador atómico.
  • Reconocer los punteros crudos como el otro gran caso !Send y por qué.

Mover la propiedad a través de una frontera

Cuando lanzas un hilo con una clausura move, cada valor capturado deja de pertenecer al hilo padre y pasa a pertenecer al hijo. Eso es un move como cualquiera de los del nivel 8, con una diferencia crucial: el nuevo dueño vive en otro hilo, y ambos hilos avanzan en paralelo. Send es exactamente la garantía de que ese cruce de frontera es inofensivo.

use std::thread;

fn main() {
    let datos = vec![1, 2, 3]; // Vec<i32> es Send
    let handle = thread::spawn(move || {
        // `datos` ha sido MOVIDO aqui; el hilo padre ya no lo posee
        println!("suma = {}", datos.iter().sum::<i32>());
    });
    handle.join().unwrap();
}

Un Vec<i32> es Send, así que el compilador acepta la mudanza sin rechistar. Como el move invalida datos en el padre (regla 2 del ownership), no hay dos dueños: solo el hilo hijo puede tocar ese buffer, y la ausencia de acceso simultáneo es precisamente lo que hace segura la operación. Send, visto así, es la extensión natural del ownership a la dimensión de los hilos: si solo hay un dueño, y ese dueño está en un único hilo, no hay carrera posible.

La regla estructural se aplica igual a tus propios tipos: un struct es Send cuando todos sus campos lo son, sin que anotes nada.

use std::thread;

struct Trabajo {
    id: u64,
    etiquetas: Vec<String>,
} // Send: u64 y Vec<String> lo son, luego Trabajo tambien

fn lanzar(t: Trabajo) {
    thread::spawn(move || {
        println!("trabajo {} con {} etiquetas", t.id, t.etiquetas.len());
    });
}

Trabajo viaja a otro hilo sin ceremonia porque cada una de sus piezas es Send, y no existe ningún impl Send for Trabajo en el código: la propiedad se dedujo de los campos. Basta con que uno solo deje de ser Send para que el tipo entero pierda el permiso de cruzar la frontera.

💡
Casi todo es Send, y esa es la idea

La inmensa mayoría de los tipos que usarás a diario son Send: los enteros, bool, char, String, Vec<T>, Box<T>, HashMap, y tus propios struct y enum compuestos de piezas Send. La regla práctica se invierte: no memorices qué es Send, memoriza la corta lista de lo que no lo es. Si un tipo aparece en esa lista negra, hay siempre una razón concreta ligada a estado compartido sin sincronizar.

Por qué Rc no es Send

El contraejemplo canónico es Rc<T>, el puntero de conteo de referencias del nivel 27. Rc permite varios dueños de un mismo dato llevando la cuenta de cuántos hay; cuando el contador llega a cero, libera. El problema: ese contador es un entero normal, sin protección atómica. Clonar un Rc hace contador += 1; soltarlo hace contador -= 1.

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

fn main() {
    let compartido = Rc::new(42);
    let clon = Rc::clone(&compartido);
    // ERROR E0277: `Rc<i32>` cannot be sent between threads safely
    thread::spawn(move || {
        println!("{clon}");
    });
}

Imagina que Rc fuera Send y que este código compilara. El hilo padre suelta su Rc (contador -= 1) en el mismo instante en que el hijo clona el suyo (contador += 1). Esas dos operaciones no son atómicas: cada una es un ciclo de leer, modificar y escribir, y al entrelazarse pueden pisarse. El contador acaba con un valor equivocado. Si queda demasiado alto, el dato nunca se libera: fuga. Si queda demasiado bajo, se libera mientras alguien lo sigue usando: use-after-free, la vulnerabilidad clásica, resucitada. Rust corta el problema de raíz declarando Rc como !Send: nunca cruza una frontera de hilo, así que su contador jamás sufre una carrera.

📝
Send no equivale a movible

Todo tipo se puede mover en Rust; Send no habla de si un valor puede moverse, sino de si moverlo a otro hilo es seguro. Rc se mueve perfectamente dentro de un mismo hilo —lo haces todo el rato—. Lo que !Send prohíbe es específicamente cruzar la frontera entre hilos. La distinción es fina y esencial: Send es una propiedad sobre hilos, no sobre movilidad.

Punteros crudos: el otro gran !Send

El segundo habitante de la lista negra son los punteros crudos, *const T y *mut T. El compilador los declara !Send (y !Sync) por una razón de principio: un puntero crudo no lleva consigo ninguna garantía. No se sabe si apunta a memoria válida, si hay otros punteros al mismo sitio, ni quién es responsable de liberarla. Mover semejante objeto a otro hilo sería firmar un cheque en blanco contra el modelo de memoria.

struct Crudo {
    ptr: *mut u8, // este campo hace que Crudo sea !Send automaticamente
}

Como los auto-traits se propagan por los campos, meter un *mut u8 en un struct lo vuelve !Send sin que lo declares. Esto es deliberado: el unsafe que rodea a los punteros crudos no debe filtrarse en silencio a la concurrencia. Si de verdad sabes que tu tipo con punteros es seguro de enviar —porque, por ejemplo, gestionas la sincronización tú mismo— tendrás que afirmarlo explícitamente con unsafe impl Send, asumiendo la responsabilidad. Esa es la historia de la última lección de este nivel.

ℹ️
Los handles de recursos externos tambien caen aqui

Muchos envoltorios de recursos del sistema o de bibliotecas de C guardan un puntero crudo por dentro, así que heredan el !Send automáticamente. A veces es lo correcto —ese recurso quizá solo pueda usarse desde el hilo que lo creó— y a veces es demasiado estricto, porque el recurso sí tolera moverse. Distinguir los dos casos, y actuar en consecuencia, es justo la decisión que estudiaremos en la última lección.

flowchart LR
A[Valor de tipo T en el hilo padre] -->|move a la clausura| B[El hilo hijo pasa a ser dueno]
B --> C[Comprobacion en compilacion, T es Send]
C -->|si| OK[Compila, un solo dueno y sin carrera]
C -->|no| ERR[Error E0277 en compilacion]
style OK fill:#a6e3a1,color:#11111b
style ERR fill:#f38ba8,color:#11111b
style C fill:#89b4fa,color:#11111b
Send es el ownership proyectado sobre los hilos

La lección profunda de Send es que no introduce ninguna idea nueva: es el principio del dueño único del nivel 8, contemplado a través de la lente de la concurrencia. Mover un valor a otro hilo es seguro por la misma razón por la que mover un valor a otra función lo es —porque el origen queda invalidado y no hay dos accesos vivos al mismo dato—. Todo lo que Send añade es cerrar la única grieta por la que esa garantía podía escaparse: los tipos que mantienen estado compartido sin sincronizar a espaldas del sistema de ownership. Rc comparte un contador; los punteros crudos comparten vaya usted a saber qué. En ambos casos existe un canal de mutación oculto que el borrow checker no vigila, y por ese canal se colaría la carrera. Send no es, entonces, una restricción arbitraria: es el ownership tapando su propio punto ciego. Cuando entiendas que !Send siempre señala “aquí hay estado compartido que yo no puedo sincronizar”, dejarás de memorizar la lista negra y empezarás a deducirla.

⚔️ Encuentra la grieta
  1. Escribe una función que lance un hilo capturando un Vec<String> con move y verifica que compila; explica por qué el move elimina la posibilidad de carrera.
  2. Sustituye el Vec por un Rc<Vec<String>> y lee el error E0277 completo. Identifica en el mensaje la frase “cannot be sent between threads”.
  3. Cambia Rc por Arc y observa que ahora sí compila. Anota la diferencia y guárdala para la lección de la tabla.
  4. Explica con tus palabras por qué el problema de Rc es su contador y no el dato que envuelve.
  5. Razona por qué un struct que contenga un *mut u8 es !Send sin que nadie lo declare, y qué haría falta para revertirlo.