Deadlocks: Rust previene carreras de datos, no interbloqueos
La seguridad de memoria de Rust elimina las carreras de datos en compilacion, pero no los interbloqueos: un programa que se cuelga esperando candados en circulo es perfectamente seguro y jamas progresa. Por que ocurren aun en Rust, las condiciones de Coffman, y los patrones que los previenen: orden global de locks, evitar locks anidados y preferir canales.
Aquí conviene ser preciso sobre lo que Rust garantiza y lo que no. El compilador demuestra, en tiempo de compilación, que tu programa no tiene carreras de datos: nunca dos hilos tocan la misma memoria sin sincronizar. Esa es una garantía de seguridad —“nada malo le pasa a la memoria”—. Pero hay otra clase de fallo concurrente que Rust no previene: el interbloqueo. Dos hilos, cada uno sosteniendo un candado y esperando el del otro, se quedan quietos para siempre. El programa no corrompe nada, no invoca comportamiento indefinido, es impecablemente seguro en el sentido de Rust; simplemente nunca progresa. La “concurrencia sin miedo” elimina la clase de bugs que corrompen memoria de forma no reproducible, y te deja al mando de la clase que solo cuelga. Entender exactamente dónde está esa línea es entender qué compraste con Rust y qué sigue siendo tu responsabilidad.
- Distinguir una propiedad de seguridad (sin carreras de datos) de una de viveza (progreso), y ver por qué Rust garantiza la primera y no la segunda.
- Reconocer las cuatro condiciones de Coffman que un interbloqueo necesita, en especial la espera circular.
- Identificar las formas típicas en Rust: candados en orden opuesto y autobloqueo por reentrada.
- Aplicar los patrones de prevención: orden global de locks, evitar anidarlos y preferir el paso de mensajes.
Un interbloqueo es seguro, y aun así es un bug
Este programa compila sin una sola advertencia y puede colgarse para siempre. No hay unsafe, no hay carrera de datos; hay dos hilos que toman dos candados en orden opuesto.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let a = Arc::new(Mutex::new(0));
let b = Arc::new(Mutex::new(0));
let (a1, b1) = (Arc::clone(&a), Arc::clone(&b));
let h1 = thread::spawn(move || {
let _ga = a1.lock().unwrap(); // toma A
let _gb = b1.lock().unwrap(); // luego espera B -> se queda esperando
});
let (a2, b2) = (Arc::clone(&a), Arc::clone(&b));
let h2 = thread::spawn(move || {
let _gb = b2.lock().unwrap(); // toma B
let _ga = a2.lock().unwrap(); // luego espera A -> cierra el ciclo
});
h1.join().unwrap();
h2.join().unwrap();
}
Si el primer hilo alcanza a tomar A y el segundo B antes de que ninguno tome el segundo candado, cada uno espera un recurso que el otro sostiene, y ninguno lo soltará —soltar exige avanzar, y avanzar exige el candado que falta—. El programa se queda inmóvil. Nota lo incómodo del asunto: es un bug no determinista que quizá no aparezca en mil ejecuciones y sí en la que corre en producción, porque depende del entrelazado exacto de los hilos. Rust erradicó ese no determinismo para la memoria, pero no para el tiempo.
No hacen falta dos hilos. El Mutex de std no es reentrante: si un hilo que ya sostiene el guard vuelve a llamar lock() sobre el mismo Mutex —a menudo sin querer, porque una función bloqueada llama a otra que también bloquea el mismo dato— se queda esperando un turno que él mismo ocupa. Es un interbloqueo de un solo hilo. La versión con RwLock que viste antes —tomar write() teniendo un read()— es la misma trampa. Sostener un guard mientras llamas a código que no controlas es la causa número uno de estos autobloqueos.
Las cuatro condiciones, y cómo romper una
Un interbloqueo clásico necesita las cuatro condiciones de Coffman a la vez; negar cualquiera de ellas lo hace imposible. Dos las impone la naturaleza del candado, pero las otras dos están en tus manos:
Exclusión mutua
El recurso solo lo tiene uno a la vez. Es la esencia del candado; no se puede negar sin dejar de usar candados.
Retener y esperar
Un hilo conserva lo que ya tiene mientras pide más. La rompes si nunca sostienes un candado al pedir otro: un solo lock a la vez.
Sin expropiación
A un hilo no se le arrebata un candado a la fuerza. try_lock con retroceso simula expropiarte a ti mismo: si no obtienes el segundo, sueltas el primero y reintentas.
Espera circular
Existe un ciclo de hilos donde cada uno espera al siguiente. La rompes con un orden global de candados: si todos adquieren en el mismo orden, no puede formarse el ciclo.
La palanca más limpia es la última. Fija un orden total sobre tus candados —por dirección, por un identificador, por cualquier criterio estable— y exige que todo hilo los adquiera siempre en ese orden. En el ejemplo, basta con que ambos hilos tomen A antes que B: entonces el que consigue A acabará consiguiendo B, porque nadie que tenga B puede estar esperando A. El ciclo no se cierra porque no hay dos flechas en sentidos opuestos.
// Correccion: ambos hilos adquieren SIEMPRE a antes que b.
let h1 = thread::spawn(move || {
let _ga = a1.lock().unwrap();
let _gb = b1.lock().unwrap(); // mismo orden
});
let h2 = thread::spawn(move || {
let _ga = a2.lock().unwrap(); // a primero, tambien aqui
let _gb = b2.lock().unwrap();
});
flowchart LR H1[Hilo 1 tiene A y espera B] --> B[Candado B] B --> H2[Hilo 2 tiene B y espera A] H2 --> A[Candado A] A --> H1 style H1 fill:#f38ba8,color:#11111b style H2 fill:#f38ba8,color:#11111b style A fill:#fab387,color:#11111b style B fill:#fab387,color:#11111b
Los patrones de prevención
Traduciendo las condiciones a reglas de ingeniería concretas:
- Orden global de candados. Si una operación necesita varios locks, adquiérelos siempre en el mismo orden acordado. Mata la espera circular de raíz. Es la técnica más usada donde compartir varios candados es inevitable.
- No anides candados; encoge la sección crítica. Sostén como mucho uno a la vez. Copia lo que necesites, suelta el guard con
dropy trabaja fuera. Nunca llames a código ajeno o a un callback mientras sostienes un lock: podría reentrar y autobloquearte. try_lockcon retroceso. Cuando no puedes garantizar un orden, intenta el segundo candado sin bloquear; si falla, suelta el primero, espera un poco y reintenta. Rompe “retener y esperar” a costa de posible livelock, que se mitiga con esperas aleatorias.- Prefiere canales. La cura más radical: si el dato fluye por un
mpscen vez de quedarse tras un candado, no hay lock sobre el que interbloquearse. La transferencia de posesión de la lección anterior no solo elude las carreras de datos; elude también toda esta familia de interbloqueos, porque no queda candado que enredar.
La biblioteca estándar no trae detección de interbloqueos: un programa colgado no imprime nada, no hay Result que consultar, y RUST_BACKTRACE no ayuda porque no ha ocurrido ningún pánico. Para diagnosticarlos se recurre a herramientas externas —un depurador que muestre en qué lock() duerme cada hilo, o utilidades como el detector de interbloqueos de parking_lot—. La consecuencia práctica: como el compilador no te salvará aquí, la prevención por diseño —orden de locks, canales— no es opcional, es tu única red.
La lección más profunda de este nivel no es cómo evitar un interbloqueo, sino por qué Rust no lo evita por ti, siendo el lenguaje que erradica tantos otros fallos concurrentes. La respuesta separa dos familias de propiedades que la teoría distingue con precisión. Una carrera de datos es la violación de una propiedad de seguridad: “nunca ocurre algo malo” —dos accesos sin sincronizar—. Esas propiedades se pueden refutar observando un instante finito de la ejecución, y por eso un sistema de tipos puede demostrar su ausencia en compilación: el borrow checker exhibe, para todo programa que acepta, que ese instante malo no existe. Un interbloqueo, en cambio, viola una propiedad de viveza: “algo bueno acaba ocurriendo” —el programa progresa—. Refutar la viveza exige observar una ejecución infinita —demostrar que el progreso no llega jamás—, y decidir eso en general es equivalente al problema de la parada: indecidible. Rust no “olvidó” prevenir interbloqueos; topó con el límite de lo que cualquier análisis estático puede prometer. Y hay una ironía exacta en el mecanismo: lo que hace seguro al candado —que lock() bloquee, que no retorne sin garantizar exclusividad— es precisamente lo que le permite bloquear para siempre. La misma operación que compra la seguridad de memoria abre la puerta a la falta de viveza. Por eso la respuesta a los interbloqueos no vive en el sistema de tipos sino en la arquitectura: un orden global de candados que niega la espera circular, un solo lock a la vez que niega el retener-y-esperar, o —la solución que trasciende el problema— abolir los candados compartidos en favor de canales, donde la ley de ownership deja un único dueño por instante y no hay recurso que esperar en círculo. Leído así, todo el nivel cuenta una sola historia: Rust empuja la corrección de la concurrencia dentro del compilador tan lejos como la teoría permite, y cuando llega a la frontera donde los problemas dejan de ser sobre memoria y pasan a ser sobre tiempo y progreso, no finge cruzarla. Te entrega esos problemas a ti, pero al menos limpios —deterministas en su estructura, depurables, sin la corrupción no reproducible que hacía de la concurrencia una pesadilla—. Saber de qué lado de esa frontera estás es saber cuándo confiar en el compilador y cuándo pensar como arquitecto.
Rust garantiza ausencia de carreras de datos (seguridad, demostrable en compilación), no ausencia de interbloqueos (viveza, indecidible en general). Un programa colgado esperando candados en círculo es seguro y nunca progresa. Un interbloqueo necesita las cuatro condiciones de Coffman; niega una: orden global de adquisición mata la espera circular, un solo lock a la vez mata el retener-y-esperar, try_lock con retroceso simula la expropiación. Y la cura de raíz: canales, donde la posesión se transfiere y no queda candado que enredar. Nunca sostengas un guard mientras llamas a código ajeno.
- Reproduce el interbloqueo de dos candados en orden opuesto. Como es no determinista, ejecútalo en un bucle o añade una breve espera entre los dos
lock()para forzarlo de forma fiable. - Corrígelo imponiendo un orden global: ambos hilos adquieren
aantes queb. Explica, con las condiciones de Coffman, cuál acabas de negar. - Provoca un autobloqueo de un solo hilo llamando dos veces a
lock()sobre el mismoMutexno reentrante, y describe por qué el hilo se espera a sí mismo. - Reescribe una coordinación entre dos hilos que usaba dos candados para que use un canal
mpsc; argumenta por qué esa versión no puede interbloquearse por candados. - Explica en tus palabras la diferencia entre una propiedad de seguridad y una de viveza, y por qué un sistema de tipos puede garantizar la primera pero no la segunda.