🔧 Programmierung 🕛 vor 4 Monaten 12 Min Lesezeit
0

Mutex deadlock en producción: los patrones que encontré en mi codebase y cómo los diagnostiqué

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht




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.




CODE
// ⚠️ 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.




CODE
// 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.




CODE
// 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.




CODE
// 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.




CODE
// 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 ) 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é — 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

Vollständiger Original-Artikel
Den kompletten Beitrag mit allen Details direkt auf dev.to lesen.
↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 0%
🟡 In Evaluierung 0%
🟢 Keine Auswirkung 0%
Spannende Innovation 0%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
2 Quellen
CVE-2024-45058 | portabilis i-educar up to 2.8 Setting educar_usuario_cad.php authorization
1 Quelle
Kompakte 10.000-mAh-Powerbank für weniger als 10 Euro bei Amazon Haul
1 Quelle
Bessere Grafik in Spielen: So steigern Sie die Bildqualität ohne FPS-Verlust
Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten Mutex deadlock en producción: los patrones que encontré en mi codebase y cómo los diagnostiqué

Thematisch verwandte Begriffe: Mutex, deadlock, producción, patrones · 6 Treffer

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...