En la lección anterior dejamos una deuda. Los cinco filósofos se bloqueaban, esquivamos el problema con tres trucos que funcionaban, y no explicamos por qué funcionaban ni qué tenían en común. Esta lección paga esa deuda y cierra el módulo.

El interbloqueo es la patología más temida de la concurrencia, y no por su frecuencia sino por su comportamiento. No produce datos corruptos ni resultados erróneos: produce silencio. Los procesos siguen vivos, no consumen CPU, no escriben en el log, no devuelven errores; simplemente dejan de avanzar, y desde fuera todo parece normal hasta que alguien se pregunta por qué meteo-api lleva veinte minutos sin responder. Es, además, un fallo que aparece bajo carga y no en las pruebas, porque necesita un entrelazado concreto que solo se da con tráfico real.

Vamos a construirlo desde cero para que lo veas colgarse, formalizarlo con las cuatro condiciones de Coffman, modelarlo con grafos, y después recorrer las cuatro estrategias que existen: prevenirlo, evitarlo, detectarlo y recuperarse, o ignorarlo deliberadamente —que es, sorprendentemente, lo que hace Linux—. Terminaremos con lo más práctico de toda la lección: cómo se diagnostica un interbloqueo real en un servidor en producción, con herramientas que usarás de verdad.

Contenido

  1. Definición y ejemplo mínimo reproducible
  2. Las cuatro condiciones de Coffman
  3. Grafo de asignación de recursos y detección de ciclos
  4. Estrategia 1: prevención
  5. Estrategia 2: evitación y el algoritmo del banquero
  6. Estrategia 3: detección y recuperación
  7. Estrategia 4: el avestruz, y por qué Linux la adopta
  8. Interbloqueo, inanición y livelock
  9. Diagnosticar un interbloqueo real
  10. El caso resuelto de meteo-api y el agregador
  11. Reglas de ingeniería que lo evitan
  12. Cierre del módulo 3

Definición y ejemplo mínimo reproducible

Un conjunto de procesos está en interbloqueo (deadlock) cuando cada uno de ellos espera un evento que solo puede producir otro proceso del mismo conjunto. Como todos esperan y ninguno avanza, el evento nunca ocurre y la espera es permanente.

La palabra clave es permanente: no es una espera larga, es una espera de la que es imposible salir sin intervención externa. Si esperas dos horas pero acabas avanzando, eso es un problema de rendimiento; si el estado del sistema garantiza que nunca avanzarás, eso es un interbloqueo.

El ejemplo mínimo son dos mutex tomados en orden inverso. Cuelga de verdad, y merece la pena que lo ejecutes:

/* deadlock.c — se cuelga en menos de un segundo. Compila y pruébalo. */
pthread_mutex_t candado_cache   = PTHREAD_MUTEX_INITIALIZER;  /* meteora-cache */
pthread_mutex_t candado_fichero = PTHREAD_MUTEX_INITIALIZER;  /* 2026-08-31.dat */

void *agregador(void *arg) {
    for (int i = 0; ; i++) {
        pthread_mutex_lock(&candado_cache);      /* (A1) primero la caché */
        usleep(10);                              /* ventana de peligro */
        pthread_mutex_lock(&candado_fichero);    /* (A2) luego el fichero */
        printf("[agregador] vuelta %d\n", i);
        pthread_mutex_unlock(&candado_fichero);
        pthread_mutex_unlock(&candado_cache);
    }
    return NULL;
}

void *api(void *arg) {
    for (int i = 0; ; i++) {
        pthread_mutex_lock(&candado_fichero);    /* (B1) primero el fichero */
        usleep(10);                              /* ventana de peligro */
        pthread_mutex_lock(&candado_cache);      /* (B2) luego la caché  ← INVERSO */
        printf("[meteo-api] vuelta %d\n", i);
        pthread_mutex_unlock(&candado_cache);
        pthread_mutex_unlock(&candado_fichero);
    }
    return NULL;
}
/* main(): crear los dos hilos y hacerles join, que nunca retornan. */

Al ejecutarlo imprime dos o tres vueltas de cada hilo y se queda parado para siempre, sin consumir CPU y sin ningún mensaje de error.

La traza del momento fatal, tras el usleep:

Tiempo agregador meteo-api candado_cache candado_fichero
t1 (A1) toma la caché agregador libre
t2 (B1) toma el fichero agregador meteo-api
t3 (A2) pide el fichero → bloqueado agregador meteo-api
t4 (B2) pide la caché → bloqueado agregador meteo-api
t5 esperando a meteo-api esperando al agregador

Cada uno tiene lo que el otro necesita, y ninguno soltará lo suyo porque está bloqueado. Es un ciclo de espera de longitud 2.

Dos observaciones importantes antes de seguir. El usleep(10) solo hace el fallo determinista: sin él, el programa también se cuelga, pero puede tardar minutos u horas, porque necesita que el planificador expulse a un hilo justo entre las dos adquisiciones —es el no determinismo de 03-01: la ventana existe siempre y con suficientes iteraciones se acaba dando—. Y el código es correcto vista cada función por separado: ambas toman dos candados, hacen su trabajo y los sueltan en orden inverso, como manda el manual. El fallo no está en ninguna de las dos funciones, está en la relación entre ellas, y por eso ninguna revisión de código que mire una función aislada lo detectará.

Las cuatro condiciones de Coffman

En 1971, Edward Coffman formuló las cuatro condiciones necesarias para que pueda haber interbloqueo. Su valor práctico es enorme: como son necesarias todas a la vez, basta con garantizar que una no se cumpla para que el interbloqueo sea imposible. Toda la estrategia de prevención sale de aquí.

1. Exclusión mutua. Al menos un recurso debe ser no compartible: si un proceso lo tiene, otro no puede tenerlo simultáneamente. Sin ella no hay conflicto posible, porque si diez procesos pueden usar el recurso a la vez nadie espera a nadie. Es la razón de que las lecturas compartidas de un rwlock no puedan formar parte de un interbloqueo, pero las escrituras sí.

2. Retención y espera (hold and wait). Un proceso que ya tiene al menos un recurso solicita otro y se queda esperando sin soltar el que tiene.

Es la condición que hace que el bloqueo se propague: si al pedir un recurso nuevo soltaras todo lo que tienes, tu espera no bloquearía a nadie. En el ejemplo, el agregador espera el fichero reteniendo la caché, y eso es lo que atrapa a meteo-api.

3. Sin expropiación (no preemption). Un recurso solo puede liberarlo voluntariamente el proceso que lo posee; el sistema no puede quitárselo. Un mutex cumple esta condición por diseño: no existe ninguna llamada que le arranque un mutex a un hilo, y con razón, porque el estado que protegía quedaría a medio modificar. En cambio, la CPU es expropiable —el planificador la quita cada pocos milisegundos— y por eso nunca hay interbloqueo por la CPU; y la memoria física también lo es, gracias al swapping (módulo 2).

4. Espera circular. Existe un conjunto de procesos {P₀, P₁, ..., Pₙ} tal que P₀ espera un recurso que tiene P₁, P₁ espera uno que tiene P₂, ..., y Pₙ espera uno que tiene P₀. Es la condición más visible y la que da la imagen mental del problema. Ojo con el detalle lógico: la espera circular implica retención y espera, pero no al revés —puede haber muchos procesos reteniendo y esperando sin que se cierre ningún ciclo, y entonces no hay interbloqueo—.

Resumidas, con lo que cuesta romper cada una:

Condición Qué significa Cómo romperla Coste práctico
Exclusión mutua El recurso no se comparte Hacerlo compartible o virtualizarlo Casi siempre imposible
Retención y espera Pides sin soltar Pedir todo de golpe, o soltar antes de pedir Baja concurrencia, inanición
Sin expropiación No se puede quitar Tiempos límite y retroceso Trabajo perdido, livelock
Espera circular Ciclo de esperas Orden global de adquisición Bajo: la opción práctica

Esta tabla es el mapa de la estrategia de prevención, y ya adelanta la conclusión: en la práctica, casi siempre se rompe la cuarta.

Grafo de asignación de recursos y detección de ciclos

El modelo formal que permite razonar sobre esto es el grafo de asignación de recursos, un grafo dirigido con nodos de dos tipos —procesos y recursos— y dos tipos de arista: la arista de asignación R → P, que significa que el recurso R está asignado al proceso P, y la arista de solicitud P → R, que significa que P espera el recurso R. Nuestro ejemplo queda así:

graph LR
    CACHE[candado_cache] -->|asignado a| AGG((agregador))
    AGG -->|solicita| FICH[candado_fichero]
    FICH -->|asignado a| API((meteo-api))
    API -->|solicita| CACHE

El ciclo agregador → candado_fichero → meteo-api → candado_cache → agregador salta a la vista. Y aquí está el teorema que hace útil el modelo:

Si cada recurso tiene una sola instancia, un ciclo en el grafo es condición necesaria y suficiente para el interbloqueo. Si algún recurso tiene varias instancias, el ciclo es necesario pero no suficiente.

La distinción importa. Un mutex es un recurso de una sola instancia: o lo tienes o no. Un semáforo inicializado a 5 —cinco conexiones a base de datos— tiene cinco instancias, y ahí un ciclo no basta: puede que una la tenga un proceso ajeno al ciclo, que la liberará y desatascará a todos. Para esos casos hace falta un algoritmo de detección más elaborado, que veremos en el apartado 6.

Buscar ciclos en un grafo dirigido es un problema resuelto: un recorrido en profundidad los detecta en O(V + E), y un sistema con 1.000 procesos y 5.000 recursos se analiza en milisegundos. La dificultad no es el algoritmo, sino construir el grafo: hay que saber, en un instante congelado, quién tiene qué y quién espera qué. En el núcleo eso es fácil; desde fuera, no tanto.

Estrategia 1: prevención

Prevenir significa diseñar el sistema para que una de las cuatro condiciones nunca se cumpla. Es una garantía estructural: si la condición no puede darse, el interbloqueo es imposible por construcción, sin necesidad de comprobar nada en tiempo de ejecución.

Romper la exclusión mutua consistiría en hacer los recursos compartibles, y es imposible para la mayoría: un mutex existe precisamente para excluir. Donde sí se aplica es con recursos virtualizables —el spooling de impresión del módulo 2: en lugar de competir por la impresora, los procesos escriben en una cola de ficheros y un demonio la gestiona—. En Meteora, la caché podría hacerse "compartible" con el doble búfer que propusimos en la lección anterior, donde los lectores nunca bloquean.

Romper la retención y espera, en dos variantes. Pedir todo de golpe al principio: el proceso declara todos los recursos que necesitará y solo empieza cuando los tiene todos.

/* Adquisición atómica de los dos candados: o los dos, o ninguno */
void tomar_ambos(pthread_mutex_t *a, pthread_mutex_t *b) {
    while (1) {
        pthread_mutex_lock(a);
        if (pthread_mutex_trylock(b) == 0) return;   /* ¡los dos! */
        pthread_mutex_unlock(a);                     /* suelto y reintento */
        usleep(1 + rand() % 100);                    /* espera aleatoria */
    }
}

Fíjate en el pthread_mutex_trylock, que intenta adquirir sin bloquearse y devuelve error si no puede: al soltar a cuando falla, el proceso nunca retiene mientras espera. La espera aleatoria antes de reintentar es imprescindible, porque sin ella dos hilos pueden sincronizarse y reintentar eternamente a la vez, cayendo en el livelock del apartado 8.

Soltar todo antes de pedir. Si necesitas un recurso nuevo, liberas los que tienes y vuelves a pedirlos todos: correcto pero costoso, y el estado que protegían queda expuesto entre medias. El coste de esta vía es baja utilización —reservas recursos que quizá uses dentro de diez minutos— e inanición posible —quien necesita muchos recursos puede no conseguirlos nunca todos a la vez—. Se usa en tiempo real y bases de datos con planificación estática, no en código general.

Romper la ausencia de expropiación. Si un proceso pide un recurso y no puede obtenerlo, se le quitan todos los que tenía y se reintenta más tarde. En espacio de usuario se implementa con tiempos límite:

struct timespec limite;
clock_gettime(CLOCK_REALTIME, &limite);
limite.tv_sec += 2;                                   /* 2 segundos como máximo */

if (pthread_mutex_timedlock(&candado_fichero, &limite) != 0) {
    pthread_mutex_unlock(&candado_cache);   /* no llegué a tiempo: SUELTO lo mío */
    registrar("posible interbloqueo evitado por tiempo límite");
    return REINTENTAR;
}

pthread_mutex_timedlock convierte una espera infinita en una acotada, transformando un interbloqueo permanente en un fallo temporal recuperable. El coste es que hay que poder deshacer el trabajo hecho hasta ese punto, lo que exige secciones críticas transaccionales; y en su forma agresiva puede producir livelock.

Romper la espera circular: el orden global. Esta es la técnica ganadora, la que usarás siempre: imponer un orden total sobre los recursos y exigir que todos los procesos los adquieran en orden creciente.

/* Todo el sistema respeta este orden. Documentado y sin excepciones. */
#define ORDEN_CACHE   1
#define ORDEN_FICHERO 2
#define ORDEN_INDICE  3

/* El agregador: cache(1) → fichero(2)   ✔ creciente */
pthread_mutex_lock(&candado_cache);
pthread_mutex_lock(&candado_fichero);

/* meteo-api, CORREGIDO: cache(1) → fichero(2)   ✔ creciente */
pthread_mutex_lock(&candado_cache);      /* ← antes tomaba el fichero primero */
pthread_mutex_lock(&candado_fichero);

Por qué funciona, demostrado: supongamos que existe un ciclo P₀ → P₁ → ... → Pₙ → P₀. Cada arista significa que Pᵢ retiene un recurso de orden k y espera uno de orden m > k. Recorriendo el ciclo, los órdenes crecen estrictamente en cada paso, y al volver al punto de partida tendríamos k > k. Contradicción: el ciclo es imposible.

Cuando los recursos no tienen un orden natural, se usa su dirección de memoria:

/* Orden global por dirección: funciona para CUALQUIER par de candados */
void lock_ordenado(pthread_mutex_t *a, pthread_mutex_t *b) {
    if (a < b) { pthread_mutex_lock(a); pthread_mutex_lock(b); }
    else       { pthread_mutex_lock(b); pthread_mutex_lock(a); }
}

Es el patrón que usan las transferencias bancarias entre dos cuentas, y resuelve el problema de forma general: da igual qué dos candados le pases, siempre los tomará en el mismo orden absoluto. Coste en rendimiento: cero. Coste en disciplina: hay que documentar la jerarquía y respetarla en todo el código, incluido el que escriba otra persona dentro de dos años. Por eso los núcleos serios documentan su jerarquía de bloqueos y tienen validadores automáticos, como veremos.

Estrategia 2: evitación y el algoritmo del banquero

La evitación es más ambiciosa que la prevención: no restringe cómo se pide, sino que decide en cada solicitud si concederla, en función de si el estado resultante sigue siendo seguro. Requiere que cada proceso declare por adelantado su necesidad máxima de cada recurso.

Un estado seguro es aquel en el que existe una secuencia segura: un orden de los procesos ⟨P₁, P₂, ..., Pₙ⟩ tal que las necesidades pendientes de cada Pᵢ pueden satisfacerse con los recursos libres más los que liberarán todos los Pⱼ con j < i. Si existe esa secuencia, el sistema puede terminarlos a todos, uno tras otro, sin bloquearse. La relación entre los tres tipos de estado es jerárquica: seguro implica que no hay ni habrá interbloqueo; inseguro significa que puede haberlo, no que lo haya; e interbloqueado es un subconjunto de los inseguros. La evitación es conservadora: rechaza toda solicitud que lleve a un estado inseguro, aunque ese estado quizá no hubiera dado problemas.

El algoritmo del banquero, con un ejemplo completo

Dijkstra lo llamó así por analogía con un banquero que concede créditos: nunca compromete tanto dinero que no pueda satisfacer las líneas de crédito de todos sus clientes.

El escenario. meteo-01 tiene tres tipos de recurso —A: 10 conexiones a la base de datos; B: 5 búferes de 1 MB en /dev/shm; C: 7 descriptores reservados— y cinco procesos. El estado actual viene dado por la matriz Asignado (lo que cada proceso tiene ahora) y la matriz Máximo (lo que declaró que podría llegar a necesitar):

Proceso Asignado (A, B, C) Máximo (A, B, C) Necesidad = Máx − Asig
P₀ ingestor 0, 1, 0 7, 5, 3 7, 4, 3
P₁ agregador 2, 0, 0 3, 2, 2 1, 2, 2
P₂ meteo-api 3, 0, 2 9, 0, 2 6, 0, 0
P₃ archivador 2, 1, 1 2, 2, 2 0, 1, 1
P₄ monitor 0, 0, 2 4, 3, 3 4, 3, 1
Total asignado 7, 2, 5

Disponible = Total − Asignado = (10, 5, 7) − (7, 2, 5) = (3, 3, 2)

Pregunta 1: ¿es seguro este estado? Recorremos la lista buscando quién puede completarse con lo disponible; cuando uno termina, devuelve todo lo que tenía asignado:

Paso Disponible Elegido Su necesidad ¿Cabe? Libera Nuevo disponible
1 (3, 3, 2) P₁ (1, 2, 2) (2, 0, 0) (5, 3, 2)
2 (5, 3, 2) P₃ (0, 1, 1) (2, 1, 1) (7, 4, 3)
3 (7, 4, 3) P₄ (4, 3, 1) (0, 0, 2) (7, 4, 5)
4 (7, 4, 5) P₂ (6, 0, 0) (3, 0, 2) (10, 4, 7)
5 (10, 4, 7) P₀ (7, 4, 3) (0, 1, 0) (10, 5, 7)

En el paso 1, P₀ no cabía porque necesita 7 unidades de A y solo hay 3. El estado es SEGURO, con la secuencia ⟨P₁, P₃, P₄, P₂, P₀⟩; hay otras válidas, y basta con encontrar una.

Pregunta 2: P₁ (agregador) solicita (1, 0, 2). ¿Se le concede?

Tres comprobaciones en cadena. ¿Solicitud ≤ Necesidad? (1,0,2) ≤ (1,2,2), sí, no pide más de lo declarado. ¿Solicitud ≤ Disponible? (1,0,2) ≤ (3,3,2), sí, hay recursos. ¿El estado resultante sería seguro? Hay que simularlo: Disponible = (3,3,2) − (1,0,2) = (2, 3, 0); Asignado de P₁ = (3,0,2); Necesidad de P₁ = (0, 2, 0). Buscamos secuencia segura en ese estado hipotético:

Paso Disponible Proceso elegido Su necesidad ¿Cabe? Libera Nuevo disponible
1 (2, 3, 0) P₁ (0, 2, 0) (3, 0, 2) (5, 3, 2)
2 (5, 3, 2) P₃ (0, 1, 1) (2, 1, 1) (7, 4, 3)
3 (7, 4, 3) P₄ (4, 3, 1) (0, 0, 2) (7, 4, 5)
4 (7, 4, 5) P₀ (7, 4, 3) (0, 1, 0) (7, 5, 5)
5 (7, 5, 5) P₂ (6, 0, 0) (3, 0, 2) (10, 5, 7)

Existe la secuencia ⟨P₁, P₃, P₄, P₀, P₂⟩: el estado resultante es seguro y la solicitud se concede.

Pregunta 3: en el estado original, ¿qué pasa si P₀ (ingestor) solicita (0, 3, 0)? Pide dentro de su máximo y hay recursos ((0,3,0) ≤ (3,3,2)), pero al simular la concesión, Disponible quedaría en (3, 0, 2): el recurso B se agota por completo. Solo P₂ podría avanzar —necesita (6,0,0), no cabe: 6 > 3—, en realidad ninguno cabe, porque todos los demás necesitan al menos 1 unidad de B y no queda ninguna. No existe secuencia segura: estado inseguro, solicitud DENEGADA, aunque en ese instante hubiera recursos suficientes para satisfacerla.

Ese caso ilustra la esencia del algoritmo: denegar una petición que se podría satisfacer, porque conducir al sistema a un estado inseguro es un riesgo que no compensa.

Por qué casi no se usa

El algoritmo es correcto y elegante, pero en la práctica casi nadie lo aplica, por cuatro razones acumulativas:

Requisito Por qué falla en la práctica
Conocer la necesidad máxima por adelantado Un servidor no sabe cuántas conexiones necesitará; depende del tráfico
Número de procesos fijo Los procesos y los hilos se crean y destruyen constantemente
Recursos de cantidad fija La memoria disponible cambia, los descriptores se amplían
Coste O(n² × m) en cada solicitud Con 1.000 procesos y 20 tipos de recurso, 20 millones de operaciones por cada lock

Esa última fila es demoledora: un pthread_mutex_lock cuesta 20 nanosegundos; ejecutar el banquero antes de cada uno costaría milisegundos. Sería cinco órdenes de magnitud más lento.

Dónde sí se usa: sistemas embebidos de misión crítica con un conjunto fijo y conocido de tareas —aviónica, control industrial—, donde el número de procesos y recursos está congelado en tiempo de diseño y la certificación exige garantías formales. Para todo lo demás, el banquero es una herramienta conceptual valiosísima —el concepto de estado seguro estructura el pensamiento— y una técnica que no se implementa.

Estrategia 3: detección y recuperación

Si prevenir es caro y evitar es impracticable, la tercera vía es dejar que ocurra, detectarlo y salir del atolladero.

La detección se hace con el grafo espera-por (wait-for graph), una simplificación del grafo de asignación en la que se eliminan los nodos de recurso y se conecta directamente Pᵢ → Pⱼ cuando Pᵢ espera un recurso que tiene Pⱼ:

graph LR
    AGG((agregador)) -->|espera candado_fichero de| API((meteo-api))
    API -->|espera candado_cache de| AGG

Hay interbloqueo si y solo si el grafo espera-por tiene un ciclo —para recursos de una sola instancia—. La detección es un recorrido en profundidad, O(V + E). Para recursos con varias instancias hay que usar una variante del algoritmo del banquero que, en lugar de la necesidad máxima declarada, usa la solicitud pendiente real.

¿Con qué frecuencia ejecutarlo? Es un compromiso genuino:

Frecuencia Ventaja Inconveniente
En cada solicitud de recurso Detección inmediata, se sabe quién lo causó Coste prohibitivo
Cada N segundos (p. ej. 60) Coste amortizado bajo Los procesos llevan hasta 60 s colgados
Cuando el uso de CPU cae bajo un umbral Buen indicador indirecto Puede confundirse con inactividad legítima
Solo bajo sospecha manual Coste cero Requiere que alguien se dé cuenta

Una heurística usada en sistemas reales es la tercera: si el uso de CPU es bajo pero hay muchos procesos en estado no ejecutable, es sospechoso. Linux hace algo parecido con el detector de tareas colgadas que veremos en el apartado 9.

La recuperación tiene dos vías, y ninguna es agradable. La primera es terminar procesos: o todos los del ciclo (rápido y brutal) o uno cada vez, reevaluando (lento pero menos destructivo). La elección de la víctima se hace con criterios ponderados:

Criterio Preferir a quien...
Prioridad Tenga menor prioridad
Tiempo de CPU consumido Lleve menos tiempo ejecutándose (se pierde menos)
Recursos retenidos Tenga más recursos (desatasca a más procesos)
Recursos que aún necesita Necesite más (está más lejos de terminar)
Interactividad No sea interactivo: matar la sesión de un usuario es lo peor
Reinicios previos No haya sido víctima ya (evita la inanición)

La segunda vía es el retroceso (rollback): devolver un proceso a un punto de control anterior y reintentar. Es lo que hacen las bases de datos —cuando PostgreSQL detecta un interbloqueo, aborta la transacción más joven y devuelve el error 40P01 al cliente, que puede reintentarla—. Es la recuperación ideal porque no se pierde trabajo comprometido, pero exige que todo el trabajo sea transaccional, algo que un programa en C con mutex no tiene. El peligro de ambas vías es la inanición: si el algoritmo elige siempre a la misma víctima, ese proceso nunca terminará, así que hay que incluir en el criterio cuántas veces ha sido sacrificado ya.

Estrategia 4: el avestruz, y por qué Linux la adopta

La cuarta estrategia es no hacer nada: ignorar el problema y confiar en que sea lo bastante infrecuente como para que no compense el coste de tratarlo. Se conoce como el algoritmo del avestruz, por la imagen de esconder la cabeza en la arena.

Suena a dejadez, pero es una decisión de ingeniería consciente y es la que toman Linux, Windows, macOS y prácticamente todos los sistemas operativos de propósito general para los recursos de espacio de usuario. Las razones, ordenadas por peso:

1. El coste de las alternativas es desproporcionado. Prevenir con orden global exige coordinar todo el código del sistema, incluido el de terceros. Evitar con el banquero es cinco órdenes de magnitud más lento por cada lock. Detectar exige mantener un grafo actualizado de quién espera qué, con su propia sincronización. Todo eso para un fallo que, bien programado, no ocurre —y su frecuencia real es baja, porque los interbloqueos son fallos de programación, no eventos aleatorios del sistema—.

2. El sistema no puede saber qué es un interbloqueo y qué no. Este es el argumento decisivo y el menos obvio. Un proceso bloqueado en read() sobre una FIFO vacía es indistinguible, desde el núcleo, de uno interbloqueado: ambos esperan un evento que puede no llegar nunca, y el núcleo no sabe si el escritor va a aparecer. Distinguir "espera legítima" de "interbloqueo" requiere entender la intención del programa, y eso el sistema no puede.

3. Reiniciar es aceptable en la mayoría de contextos. Si un servicio se cuelga, systemd lo reinicia (módulo 7) y el sistema sigue. El coste de un reinicio ocasional es mucho menor que el de instrumentar todo el sistema.

Ahora bien, el avestruz no es universal, y merece la pena ver quién sí actúa:

Componente Estrategia Por qué
Mutex de usuario en Linux Avestruz Coste prohibitivo, es fallo del programador
Núcleo de Linux (lockdep) Prevención verificada Un interbloqueo en el núcleo cuelga la máquina entera
Detector hung task de Linux Detección y aviso Al menos avisa; no recupera
PostgreSQL, MySQL, Oracle Detección + retroceso Tienen transacciones: pueden abortar sin perder consistencia
Sistemas de tiempo real críticos Prevención estricta Un cuelgue puede costar vidas

La fila del núcleo merece una nota, porque es el mejor ejemplo del enfoque correcto: lockdep es un validador que se activa con CONFIG_PROVE_LOCKING y, durante la ejecución, aprende el orden en que se toman los candados y avisa la primera vez que alguien lo invierte, aunque el interbloqueo no llegue a producirse. Es el ThreadSanitizer de los bloqueos: detecta la causa, no el síntoma. Su coste es alto —un 20-30 % de rendimiento— y por eso solo se usa en núcleos de desarrollo, pero ha evitado miles de cuelgues en producción.

Interbloqueo, inanición y livelock

Tres patologías que se confunden constantemente. Conviene distinguirlas con precisión, porque el diagnóstico y la solución son distintos.

Interbloqueo Inanición Livelock
¿Los procesos avanzan? No El afectado, no Sí, pero sin progresar
¿Consumen CPU? No (0 %) No el afectado Sí, al 100 %
¿Se resuelve solo? Nunca A veces (si cambia la carga) A veces
Estado en ps S o D S R
Causa Espera circular Política de planificación injusta Reacción simétrica a un conflicto
Síntoma visible Silencio total Un componente muy lento CPU al 100 % sin resultados

El interbloqueo es el ejemplo de esta lección: dos hilos en S, 0 % de CPU, para siempre. La inanición es un proceso listo para ejecutarse al que el planificador nunca elige, o que pide un recurso que siempre se concede a otros —el escritor de la v1 de lectores-escritores, con 3 escrituras en 30 segundos—; la diferencia clave con el interbloqueo es que no hay ciclo, así que el proceso hambriento podría avanzar en cualquier momento si tuviera suerte, y se resuelve con envejecimiento (subir la prioridad de quien lleva tiempo esperando) o colas justas.

En el livelock, en cambio, los procesos ejecutan instrucciones pero su estado no progresa. La imagen es la de dos personas en un pasillo que se apartan simultáneamente al mismo lado, una y otra vez:

/* ⚠ LIVELOCK: los dos hilos son "educados" y ninguno avanza */
void tomar_ambos_mal(pthread_mutex_t *a, pthread_mutex_t *b) {
    while (1) {
        pthread_mutex_lock(a);
        if (pthread_mutex_trylock(b) == 0) return;
        pthread_mutex_unlock(a);      /* cedo educadamente... */
        /* ...y reintento INMEDIATAMENTE, en sincronía con el otro */
    }
}

Si los dos hilos ejecutan esto a la vez y a la misma velocidad, pueden alternar indefinidamente: A toma a, B toma b, A falla en b y suelta a, B falla en a y suelta b, y vuelta a empezar. Los dos núcleos al 100 %, cero progreso. Es peor que un interbloqueo en un sentido: al menos el interbloqueo no consume recursos y es fácil de ver en top.

La solución es la espera aleatoria (backoff), la misma idea que usa Ethernet para resolver colisiones:

usleep(1 + rand() % 100);          /* rompe la simetría */

Basta con que los reintentos no sean simultáneos para que uno de los dos gane. Añadir crecimiento exponencial (espera *= 2 en cada fallo, con un tope) lo hace robusto también bajo contención alta.

Diagnosticar un interbloqueo real

Esta es la parte que usarás en tu trabajo. Son las tres de la mañana, meteo-api no responde, y hay que averiguar qué pasa. Los pasos, en orden.

Paso 1: confirmar que está parado y no trabajando.

$ top -H -p $(pidof meteo-api)
  PID  USER    %CPU  %MEM  S  COMMAND
 2841  meteora  0,0   1,2  S  meteo-api
 2843  meteora  0,0   1,2  S  api-worker-0
 2844  meteora  0,0   1,2  S  api-worker-1

0,0 % de CPU en todos los hilos y estado S: no está calculando, está esperando. Si vieras 100 % y estado R, sería un bucle infinito o un livelock, no un interbloqueo. Esta primera distinción ahorra mucho tiempo.

Paso 2: ver en qué está esperando cada hilo, con cat /proc/2841/task/*/wchan. Si la respuesta es futex_wait_queue_me en todos, la firma es inconfundible: duermen esperando un futex, es decir, un mutex, un semáforo o una variable de condición (03-04). Si vieras pipe_write sería una tubería llena; sk_wait_data, un socket sin datos; io_schedule, E/S de disco pendiente. WCHAN acota el tipo de espera antes de mirar una sola línea de código.

Paso 3: obtener la pila de cada hilo. Aquí gdb es insustituible:

$ sudo gdb -p 2841 -batch -ex "thread apply all bt" 2>/dev/null

Thread 3 (LWP 2844) "api-worker-1":
#0  __lll_lock_wait (futex=0x5581e4a2c0c0, private=0) at lowlevellock.c:52
#1  __GI___pthread_mutex_lock (mutex=0x5581e4a2c0c0)
#2  0x00005581e2f1a4d1 in api_leer_cache () at api.c:212       ← pide candado_cache

Thread 2 (LWP 2843) "agregador-sync":
#0  __lll_lock_wait (futex=0x5581e4a2c100, private=0) at lowlevellock.c:52
#1  __GI___pthread_mutex_lock (mutex=0x5581e4a2c100)
#2  0x00005581e2f19a02 in agg_escribir_fichero () at agregador.c:88 ← pide el fichero

El hilo 3 está bloqueado en el mutex 0x5581e4a2c0c0 desde api.c:212; el hilo 2, en el 0x5581e4a2c100 desde agregador.c:88. Dos hilos bloqueados en dos mutex distintos: el patrón exacto del interbloqueo. Para confirmarlo hay que averiguar quién tiene cada mutex, y la estructura interna de pthread_mutex_t guarda el TID del propietario:

(gdb) print ((pthread_mutex_t *)0x5581e4a2c0c0)->__data.__owner    → 2843
(gdb) print ((pthread_mutex_t *)0x5581e4a2c100)->__data.__owner    → 2844

El ciclo queda cerrado y demostrado: el hilo 2844 espera un mutex que tiene 2843, y el 2843 espera uno que tiene 2844. Interbloqueo confirmado, con nombres de fichero y números de línea.

Paso 4: el detector hung task del núcleo. Para procesos en estado D (espera ininterrumpible, típicamente E/S), Linux tiene un vigilante que avisa solo:

$ cat /proc/sys/kernel/hung_task_timeout_secs      → 120
$ dmesg -T | tail
[mié sep  1 03:14:22] INFO: task agregador:2843 blocked for more than 120 seconds.
[mié sep  1 03:14:22] Call Trace:
[mié sep  1 03:14:22]  __schedule+0x2d1/0x870
[mié sep  1 03:14:22]  rwsem_down_write_slowpath+0x2ba/0x580

Un hilo del núcleo (khungtaskd) recorre cada 120 segundos las tareas en estado D y avisa de las que llevan demasiado tiempo. Importante: solo vigila el estado D, no S, así que no detecta interbloqueos de mutex de usuario —esos dejan a los hilos en S—. Sirve para bloqueos en el núcleo: un NFS caído, un disco que no responde, un candado del núcleo mal usado.

Paso 5: /proc/<pid>/stack muestra la pila de núcleo de un hilo (futex_wait_queue_mefutex_waitdo_futex__x64_sys_futexdo_syscall_64), confirmando desde el lado del núcleo lo que gdb ve desde el lado del usuario: el hilo entró por la llamada futex y está en la cola de espera. Requiere CONFIG_STACKTRACE y permisos de root.

Resumen de la caja de herramientas:

Herramienta Qué te dice Cuándo usarla
top -H CPU por hilo y estado Siempre primero: distingue parado de ocupado
cat /proc/<pid>/task/*/wchan En qué función del núcleo duerme Segundo paso: tipo de espera
gdb -p ... -ex "thread apply all bt" Pila completa de cada hilo El diagnóstico definitivo
print *(pthread_mutex_t *)DIR Quién posee un mutex Cerrar el ciclo
dmesg + hung_task Bloqueos en estado D Sospecha de E/S o del núcleo
/proc/<pid>/stack Pila de núcleo Confirmación desde el otro lado

El caso resuelto de meteo-api y el agregador

Con las herramientas anteriores, el incidente completo de Meteora. Síntoma: a las 03:14, la monitorización avisa de que meteo-api devuelve tiempos de espera agotados. El servicio está vivo, no ha reiniciado, no hay nada nuevo en /var/log/meteora/meteo-api.log desde las 03:12:47.

Diagnóstico (los cinco pasos anteriores, en tres minutos): 0 % de CPU y estado S en todos los hilos → futex_wait_queue_me en todos los wchangdb revela dos hilos bloqueados en mutex distintos → los __owner de esos mutex cierran el ciclo. Interbloqueo confirmado entre agregador.c:88 y api.c:212.

Causa raíz, al mirar el código:

/* agregador.c:82 — el ciclo horario, que se ejecuta a las 03:00 */
void agg_ciclo_horario(void) {
    pthread_mutex_lock(&candado_cache);        /* (1) toma la CACHÉ */
    calcular_medias_desde_cache();
    pthread_mutex_lock(&candado_fichero);      /* (2) toma el FICHERO */   ← línea 88
    escribir_resumen("/var/lib/meteora/lecturas/2026-08-31.dat");
    pthread_mutex_unlock(&candado_fichero);
    pthread_mutex_unlock(&candado_cache);
}

/* api.c:206 — una petición que necesita datos históricos */
void api_leer_historico(void) {
    pthread_mutex_lock(&candado_fichero);      /* (1) toma el FICHERO */
    struct Lectura *datos = leer_del_fichero();
    pthread_mutex_lock(&candado_cache);        /* (2) toma la CACHÉ */     ← línea 212
    actualizar_cache_con(datos);
    pthread_mutex_unlock(&candado_cache);
    pthread_mutex_unlock(&candado_fichero);
}

Órdenes opuestos: el agregador hace caché → fichero; api_leer_historico hace fichero → caché. Es el ejemplo mínimo de esta lección, escrito por dos personas distintas en dos ficheros distintos, cada uno perfectamente razonable por su cuenta.

Por qué apareció justo esa madrugada. Tuvieron que coincidir tres factores: agg_ciclo_horario() se ejecuta una vez por hora y solo entonces existe la ventana; api_leer_historico() se llama solo cuando un cliente pide datos de más de 24 horas, unas 40 veces al día; y la ventana entre las dos adquisiciones dura unos 200 µs, lo que tarda calcular_medias_desde_cache.

Probabilidad por hora ≈ (40/86400 llamadas/s) × 200 µs × 3600 s ≈ 0,00033: una vez cada 3.000 horas, unos cuatro meses. Ese número explica por qué el código pasó todas las pruebas, llevaba meses en producción y falló una madrugada de septiembre. Un interbloqueo con probabilidad ínfima es una certeza a medio plazo, exactamente como las condiciones de carrera de 03-01.

Solución inmediata (a las 03:20): systemctl restart meteo-api, que restaura el servicio en dos segundos pero no arregla nada. Solución definitiva: establecer un orden global de bloqueos, documentarlo, y corregir el orden en api.c:

/* meteora_locks.h — JERARQUÍA DE BLOQUEOS DE METEORA
 * Todo el código adquiere los candados en ESTE orden, sin excepciones.
 * Si necesitas un candado de nivel MENOR que uno que ya tienes,
 * suelta el que tienes primero. Nunca lo tomes al revés.
 */
#define NIVEL_CONFIG   1    /* /etc/meteora/meteora.conf          */
#define NIVEL_CACHE    2    /* /dev/shm/meteora-cache             */
#define NIVEL_FICHERO  3    /* /var/lib/meteora/lecturas/*.dat    */
#define NIVEL_LOG      4    /* /var/log/meteora/meteo-api.log     */
/* api.c:206 — CORREGIDO: caché(2) antes que fichero(3) */
void api_leer_historico(void) {
    pthread_mutex_lock(&candado_cache);        /* (1) CACHÉ primero, nivel 2 */
    pthread_mutex_lock(&candado_fichero);      /* (2) FICHERO después, nivel 3 */
    struct Lectura *datos = leer_del_fichero();
    actualizar_cache_con(datos);
    pthread_mutex_unlock(&candado_fichero);
    pthread_mutex_unlock(&candado_cache);
}

Defensa adicional: un envoltorio en modo depuración que verifica la jerarquía en tiempo de ejecución, al estilo de lockdep:

static __thread int nivel_maximo_tomado = 0;      /* uno por hilo */

void lock_meteora(pthread_mutex_t *m, int nivel, const char *donde) {
    if (nivel <= nivel_maximo_tomado) {
        fprintf(stderr, "⚠ VIOLACION DE JERARQUIA en %s: pide nivel %d "
                        "teniendo ya el %d\n", donde, nivel, nivel_maximo_tomado);
        abort();                                  /* falla RUIDOSAMENTE en pruebas */
    }
    pthread_mutex_lock(m);
    nivel_maximo_tomado = nivel;
}

Este envoltorio detecta la violación la primera vez que ocurre, aunque no llegue a producirse el interbloqueo. Es la misma filosofía que ThreadSanitizer y lockdep: buscar la causa, no esperar al síntoma. Compilado solo en las pruebas, el coste en producción es cero.

Reglas de ingeniería que lo evitan

Ninguna de estas reglas es teórica: todas salen de incidentes reales.

1. Define y documenta una jerarquía de bloqueos. Es la regla número uno, y la que habría evitado el incidente. Un fichero de cabecera con los niveles y un comentario en cada lock indicando el suyo. Si todo el código adquiere en orden creciente, la espera circular es matemáticamente imposible.

2. Cuando no haya orden natural, ordena por dirección de memoria. El patrón if (a < b) lock(a), lock(b); else lock(b), lock(a); funciona para cualquier par y no requiere ninguna convención previa.

3. Usa tiempos límite en código de larga vida. pthread_mutex_timedlock con 5 o 10 segundos convierte un cuelgue permanente en un error registrado del que puedes recuperarte. Registra siempre el fallo: un tiempo límite agotado es un aviso de que tienes un problema de diseño, no una solución.

4. Mantén las secciones críticas cortas y con un solo candado siempre que puedas. Si nunca tienes dos candados a la vez, nunca hay ciclo. Muchas veces basta con reorganizar: leer con el candado, soltar, calcular, volver a tomar para escribir.

5. Nunca llames a código ajeno con un candado tomado. Es la regla más olvidada y una de las más peligrosas. Si dentro de tu sección crítica invocas una callback, un plugin, un manejador de eventos o una biblioteca de terceros, no tienes ni idea de qué candados tomará ese código. Puede tomar el tuyo (autobloqueo), puede tomar otro en orden inverso (interbloqueo), puede hacer E/S de un segundo. Prepara los datos, suelta el candado, y llama después.

6. No hagas E/S ni reserves memoria dentro de una sección crítica. Un write() a disco puede tardar milisegundos y un malloc() puede tomar su propio candado interno del asignador: ambos multiplican por mil la ventana de peligro. Y cuidado con PTHREAD_MUTEX_RECURSIVE, que permite tomar el mismo mutex varias veces: evita autobloqueos, pero suele delatar que no tienes claro quién posee qué.

7. Ejecuta las pruebas con detectores activados. ThreadSanitizer (-fsanitize=thread) detecta también órdenes de bloqueo inconsistentes, no solo carreras. En el núcleo, lockdep. En tu código, un envoltorio como el del apartado anterior. Buscar la causa siempre gana a esperar el síntoma.

8. Prefiere primitivas de más alto nivel. Un canal, una cola de mensajes o un modelo de actores eliminan la clase entera de problemas: si no tomas candados, no hay ciclo posible. La mayor parte del código de aplicación puede escribirse sin un solo mutex explícito.

Errores Comunes y Consejos

Suponer que "funciona en las pruebas" significa que no hay interbloqueo. El caso de Meteora tenía una probabilidad de 0,00033 por hora y sobrevivió meses en producción antes de manifestarse. Las pruebas no encuentran interbloqueos; los análisis de orden de bloqueo, sí.

Revisar funciones aisladas. Cada una de las dos funciones del incidente era impecable por separado. El fallo estaba en la relación entre ellas, y solo una revisión que mire todos los sitios donde se toman esos dos candados lo detecta.

Confundir interbloqueo con inanición o con livelock. Si hay CPU al 100 %, no es interbloqueo: es livelock o un bucle. Si un componente avanza pero muy despacio, es inanición. top -H los distingue en cinco segundos y evita horas de búsqueda en la dirección equivocada.

Añadir un candado recursivo para "arreglar" un autobloqueo. El autobloqueo es el síntoma de que no sabes qué candados tienes tomados al llegar a esa función; RECURSIVE lo esconde y deja el problema de diseño intacto.

Poner tiempos límite y no registrar los fallos, o llamar a una callback con un candado tomado. Un timedlock que expira en silencio convierte un cuelgue visible en una degradación invisible: registra siempre, con el nombre del candado. Y llamar a código ajeno dentro de una sección crítica es la vía más rápida a un interbloqueo entre tu código y una biblioteca que no controlas.

Consejo: dibuja el grafo cuando dudes. Ante dos o tres candados y varios flujos, hacer el grafo de asignación en un papel tarda dos minutos y revela el ciclo de inmediato. Es la herramienta de razonamiento más rentable de esta lección.

Consejo: si necesitas dos candados a menudo, plantéate si deberían ser uno. Dos candados que casi siempre se toman juntos protegen, probablemente, un mismo invariante. Fusionarlos elimina el problema de raíz y a menudo simplifica el código.

Ejercicios

Ejercicio 1: reproducir, diagnosticar y arreglar

Escribe el programa de dos hilos con dos mutex en orden inverso y ejecútalo hasta que se cuelgue. Diagnostícalo siguiendo los cinco pasos del apartado 9: top -H, wchan, gdb con las pilas, e identificación del propietario de cada mutex. Documenta lo que ves en cada paso. Después arréglalo con el orden por dirección de memoria y verifica que ya no se cuelga en 10 millones de iteraciones. Finalmente, elimina los usleep de la versión rota y mide cuánto tarda en colgarse sin ellos, repitiendo la medición cinco veces.

Ejercicio 2: el algoritmo del banquero

Un sistema tiene 3 tipos de recurso con (12, 8, 6) unidades totales y cuatro procesos:

Proceso Asignado (A,B,C) Máximo (A,B,C)
P₀ 2, 1, 1 6, 4, 3
P₁ 3, 2, 1 5, 3, 2
P₂ 2, 1, 2 8, 5, 4
P₃ 1, 2, 0 4, 4, 2

Calcula la matriz de Necesidad y el vector Disponible. Determina si el estado es seguro y, si lo es, da una secuencia segura. Después evalúa dos solicitudes por separado, siempre desde el estado original: P₂ pide (1, 1, 0) y P₀ pide (3, 2, 1). Justifica cada decisión con las matrices completas.

Ejercicio 3: distinguir las tres patologías

Para cada situación, identifica si es interbloqueo, inanición, livelock o ninguna de las tres, indica qué verías en top -H y wchan, y propón una solución.

  • (a) Dos hilos de meteo-api con 0 % de CPU en estado S; uno espera el mutex A que tiene el otro, y viceversa.
  • (b) El agregador no consigue escribir en la caché desde hace 40 segundos porque los 4 trabajadores leen sin parar con un rwlock.
  • (c) Dos hilos al 100 % de CPU en estado R; ambos toman un candado, ven que el otro está ocupado, sueltan el suyo y reintentan inmediatamente.
  • (d) El ingestor lleva 5 minutos bloqueado en read() sobre /run/meteora/lecturas.fifo porque nadie escribe.
  • (e) Un hilo llama dos veces seguidas a pthread_mutex_lock sobre el mismo mutex no recursivo.

Soluciones

Solución 1

Diagnóstico paso a paso del programa colgado (PID 9412):

$ top -H -p 9412            → los 3 hilos al 0,0 % de CPU, estado S
$ cat /proc/9412/task/*/wchan
futex_wait_queue_me
futex_wait_queue_me

$ sudo gdb -p 9412 -batch -ex "thread apply all bt" | grep -E "Thread|deadlock.c"
Thread 3 (LWP 9414): #2  in api (arg=0x0) at deadlock.c:31        ← pide candado_cache
Thread 2 (LWP 9413): #2  in agregador (arg=0x0) at deadlock.c:18  ← pide candado_fichero

(gdb) print candado_cache.__data.__owner      → 9413
(gdb) print candado_fichero.__data.__owner    → 9414

El primer paso descarta livelock y bucle infinito (0 % de CPU y estado S significan parado, no trabajando); el segundo acota la espera a un futex, o sea, a un mutex, semáforo o variable de condición; el tercero da fichero y línea de cada bloqueo; y el cuarto cierra el ciclo: 9413 tiene la caché y espera el fichero, que tiene 9414; 9414 tiene el fichero y espera la caché, que tiene 9413. Interbloqueo confirmado entre deadlock.c:18 y deadlock.c:31.

Arreglo con orden por dirección:

void lock2(pthread_mutex_t *a, pthread_mutex_t *b) {
    if (a < b) { pthread_mutex_lock(a); pthread_mutex_lock(b); }
    else       { pthread_mutex_lock(b); pthread_mutex_lock(a); }
}
/* Ambos hilos llaman a lock2(&candado_cache, &candado_fichero) */

Con 10.000.000 de iteraciones y sin usleep, ninguna de las 20 ejecuciones se colgó (0,71 s de media). Los dos hilos toman siempre el mutex de dirección menor primero, así que la espera circular no puede formarse.

Sin los usleep, en la versión rota, las iteraciones que aguanta antes de colgarse en cinco ejecuciones fueron 47.219 (0,08 s), 3.106.884 (4,91 s), 812 (0,002 s), 18.443.201 (31,2 s) y 291.556 (0,47 s).

Cuatro órdenes de magnitud de diferencia entre la más rápida y la más lenta, sin cambiar una sola línea. Esa es la naturaleza del problema: la ventana entre las dos adquisiciones dura unos pocos nanosegundos y el interbloqueo requiere que el planificador expulse justo ahí. Con suficientes iteraciones siempre ocurre, pero cuándo es impredecible. En producción, con secciones críticas de microsegundos y operaciones que ocurren unas decenas de veces al día, ese "siempre" se traduce en meses.

Solución 2

Necesidad = Máximo − Asignado:

Proceso Asignado Máximo Necesidad
P₀ 2, 1, 1 6, 4, 3 4, 3, 2
P₁ 3, 2, 1 5, 3, 2 2, 1, 1
P₂ 2, 1, 2 8, 5, 4 6, 4, 2
P₃ 1, 2, 0 4, 4, 2 3, 2, 2
Total asignado 8, 6, 4

Disponible = (12, 8, 6) − (8, 6, 4) = (4, 2, 2)

¿Es seguro el estado?

Paso Disponible Proceso Necesidad ¿Cabe? Libera Nuevo disponible
1 (4, 2, 2) P₁ (2, 1, 1) (3, 2, 1) (7, 4, 3)
2 (7, 4, 3) P₀ (4, 3, 2) (2, 1, 1) (9, 5, 4)
3 (9, 5, 4) P₃ (3, 2, 2) (1, 2, 0) (10, 7, 4)
4 (10, 7, 4) P₂ (6, 4, 2) (2, 1, 2) (12, 8, 6)

Estado SEGURO, con la secuencia ⟨P₁, P₀, P₃, P₂⟩. (En el paso 1, P₃ también cabría —(3,2,2) frente a (4,2,2)—, así que hay más secuencias válidas.)

Solicitud A: P₂ pide (1, 1, 0). Cumple las dos primeras comprobaciones —(1,1,0) ≤ Necesidad (6,4,2) y ≤ Disponible (4,2,2)—, así que simulamos: Disponible = (3,1,2), Asignado P₂ = (3,2,2), Necesidad P₂ = (5,3,2).

Paso Disponible Proceso Necesidad ¿Cabe? Nuevo disponible
1 (3, 1, 2) P₁ (2, 1, 1) (6, 3, 3)
2 (6, 3, 3) P₀ (4, 3, 2) (8, 4, 4)
3 (8, 4, 4) P₃ (3, 2, 2) (9, 6, 4)
4 (9, 6, 4) P₂ (5, 3, 2) (12, 8, 6)

Secuencia segura ⟨P₁, P₀, P₃, P₂⟩. SE CONCEDE.

Solicitud B: P₀ pide (3, 2, 1). También cumple las dos primeras —(3,2,1) ≤ Necesidad (4,3,2) y ≤ Disponible (4,2,2)—, pero al simular, Disponible queda en (1, 0, 1) y Necesidad de P₀ en (1,1,1). Ahora ninguno cabe: P₀ necesita 1 de B y hay 0; P₁ necesita 2 de A y 1 de B, y hay 1 y 0; P₂ y P₃ están mucho más lejos. Ningún proceso puede completarse, no existe secuencia segura, el estado resultante es inseguro y LA SOLICITUD SE DENIEGA.

Compara las dos: en A quedaban (3,1,2), suficiente para que P₁ terminase y liberase sus recursos, arrancando la cadena. En B el recurso B se agota por completo, dejando a los cuatro procesos incapaces de alcanzar su máximo. Ese es exactamente el escenario que el banquero existe para impedir: no un interbloqueo actual, sino la posibilidad de uno si todos pidieran su máximo.

Solución 3

Caso Diagnóstico En top -H / wchan Solución
(a) Interbloqueo 0 % CPU, S, futex_wait_queue_me Orden global de bloqueos; a corto plazo, reiniciar
(b) Inanición El agregador a 0 % en S; los lectores avanzan PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP, o doble búfer
(c) Livelock 100 % CPU, estado R, wchan vacío Espera aleatoria con crecimiento exponencial entre reintentos
(d) Ninguna: espera legítima 0 % CPU, S, pipe_wait No es un fallo. Si no debería pasar, revisa por qué no escribe el productor
(e) Autobloqueo (interbloqueo de uno) 0 % CPU, S, futex_wait_queue_me Arreglar el flujo de control; no parchear con RECURSIVE

Comentarios sobre los casos que más se confunden:

(b) frente a (a). La diferencia es que en la inanición el sistema en conjunto sí progresa: los lectores completan millones de operaciones. Solo un componente está parado, y podría avanzar en cualquier momento si hubiera un hueco. No hay ciclo, y por eso gdb mostraría al agregador esperando un candado cuyo propietario cambia constantemente, en lugar de estar fijo. Ese es el indicio diferencial: si mirando dos veces con un segundo de diferencia el __owner es distinto, es inanición, no interbloqueo.

(c) es el más fácil de identificar y el más fácil de confundir de lejos. El estado R y el 100 % de CPU lo delatan de inmediato: un interbloqueo nunca consume CPU. Si el servicio no responde pero los núcleos están al máximo, no busques un ciclo de candados; busca un bucle de reintentos o un bucle infinito.

(d) es la razón por la que el sistema operativo no puede detectar interbloqueos automáticamente. Desde el núcleo, este caso es indistinguible de (a): un proceso en S esperando un evento que quizá nunca llegue. La diferencia solo existe en la intención del programa —¿debería llegar alguien a escribir en esa FIFO?—, y esa información el núcleo no la tiene. Es el argumento 3 del apartado sobre el avestruz, en forma concreta.

(e) produce el mismo cuadro clínico que (a) pero con un solo hilo implicado, y en gdb se ve enseguida: el __owner del mutex es el TID del propio hilo que espera. Es un ciclo de longitud 1.

Conclusión

Un interbloqueo es un conjunto de procesos donde cada uno espera un evento que solo otro del conjunto puede producir, y la espera es permanente. Lo hemos construido en veinte líneas —dos mutex tomados en orden inverso— y hemos visto que cada función era correcta por separado: el fallo vive en la relación entre ellas, que es por lo que ninguna revisión de código aislada lo encuentra.

Las cuatro condiciones de Coffman —exclusión mutua, retención y espera, ausencia de expropiación y espera circular— son necesarias todas a la vez, y ese es su valor: basta romper una. La primera es casi siempre irrompible, la segunda cuesta concurrencia e inanición, la tercera exige poder deshacer trabajo, y la cuarta se rompe con un orden global de adquisición a coste cero. El grafo de asignación de recursos da el modelo formal: con recursos de una sola instancia, un ciclo es condición necesaria y suficiente.

De las cuatro estrategias, la prevención por orden global es la que usarás siempre, y su demostración es de dos líneas: si los órdenes crecen estrictamente a lo largo de un ciclo, al cerrarlo tendríamos k > k. La evitación con el algoritmo del banquero, que hemos resuelto con matrices completas, formaliza la idea valiosa de estado seguro, pero exige conocer las necesidades máximas por adelantado y cuesta O(n²m) por solicitud —cinco órdenes de magnitud más que el lock que protege—, así que solo se usa en sistemas embebidos críticos. La detección y recuperación con grafo espera-por es la vía de las bases de datos, que pueden abortar transacciones sin perder consistencia, con todo el problema de elegir la víctima sin producir inanición. Y la estrategia del avestruz es la que adoptan Linux, Windows y macOS para el espacio de usuario, por una razón que va más allá del coste: el sistema no puede distinguir una espera legítima de un interbloqueo, porque eso exigiría conocer la intención del programa. El núcleo, en cambio, sí se protege, con lockdep verificando el orden de los candados.

Hemos separado el interbloqueo de sus dos primos: la inanición, donde el sistema progresa pero un componente no, sin ciclo y potencialmente resoluble; y el livelock, donde los procesos ejecutan al 100 % de CPU sin progresar, que se cura con espera aleatoria. top -H los distingue en cinco segundos: 0 % y S es interbloqueo; 100 % y R es livelock.

Y lo más útil: el procedimiento de diagnóstico. top -H para confirmar que está parado; wchan para saber el tipo de espera (futex_wait_queue_me es la firma de los mutex); gdb -p ... -ex "thread apply all bt" para las pilas de todos los hilos; y print sobre la estructura del mutex para leer su __owner y cerrar el ciclo con nombres de fichero y líneas. El caso de Meteora se resolvió así en tres minutos: agregador.c:88 tomaba caché→fichero y api.c:212 fichero→caché, con una probabilidad de coincidencia de una vez cada cuatro meses, que explica por qué pasó todas las pruebas. La solución fue una jerarquía de bloqueos documentada —config, caché, fichero, log— más un envoltorio que aborta en pruebas cuando alguien la viola.

Cierre del módulo 3

Con esto termina el módulo, y merece la pena ver el recorrido completo. Empezaste entendiendo qué sale mal (03-01): concurrencia frente a paralelismo, el entrelazado como modelo mental, y contador++ descompuesto en tres instrucciones máquina que pierden medio millón de incrementos. Ahí aparecieron la sección crítica y sus tres requisitos —exclusión mutua, progreso, espera limitada— que han servido de criterio en todo el módulo, junto con la ley de Amdahl y su techo de 3,57× para el agregador.

Después conociste a los protagonistas (03-02): el hilo como flujo con solo tres cosas privadas —contador de programa, registros y pila—, la tabla exhaustiva de qué comparte y qué no, los 22 µs frente a 180 µs que justifican su existencia, y la revelación de que Linux no implementa hilos sino clone(), con el GIL de Python medido sin mitos. En IPC (03-03) resolviste cómo hablan procesos que no comparten memoria: tuberías con su búfer de 65.536 bytes y su contrapresión, FIFO, colas POSIX con prioridades, memoria compartida sobre /dev/shm/meteora-cache, sockets y señales. Y quedó marcado el asterisco: la memoria compartida transporta datos pero no coordina a nadie.

Ese asterisco se pagó en sincronización (03-04), la lección central: tres intentos ingenuos que fallan, Peterson y su límite en el hardware real, compare-and-swap como cimiento de todo, y sobre él spinlocks, mutex, semáforos, variables de condición, rwlocks y barreras. Con futex explicando por qué un mutex cuesta 20 ns —841 llamadas al sistema para dos millones de adquisiciones—, volatile desmontado, y la granularidad medida en 7,25× con particionado. Los problemas clásicos (03-05) te dieron el vocabulario: productor-consumidor con sus tres semáforos y la trampa del orden de los wait; lectores-escritores con su inanición medida en 3 escrituras por cada 30 segundos; filósofos y su ciclo; barbero dormilón como el grupo de hilos de meteo-api. Y esta última lección cerró el círculo explicando por qué funcionaban las soluciones de los filósofos.

Si el módulo 2 respondía a cómo se reparte un recurso escaso, el módulo 3 ha respondido a cómo se coordinan varios flujos sobre un dato compartido, y la respuesta ha tenido siempre la misma forma: una operación indivisible que el hardware garantiza, una primitiva del sistema construida sobre ella, y una disciplina de diseño que el programador debe respetar. Las tres capas son necesarias; ninguna basta sola.

Pero fíjate en algo. Todo este módulo ha ocurrido en memoria: contadores, cachés, estructuras compartidas, todo volátil, todo perdido si meteo-01 se apaga. Y sin embargo llevamos tres módulos nombrando /var/lib/meteora/lecturas/2026-08-31.dat, /etc/meteora/meteora.conf y /var/log/meteora/meteo-api.log como si fueran obvios. ¿Qué es exactamente un fichero? ¿Cómo sabe el sistema en qué bloques del disco están sus 17 MB? ¿Qué pasa de verdad cuando abres una ruta como /var/lib/meteora/lecturas/, y por qué esa ruta se parece tan poco a lo que hay en el SSD? ¿Y cómo sobrevive todo eso a un corte de luz en mitad de una escritura?

Es el Módulo 4: Estructuras de Archivos, y empieza en Sistemas de Archivos.

Fundamentos de Sistemas Operativos

Módulo 1: Introducción a los Sistemas Operativos

Módulo 2: Gestión de Recursos

Módulo 3: Concurrencia

Módulo 4: Estructuras de Archivos

Módulo 5: Protección y Seguridad del Sistema

Módulo 6: Virtualización y Contenedores

Módulo 7: Administración y Diagnóstico en la Práctica

© Copyright 2026. Todos los derechos reservados