Auto-traits y unsafe impl: la responsabilidad del marcador
Send y Sync se derivan solos recorriendo los campos de cada tipo, sin derive. En los casos raros en que el compilador se queda corto por conservador, se implementan a mano con unsafe impl, un gesto que traslada al programador la obligación de demostrar la ausencia de carreras.
Hemos dicho muchas veces que el compilador “deduce” Send y Sync. Esta lección explica el mecanismo exacto de esa deducción —una recursión estructural sobre los campos de cada tipo— y llega al lugar donde la automatización se detiene: los tipos construidos sobre punteros crudos, donde el compilador es deliberadamente conservador y calla. Ahí, y solo ahí, aparece unsafe impl Send o unsafe impl Sync: una promesa manual que devuelve al programador la carga de la prueba que el sistema suele llevar por él.
- Explicar la derivación estructural de
SendySyncsobre los campos. - Usar
PhantomDatapara retirarSendoSyncde un tipo sin coste. - Reconocer cuándo el compilador es demasiado conservador y por qué.
- Asumir la responsabilidad que implica un
unsafe impl SendoSync.
La derivación estructural
Un auto-trait no se deriva con #[derive]: el compilador lo concede recorriendo la estructura del tipo. La regla es una recursión de una sola línea: un tipo compuesto es Send si todos sus campos son Send, y Sync si todos sus campos son Sync. El caso base son los tipos primitivos —enteros, bool, char— que son ambos; el paso inductivo son los struct, enum y tuplas, que heredan la conjunción de sus partes.
use std::rc::Rc;
struct Limpio {
id: u64,
nombre: String,
} // Send + Sync: todos sus campos lo son
struct Contaminado {
id: u64,
cache: Rc<String>, // Rc no es Send ni Sync...
} // ...asi que Contaminado tampoco lo es, automaticamente
No hay que anotar nada. Añadir un Rc en lo más hondo de una jerarquía de struct retira Send y Sync de todo lo que lo contiene, en cascada, hasta la raíz. Esta propagación silenciosa es la que hace el sistema infalible: es imposible olvidarse de marcar un tipo como inseguro, porque nadie lo marca a mano.
Si intentas enviar un Contaminado a otro hilo, el compilador reconstruye la cadena hasta el campo culpable:
error[E0277]: `Rc<String>` cannot be sent between threads safely
= note: required because it appears within the type `Contaminado`
= note: required by a bound in `spawn`
El diagnóstico es la demostración leída al revés: Contaminado no es Send porque contiene un Rc<String> que no lo es. No hay que interpretar nada; el compilador cita el campo exacto que rompe la propiedad y la regla que la exige.
Los cuatro cuadrantes existen. Rc no tiene ninguno de los dos; casi todo tiene ambos; los átomos y Mutex cubren el resto. Y hay tipos en las casillas cruzadas: RefCell es Send pero no Sync; el guardián MutexGuard es Sync pero no Send —no debe soltarse el candado desde un hilo distinto del que lo tomó—. Que un tipo tenga uno no implica que tenga el otro: son dos preguntas separadas que se responden por separado.
Retirar el marcador con PhantomData
A veces la derivación automática concede un marcador que tú no quieres. Un tipo que envuelve un puntero pero que, conceptualmente, representa algo atado a un hilo concreto no debería ser Send aunque sus campos visibles lo permitan. La herramienta idiomática para retirarlo sin coste es PhantomData, un campo de tamaño cero que existe solo para que el compilador lo tenga en cuenta al deducir los auto-traits.
use std::marker::PhantomData;
use std::rc::Rc;
struct SoloEsteHilo {
dato: u64,
_marca: PhantomData<Rc<()>>, // hereda el !Send + !Sync de Rc
}
PhantomData<T> copia los Send y Sync de T sin almacenar ningún T. Al incrustar PhantomData<Rc<()>>, el struct hereda la ausencia de ambos marcadores de Rc, y queda !Send y !Sync —sin ocupar ni un byte y sin una sola instrucción en ejecución—. Es la vía estable para decir “trátame como si contuviera algo atado a un hilo”.
La marca no cuesta ni un byte, y puedes comprobarlo:
use std::mem::size_of;
// size_of::<SoloEsteHilo>() == size_of::<u64>() == 8
// PhantomData<Rc<()>> ocupa 0 bytes: solo altera los auto-traits.
El campo fantasma cambia el sistema de tipos sin tocar la representación en memoria: es una anotación dirigida al compilador, no un dato que se almacene.
Cuándo el compilador es demasiado conservador
La derivación estructural es segura pero conservadora: ante un puntero crudo, el compilador siempre dice !Send y !Sync, porque un *mut T no ofrece ninguna garantía y él no puede adivinar tus invariantes. La inmensa mayoría de las veces esa prudencia es correcta. Pero al escribir estructuras de datos de bajo nivel —un canal entre hilos, una cola sin bloqueo, un Arc propio, un envoltorio de un recurso de C— tú sabes algo que el compilador no puede ver: que tu tipo, pese a contener punteros crudos, sincroniza sus accesos correctamente y es seguro de enviar o compartir.
use std::ptr::NonNull;
// Un contenedor que posee en exclusiva su asignacion en el heap.
struct MiCaja<T> {
ptr: NonNull<T>, // contiene un puntero crudo: el compilador lo hace !Send
}
MiCaja<T> es, en su semántica, tan dueño de su dato como un Box<T>, y por tanto debería ser Send cuando T lo sea. Pero el NonNull lo ha vuelto !Send por precaución. El compilador no se equivoca por capricho: se equivoca hacia el lado seguro, y te cede a ti la última palabra.
unsafe impl: firmar la garantía con tu nombre
Esa última palabra es unsafe impl. Afirmas manualmente el marcador, y el unsafe proclama que la obligación de prueba —la que el compilador descarga por ti en los tipos normales— pasa ahora a tus manos.
// Prometo que MiCaja posee su dato en exclusiva, como Box:
// enviarla a otro hilo es tan seguro como enviar el T que contiene.
unsafe impl<T: Send> Send for MiCaja<T> {}
unsafe impl<T: Sync> Sync for MiCaja<T> {}
Cada línea es un teorema que tú te comprometes a haber demostrado. unsafe impl Send afirma: “mover esto a otro hilo no puede causar una carrera”. unsafe impl Sync afirma: “que dos hilos compartan &esto es seguro”. Los límites T: Send y T: Sync no son adorno: replican la derivación estructural que harías a mano, propagando la exigencia al contenido. Si mientes —si tu tipo comparte estado mutable sin sincronizar y aun así lo declaras Sync— el compilador te creerá, y habrás reabierto exactamente la puerta a las condiciones de carrera que todo este nivel existe para cerrar. Nadie te avisará: el unsafe era el aviso.
flowchart TD T[Defines un tipo] --> aut[Contiene solo campos normales] aut -->|si| derive[El compilador deduce Send y Sync de los campos] aut -->|no, hay punteros crudos| cons[El compilador lo marca no Send por precaucion] cons --> dec[Sabes que es seguro y puedes probarlo] dec -->|no| stop[Deja el no Send, es correcto] dec -->|si| ui[unsafe impl Send, asumes la carga de la prueba] style derive fill:#a6e3a1,color:#11111b style stop fill:#fab387,color:#11111b style ui fill:#f38ba8,color:#11111b
unsafe impl Send es uno de los gestos más densos de todo Rust, porque marca la frontera exacta donde la demostración mecánica cede el paso a la palabra humana. Durante todo este nivel el compilador ha sido un probador de teoremas infalible: propagaba Send y Sync por el grafo de tipos y garantizaba, sin margen de error, la ausencia de carreras. Pero un probador de teoremas necesita axiomas, y los axiomas no se prueban: se postulan. Los tipos primitivos —los enteros que son Send, el UnsafeCell que no es Sync, los átomos que sincronizan— son esos axiomas, escritos con unsafe impl en las entrañas de la biblioteca estándar por seres humanos que se responsabilizaron de que el hardware se comporta como prometen. Cuando tú escribes tu propio unsafe impl Send, te unes a ese grupo: dejas de ser usuario del sistema de garantías y pasas a ser uno de sus fundamentos. La seguridad de todo el código seguro que use tu tipo descansará, en ese punto, no en una prueba del compilador sino en tu razonamiento sobre el modelo de memoria. Por eso el unsafe no es una traba burocrática que sortear, sino un contrato solemne: la totalidad de la concurrencia sin miedo de Rust se sostiene sobre una base pequeña de estas promesas manuales, cada una verificada por una persona. Respétalas —escribe pocas, pruébalas mucho, coméntalas siempre— porque son el único lugar del sistema donde una equivocación tuya no la atrapa el compilador.
- Define un
structcon un campoStringy otroRc<i32>y confirma, leyendo un error al enviarlo a un hilo, que la contaminación estructural lo ha vuelto!Send. - Retira
Sendde un tipo tuyo insertando unPhantomData<Rc<()>>y comprueba que ya no compila al cruzar una frontera de hilo. Verifica consize_ofque el tipo no ha crecido. - Encuentra en la documentación un tipo que sea
Sendpero noSync, u otroSyncpero noSend, y explica su asimetría. - Escribe un envoltorio sobre
NonNull<T>y añadeunsafe impl Sendyunsafe impl Synccon los límites correctos. Redacta el comentario que justifica por qué es seguro. - Argumenta qué clase de fallo reintroducirías si declararas
unsafe impl Syncpara un tipo con un contador de préstamos no atómico, y por qué el compilador no te detendría.