Mutex deadlock en producción: los patrones que encontré en mi codebase y cómo los diagnostiqué
Eran las 11:47 de la noche y el servicio no respondía. No había panic. No había error en los logs. Railway me mostraba el container vivo, memoria estable, CPU en cero. Cero. Eso fue lo que me llamó la atención: cero actividad en un servicio que debería estar procesando colas. Abrí una sesión de tokio-console y ahí estaba: cuatro tasks suspendidas, todas esperando el mismo MutexGuard. Ninguna iba a moverse jamás.
Ese fue el primero. Después vino el segundo. Después el tercero. Los tres con la misma cara: silencio total, container "healthy", y una cadena de locks que nunca se iba a resolver sola.
Mi tesis es esta: los mutex deadlocks en async Rust no son raros ni misteriosos. Son predecibles. Siguen patrones. Y una vez que los ves una vez, los reconocés de lejos. El problema es que la mayoría de los recursos te enseñan qué es un deadlock, no cómo lo diagnosticás cuando ya está en producción y no podés simplemente hacer un backtrace.
Mutex deadlock en async Rust: por qué el problema no es trivial
El problema específico de Rust async no es que los locks sean difíciles de entender. Es que tokio::sync::Mutex y std::sync::Mutex se comportan diferente de formas que no son obvias hasta que algo explota.
Cuando usás std::sync::Mutex dentro de un runtime async, si una task toma el lock y después hace .await, bloqueás el hilo completo del executor. No solo tu task: el hilo entero. Todas las tasks que corren en ese worker thread quedan suspendidas. Con un runtime de un solo hilo, el programa entero se congela.
// ⚠️ Esto es veneno en async — bloqueás el executor
use std::sync::Mutex;
async fn procesar_item(estado: Arc<Mutex<Estado>>) {
let guard = estado.lock().unwrap(); // bloqueo sincrónico
hacer_io_async().await; // mientras el hilo está bloqueado
// guard se dropea acá — pero el daño ya está hecho
}
Con tokio::sync::Mutex el comportamiento cambia: el .lock().await suspende la task, no el hilo. El executor puede seguir ejecutando otras tasks mientras esperás el lock. Pero eso no te salva del deadlock: si tenés dependencias circulares, seguís igual de bloqueado, solo que de forma más educada.
Lo que descubrí en mi codebase, después de tres incidentes, es que tengo tres patrones distintos de deadlock. Los llamo así internamente: el Abrazo Mortal Clásico, el Lock Reentrante, y el Orden Invertido bajo Presión.
Los tres patrones concretos que encontré y cómo los reproducí
Patrón 1: El Abrazo Mortal Clásico
Este es el más conocido pero igual me agarró. Dos tasks, dos recursos, orden inverso de adquisición.
// Reproducción del primer deadlock real
// Task A: toma lock_cache, después pide lock_db
// Task B: toma lock_db, después pide lock_cache
async fn task_a(
cache: Arc<Mutex<Cache>>,
db: Arc<Mutex<DbPool>>,
) {
let _cache_guard = cache.lock().await; // Task A toma cache
tokio::time::sleep(Duration::from_millis(1)).await; // pausa = ventana de deadlock
let _db_guard = db.lock().await; // Task A espera db — que Task B tiene
}
async fn task_b(
cache: Arc<Mutex<Cache>>,
db: Arc<Mutex<DbPool>>,
) {
let _db_guard = db.lock().await; // Task B toma db
tokio::time::sleep(Duration::from_millis(1)).await;
let _cache_guard = cache.lock().await; // Task B espera cache — que Task A tiene
}
La solución no es solo "agarrá los locks en el mismo orden". La solución real es preguntarte si necesitás los dos locks al mismo tiempo. En mi caso, no los necesitaba: reestructuré para adquirir, operar, soltar, y recién ahí adquirir el segundo.
// Versión corregida: scope explícito, no superposición de guards
async fn task_a_corregida(
cache: Arc<Mutex<Cache>>,
db: Arc<Mutex<DbPool>>,
) {
// Primero operamos con cache y la soltamos
let dato = {
let guard = cache.lock().await;
guard.obtener_dato()
}; // guard dropeado acá
// Recién después usamos db
let mut db_guard = db.lock().await;
db_guard.escribir(dato).await;
}
Patrón 2: El Lock Reentrante
Este me tomó más tiempo porque no parecía un deadlock clásico. Una sola función, un solo mutex. El problema: la función se llamaba a sí misma (indirectamente, a través de un callback) mientras tenía el lock tomado.
// El callback interno llamaba a la misma función que ya tenía el lock
async fn procesar_evento(
estado: Arc<Mutex<Estado>>,
evento: Evento,
) {
let mut guard = estado.lock().await;
// Este handler interno llama de nuevo a procesar_evento
// con el mismo Arc<Mutex<Estado>> — deadlock garantizado
guard.ejecutar_handlers(&evento).await;
}
Rust no tiene RwLock reentrante en std, y tokio::sync::Mutex tampoco. La solución fue separar el estado que necesita el handler del estado que toma el lock principal, o clonar los datos necesarios antes de liberar el guard.
// Solución: clonar lo necesario, soltar el lock, ejecutar handlers
async fn procesar_evento_corregido(
estado: Arc<Mutex<Estado>>,
evento: Evento,
) {
// Tomamos lo que necesitamos y soltamos el lock
let handlers = {
let guard = estado.lock().await;
guard.handlers_para(&evento).clone() // clone deliberado
}; // guard dropeado
// Ejecutamos los handlers sin tener el lock
for handler in handlers {
handler.ejecutar(&evento).await;
}
}
Patrón 3: Orden Invertido bajo Presión
Este es el más traicionero porque el código en desarrollo nunca falla. Solo aparece cuando hay concurrencia real, bajo carga, con múltiples replicas. Lo vi en producción cuando Railway empezó a escalar horizontalmente el servicio.
El patrón: tenés un orden de locks que parece consistente en el código, pero bajo presión las tasks se intercalan en el momento justo donde el orden efectivo se invierte. Relacionado con esto, en mi análisis de Docker Compose en producción durante 30 días noté que los problemas de concurrencia no aparecían hasta la segunda semana, cuando el tráfico real empezó.
La herramienta que cambió todo fue tokio-console. Con ella pude ver exactamente qué tasks estaban en qué estado:
# Instalar tokio-console
cargo install tokio-console
# En el código, habilitar el subscriber
# Cargo.toml:
# console-subscriber = "0.4"
# tokio = { features = ["full", "tracing"] }
# main.rs
fn main() {
console_subscriber::init(); // una sola línea
// ... resto del runtime
}
El output me mostró esto:
Task 47: waiting on Mutex (owned by Task 23) — 4m 32s
Task 23: waiting on Mutex (owned by Task 47) — 4m 32s
Task 31: waiting on Mutex (owned by Task 47) — 4m 32s
Cuatro minutos y medio. Sin log. Sin error. El servicio respirando.
Los errores que cometí antes de entender qué buscaba
El primer error fue buscar en el lugar equivocado. Después de ese incidente de las 11:47, mi instinto fue revisar los logs de Railway, buscar panics, buscar OOM. No había nada. Un container sano que no hace nada es exactamente lo que parece un deadlock bien formado.
El segundo error fue usar unwrap() en los locks:
// Esto oculta el problema — si el lock está poisoned, panics
// Si está en deadlock, nunca llega a ejecutarse
let guard = mutex.lock().unwrap();
// Mejor: timeout explícito para detectar deadlocks en desarrollo
use tokio::time::timeout;
match timeout(Duration::from_secs(5), mutex.lock()).await {
Ok(guard) => { /* usar guard */ }
Err(_) => {
// Esto me salvó en staging: si tarda más de 5s, algo está mal
tracing::error!("Posible deadlock detectado en MutexX");
return Err(AppError::LockTimeout);
}
}
El tercer error fue confiar en que la arquitectura de agentes que armé (podés ver parte de ese stack en mi post sobre agentes de deploy autónomo) no iba a tener problemas de concurrencia porque "es async". Async no te protege de deadlocks. Te cambia cómo se expresan.
Validé esta misma intuición cuando analicé los edge cases de async Rust contra mi codebase real: el lenguaje te da herramientas para razonar sobre concurrencia, pero las herramientas no piensan por vos.
Un patrón que aprendí a evitar después de todo esto:
// ❌ Lock tomado a través de un .await — clásico en código async descuidado
async fn mala_practica(estado: Arc<Mutex<Estado>>) -> Result<()> {
let guard = estado.lock().await;
// Cualquier .await mientras guard está vivo es potencial problema
let resultado = llamada_externa().await?; // ← acá
guard.actualizar(resultado);
Ok(())
}
// ✅ Lock tomado el menor tiempo posible
async fn buena_practica(estado: Arc<Mutex<Estado>>) -> Result<()> {
// IO primero, sin lock
let resultado = llamada_externa().await?;
// Lock solo para la escritura atómica
{
let mut guard = estado.lock().await;
guard.actualizar(resultado);
} // guard dropeado inmediatamente
Ok(())
}
FAQ: Mutex deadlock en async Rust
¿Cuál es la diferencia real entre std::sync::Mutex y tokio::sync::Mutex para detectar deadlocks?
La diferencia más importante para el diagnóstico es el comportamiento ante el bloqueo. std::sync::Mutex bloquea el hilo completo del executor cuando hace .lock(), lo que puede congelar todo el runtime. tokio::sync::Mutex suspende solo la task, pero sigue siendo vulnerable a deadlocks circulares. Para detectar cuál tenés, tokio-console te muestra el estado de cada task; si ves tasks esperando mutexes por más de lo razonable, el deadlock está ahí.
¿tokio-console funciona en producción o solo en desarrollo?
Funciona en ambos, pero tiene un overhead de tracing que no es gratis. En producción lo habilité solo durante el incidente, con un flag de feature condicional. En staging lo tengo siempre activo. El overhead en desarrollo es aceptable; en producción bajo carga alta, medí alrededor de 3-5% de CPU adicional, que es manejable para un diagnóstico puntual.
¿RwLock soluciona el problema o lo complica?
Depende. RwLock permite múltiples lectores simultáneos, lo que reduce contención en workloads read-heavy. Pero agrega un vector nuevo de deadlock: si una task tiene un read lock y pide un write lock, y otra task tiene un write lock esperando que los readers suelten, bloqueás igual. Lo usé donde el ratio era 90% lecturas, 10% escrituras, y mejoró la performance sin agregar deadlocks. El truco es no mezclar read y write locks en la misma función.
¿Cómo reproducir un deadlock en tests para validar que la corrección funcionó?
La forma más confiable que encontré es usar tokio::time::timeout en los tests y simular la condición de carrera con tokio::task::yield_now():
#[tokio::test]
async fn test_sin_deadlock() {
let estado = Arc::new(Mutex::new(Estado::new()));
let resultado = timeout(
Duration::from_secs(2),
procesar_evento_corregido(estado.clone(), Evento::Test)
).await;
// Si hay deadlock, timeout dispara y el test falla
assert!(resultado.is_ok(), "Posible deadlock detectado");
}
¿Cuándo conviene usar Arc<Mutex<T>> versus un actor pattern (canales)?
Después de los tres incidentes, mi regla es: si el estado compartido tiene más de dos consumidores concurrentes o si la lógica de acceso es compleja, uso canales. Arc<Mutex<T>> es simple y correcto para estado compartido con acceso predecible y baja contención. El actor pattern (una task con un mpsc::Receiver que es la única que toca el estado) elimina el deadlock por diseño: no hay lock compartido. Lo implementé en mi pipeline de LLM —podés ver parte de ese diseño en el análisis de costos de entrenar un LLM desde cero— y eliminé una clase entera de problemas.
¿El deadlock aparece en el Clippy o en algún análisis estático?
No. Clippy no detecta deadlocks en async. El compilador tampoco. Es un problema de comportamiento en runtime, no de tipos. Hay propuestas en la comunidad para agregar análisis de lock order, pero nada estable todavía. La única herramienta real que tengo es tokio-console en runtime y timeouts explícitos en los locks críticos. El análisis estático de Rust es extraordinario para muchas cosas —incluso lo validé contra cosas que Chrome hace sin pedirte permiso en términos de acceso a recursos—, pero los deadlocks en async escapan al sistema de tipos.
Lo que cambié en mi arquitectura después de los tres incidentes
La conclusión no es "evitá los mutexes". La conclusión es: los mutexes son seguros si sos explícito sobre cuánto tiempo los sostenés y en qué orden los adquirís. Lo que me faltaba era disciplina estructural, no teoría.
Los cambios concretos que hice:
- Timeout en todos los locks críticos — si algo tarda más de 10 segundos en adquirir un lock en producción, quiero saberlo.
-
Scope explícito con llaves — cada lock tiene un bloque
{}que define exactamente su vida útil. Nada de guards flotando hasta el final de la función. -
tokio-consoleen staging siempre activo — los deadlocks que encontré en staging me evitaron tres incidentes de producción. - Revisión de lock order en code review — agregué una checklist: ¿este PR adquiere más de un lock? ¿En qué orden? ¿Es consistente con el resto del codebase?
Lo que no hice fue migrar todo a canales. Sería sobrediseñar. Arc<Mutex<T>> sigue siendo la herramienta correcta para estado compartido simple. La diferencia es que ahora sé cuándo es la herramienta correcta y cuándo no.
Si estás arrancando con async Rust y este tema te resulta nuevo, el punto de entrada que te recomiendo es instrumentar con tokio-console antes de tener el problema, no después. El costo es bajo. La información que da cuando algo explota no tiene precio.
Y si ya tuviste tu propio incidente de deadlock silencioso a las 11 de la noche, bienvenido al club. La membresía incluye una apreciación mucho más profunda por los logs de verdad y una desconfianza sana hacia los containers que respiran pero no hacen nada.
Este artículo fue publicado originalmente en juanchi.dev
Top comments (0)