Concurrencia segura: Mutex, SpinLock, Arc y el muro de Send/Sync
Los tipos Mutex, SpinLock y Arc del crate kernel, y cómo los traits Send y Sync convierten las data races que en C había que cazar con KCSAN en errores de compilación. La concurrencia del nivel 14 al 19, ahora demostrada correcta antes de arrancar.
Del nivel 14 al 19 recorriste el campo de minas de la concurrencia del kernel: condiciones de carrera, spinlock frente a mutex, y KCSAN cazando data races en tiempo de ejecución con watchpoints y muestreo. Rust for Linux ofrece los mismos primitivos —Mutex, SpinLock, Arc— pero con un añadido que cambia las reglas del juego: dos traits, Send y Sync, que hacen que una data race no compile.
- Proteger estado con
MutexySpinLockdel cratekernel. - Compartir estado entre contextos con
Arcy su conteo atómico. - Entender qué prometen los traits
SendySync. - Ver por qué una data race pasa de bug de ejecución a error de compilación.
Mutex y SpinLock
En C, un struct mutex o un spinlock_t es un candado al lado del dato que protege, y nada en el lenguaje ata uno a otro: puedes tomar el lock equivocado, o acceder al dato sin tomar ninguno. En Rust, el lock envuelve el dato: para llegar al valor tienes que pasar por el candado.
use kernel::sync::{new_mutex, Mutex};
use kernel::prelude::*;
#[pin_data]
struct Contadores {
#[pin]
aciertos: Mutex<u64>,
}
impl Contadores {
fn incrementar(&self) {
let mut guard = self.aciertos.lock(); // toma el lock; guard ES el permiso
*guard += 1;
} // Drop de guard suelta el lock
}
lock() devuelve un guard, un objeto que representa el candado tomado y a través del cual accedes al dato con *guard. Cuando el guard sale de ámbito, su Drop suelta el lock: no hay mutex_unlock que puedas olvidar ni un camino de salida que se escape sin soltar. La elección entre dormir o no dormir del nivel 16 sigue vigente: Mutex puede dormir y va en contexto de proceso; para el contexto atómico del nivel 15 usas SpinLock, con la misma forma:
use kernel::sync::{new_spinlock, SpinLock};
#[pin_data]
struct Cola {
#[pin]
pendientes: SpinLock<KVec<u32>>, // contexto atomico: la seccion no duerme
}
Ambos se construyen con las macros new_mutex! y new_spinlock!, que existen porque estos locks son pinned: no se pueden mover en memoria una vez inicializados, ya que el kernel guarda punteros a su interior.
Lo decisivo de “el lock envuelve el dato” es que no queda ninguna puerta trasera. El dato está dentro del Mutex, así que la única forma de tocarlo es tomar el candado:
#[pin_data]
struct Recurso {
#[pin]
datos: Mutex<KVec<u32>>,
}
fn usar(r: &Recurso) -> Result {
// r.datos.push(7, GFP_KERNEL)?; // NO compila: el KVec esta DENTRO del Mutex
let mut g = r.datos.lock(); // unica via de acceso: tomar el lock
g.push(7, GFP_KERNEL)?; // g deref al KVec ya protegido
Ok(())
}
En C, nada te impide leer o escribir el dato sin tomar el lock que lo protege —ese es justo el descuido que KCSAN persigue después—. En Rust, acceder sin el candado ni siquiera es expresable: el dato no existe fuera del Mutex.
Arc: compartir con conteo atómico
Un dato protegido por un lock a menudo necesita ser compartido por varios contextos —una interrupción, un kthread, una llamada de sistema—. El nivel 14 te enseñó los contadores de referencia (kref) como la respuesta del kernel a “¿cuándo es seguro liberar esto?”. Arc es esa idea hecha tipo: un puntero de propiedad compartida con conteo de referencias atómico.
use kernel::sync::{new_mutex, Arc, Mutex};
#[pin_data]
struct Compartido {
#[pin]
estado: Mutex<u64>,
}
fn arrancar() -> Result<Arc<Compartido>> {
let obj = Arc::pin_init(
pin_init!(Compartido {
estado <- new_mutex!(0),
}),
GFP_KERNEL,
)?;
let otra = obj.clone(); // +1 al contador, no copia el dato
entregar_a_otro_contexto(otra);
Ok(obj) // cuando la ultima Arc muera, se libera el dato
}
clone() sobre un Arc no copia el dato: incrementa el contador atómico y te da otra referencia al mismo objeto. Cuando la última Arc desaparece, el contador llega a cero y el dato se libera —una vez, exactamente—. Es kref sin el riesgo de descuadrar el conteo a mano.
Send, Sync y las data races imposibles
Aquí está lo que ningún sanitizer de C puede darte. Rust tiene dos traits automáticos que describen la seguridad de un tipo frente a hilos:
Send
Un tipo es Send si es seguro mover su propiedad a otro hilo. La mayoría lo son; los que atan a un hilo o CPU concretos, no.
Sync
Un tipo es Sync si es seguro compartir una referencia a él desde varios hilos a la vez. Equivale a decir que &T es Send.
El compilador deduce estos traits solo, y las APIs que cruzan hilos los exigen. Para mover un dato al cierre de un kthread hace falta que sea Send; para compartir una referencia entre contextos hace falta que sea Sync. Y aquí está el muro: un dato mutable sin sincronizar no es Sync.
use core::cell::Cell;
// Cell da mutabilidad interior SIN sincronizacion: no es Sync.
struct Malo {
contador: Cell<u64>,
}
// esta funcion solo acepta lo que se puede compartir entre hilos:
fn compartir_entre_hilos<T: Sync>(_dato: &T) {}
// compartir_entre_hilos(&malo); // NO compila: Cell<u64> no es Sync
// Mutex<u64> SI es Sync porque serializa el acceso. Esto compila:
fn ok<T: Send>(dato: &Mutex<T>) { compartir_entre_hilos(dato); }
El Mutex del kernel es Sync (si su contenido es Send) precisamente porque serializa el acceso; una Cell desnuda no lo es porque permite mutación concurrente sin coordinación —la definición misma de data race—. Así, “estado mutable, compartido y sin sincronizar” no es un patrón que debas evitar por disciplina: es una combinación de tipos que el compilador rechaza.
Vale la pena medir la distancia exacta entre las dos herramientas. KCSAN, que conociste en el nivel 19, es un cazador extraordinario: instrumenta los accesos, coloca watchpoints, retarda la ejecución para ampliar la ventana de colisión y, cuando dos hilos tocan el mismo dato sin sincronizar, lo ve en directo. Pero es un cazador que trabaja por muestreo y en tiempo de ejecución: solo encuentra las carreras que de hecho ocurren mientras corre, en la planificación que de hecho se da, sobre el código que de hecho ejercitas. Una carrera que solo aparece una vez de cada millón, o solo con cuatro núcleos y una interrupción en el instante justo, puede pasar por delante de KCSAN mil veces sin dispararlo. Send y Sync atacan el mismo problema desde el otro extremo del tiempo. No buscan la carrera que ocurre: prueban, antes de que el kernel arranque, que ninguna puede ocurrir en el código seguro, porque los tipos que permitirían compartir estado mutable sin sincronizar simplemente no se dejan compartir. Es la diferencia entre un detector de intrusos que patrulla el edificio y un plano en el que las puertas por las que entraría el intruso no existen. Ninguna de las dos es gratis —el muro de tipos te obliga a envolver todo estado compartido en un lock o un atómico, y a veces a pelearte con el compilador para convencerlo de que algo es seguro— pero el resultado es que una categoría entera de los bugs más caros y menos reproducibles del kernel deja de ser algo que se caza para volverse algo que no se puede escribir. KCSAN sigue siendo necesario en la frontera, donde el Rust seguro se apoya en bloques unsafe o llama a C, y ahí los dos mundos se dan la mano; pero en el corazón del código en Rust, la data race no es un bug raro: es una figura que el lenguaje no sabe representar.
- Protege un contador con
Mutexy otro conSpinLock, y razona en qué contexto —proceso o atómico— usarías cada uno según el nivel 16. - Comparte un
Arc<Mutex<...>>entre dos partes del código y sigue el contador de referencias al clonar y al soltar. - Intenta compartir una
Cello unRcentre hilos y lee el error de compilación sobreSyncoSend. - Explica por qué
Mutex<T>puede serSyncaunqueTpor sí solo no lo sea. - Recuerda el data race del nivel 19 que cazaste con KCSAN y describe qué tipo, en Rust, lo habría hecho imposible de escribir.