wandres.dev
SINCRONIZADOR · Concurrencia

Exclusión mutua: mutex, condiciones e interbloqueos

El mutex como guardián de invariantes y no de datos, las variables de condición y su predicado obligatorio, el productor-consumidor completo con búfer circular, y las cuatro condiciones de Coffman que hay que romper para que no haya interbloqueo.

⏱ 20 min

Casi todo el mundo aprende que un mutex sirve para proteger una variable. Es una media verdad que se paga cara, porque conduce a poner un candado por dato y a que el programa se interbloquee o se corrompa igualmente. Un mutex protege una invariante: la afirmación que debe ser cierta sobre un conjunto de datos siempre que nadie esté dentro de la sección crítica.

🎯 Al terminar esta lección sabrás
  • Escribir secciones críticas correctas y elegir la granularidad del candado.
  • Usar variables de condición con predicado y bucle, y entender el despertar espurio.
  • Implementar un productor-consumidor completo con búfer circular acotado.
  • Reconocer las cuatro condiciones del interbloqueo y romper la que se puede romper.

El mutex y la sección crítica

Un mutex tiene dos operaciones y un único invariante propio: como mucho un hilo está dentro a la vez. Todo lo demás —qué protege, durante cuánto tiempo, en qué orden— lo pones tú.

#include <threads.h>

typedef struct {
    mtx_t candado;
    size_t n, capacidad;
    Elemento *datos;          /* invariante: n es menor o igual que capacidad */
} Lista;                      /*             y datos tiene capacidad huecos   */

bool lista_push(Lista *l, Elemento e) {
    mtx_lock(&l->candado);
    if (l->n == l->capacidad && !crecer(l)) {
        mtx_unlock(&l->candado);          /* toda salida debe desbloquear */
        return false;
    }
    l->datos[l->n++] = e;
    mtx_unlock(&l->candado);
    return true;
}

La invariante enunciada en el comentario es falsa durante un instante dentro de crecer, mientras el puntero apunta al bloque nuevo pero capacidad todavía guarda el valor viejo. Ese instante es precisamente lo que la sección crítica existe para ocultar. Si dividieras el candado en dos —uno para n y otro para datos— cada dato quedaría “protegido” y el objeto entero seguiría rompiéndose.

De ahí salen las dos reglas de granularidad. Un candado demasiado fino no protege nada, porque las invariantes cruzan campos. Un candado demasiado grueso serializa el programa entero y convierte ocho núcleos en uno. La regla operativa: un candado por invariante, no por campo ni por objeto.

La segunda disciplina es la de las salidas. En C no hay destructores, así que cada return, cada goto y cada rama de error dentro de una sección crítica es una oportunidad de salir con el candado echado. El patrón de salida única con goto de la lección de control de flujo no es aquí una preferencia estilística sino una defensa:

int operacion(Lista *l) {
    int rc = 0;
    mtx_lock(&l->candado);
    if (!paso_uno(l))  { rc = -1; goto fin; }
    if (!paso_dos(l))  { rc = -2; goto fin; }
fin:
    mtx_unlock(&l->candado);
    return rc;
}

Los sabores del mutex importan. mtx_plain es el básico; mtx_timed admite mtx_timedlock; mtx_recursive permite que el mismo hilo lo tome varias veces. El recursivo suele ser un parche que oculta un diseño donde no está claro quién posee el candado: si lo necesitas, casi siempre existe una versión interna de la función que asume el candado ya tomado y que resuelve el problema mejor.

Variables de condición: nunca sin predicado

Un mutex resuelve la exclusión, no la espera. Para bloquearse hasta que ocurra algo hace falta una variable de condición, que hace atómicamente dos cosas: soltar el mutex y dormir.

mtx_lock(&m);
while (!hay_trabajo(&cola))       /* while, JAMAS if */
    cnd_wait(&no_vacia, &m);      /* suelta m, duerme, y vuelve con m tomado */
Tarea t = sacar(&cola);
mtx_unlock(&m);

El bucle no es defensivo por gusto. Hay tres razones independientes por las que despertar no implica que la condición sea cierta. La primera es el despertar espurio: la norma permite explícitamente que cnd_wait retorne sin que nadie haya señalado. La segunda es el robo: entre que te despiertan y que recuperas el mutex, otro hilo puede haber entrado y consumido lo que te habían anunciado. La tercera es cnd_broadcast, que despierta a todos aunque solo haya trabajo para uno.

La regla de oro que hace correcto el conjunto: el predicado se modifica siempre con el mutex tomado. La señal puede emitirse dentro o fuera de la sección crítica —fuera reduce el rebote del mutex, dentro es más fácil de razonar—, pero si cambias el predicado sin el candado, la señal puede colarse en el hueco entre la comprobación y el sueño, y el hilo se duerme para siempre con el trabajo ya en la cola. Eso es el despertar perdido, y es un interbloqueo que aparece una vez cada varios millones de iteraciones.

Productor-consumidor con búfer acotado

El patrón canónico necesita dos condiciones, porque hay dos razones distintas para esperar: que esté lleno y que esté vacío.

#define CAP 64

typedef struct {
    mtx_t m;
    cnd_t no_lleno, no_vacio;
    Tarea buf[CAP];
    size_t cabeza, cola, n;
} Canal;

void canal_enviar(Canal *c, Tarea t) {
    mtx_lock(&c->m);
    while (c->n == CAP)
        cnd_wait(&c->no_lleno, &c->m);
    c->buf[c->cola] = t;
    c->cola = (c->cola + 1) % CAP;
    c->n++;
    mtx_unlock(&c->m);
    cnd_signal(&c->no_vacio);          /* hay algo que consumir */
}

Tarea canal_recibir(Canal *c) {
    mtx_lock(&c->m);
    while (c->n == 0)
        cnd_wait(&c->no_vacio, &c->m);
    Tarea t = c->buf[c->cabeza];
    c->cabeza = (c->cabeza + 1) % CAP;
    c->n--;
    mtx_unlock(&c->m);
    cnd_signal(&c->no_lleno);          /* hay hueco para producir */
    return t;
}

Que el búfer sea acotado no es un detalle de implementación: es el mecanismo de contrapresión. Con una cola infinita, un productor más rápido que el consumidor no se bloquea nunca y el proceso muere por consumo de memoria horas después, lejos de la causa. El límite convierte un fallo catastrófico y tardío en una espera local y visible.

Dos errores frecuentes en esta estructura. El primero es usar una sola variable de condición para las dos esperas: entonces cnd_signal puede despertar a un productor cuando lo que había era un hueco para consumidor, y el sistema se para; con una sola condición hay que usar cnd_broadcast siempre, y pagar el rebaño despertado. El segundo es olvidar el cierre ordenado: hace falta un campo cerrado que los consumidores comprueben en el mismo predicado, seguido de un cnd_broadcast final, o los consumidores se quedan esperando eternamente un trabajo que ya no vendrá.

Interbloqueos: cuatro condiciones, una rompible

Un interbloqueo necesita las cuatro condiciones de Coffman a la vez: exclusión mutua, retención y espera, ausencia de expropiación y espera circular. Las tres primeras son consustanciales a los mutex; la cuarta es la única que está en tu mano.

flowchart LR
H1[Hilo 1 posee A] --> W1[Espera B]
W1 --> H2[Hilo 2 posee B]
H2 --> W2[Espera A]
W2 --> H1
style W1 fill:#f38ba8,color:#11111b
style W2 fill:#f38ba8,color:#11111b

La transferencia entre dos cuentas es el ejemplo perfecto, porque el ciclo aparece sin que nadie escriba código raro: dos hilos transfiriendo en sentidos opuestos toman los candados en orden inverso. La solución es imponer un orden global y respetarlo en todo el programa; cuando no hay una jerarquía natural, la dirección del objeto sirve como orden total arbitrario pero consistente:

void transferir(Cuenta *a, Cuenta *b, long importe) {
    Cuenta *primero = a, *segundo = b;
    if ((uintptr_t)b < (uintptr_t)a) { primero = b; segundo = a; }

    mtx_lock(&primero->m);             /* siempre en el mismo orden */
    mtx_lock(&segundo->m);
    a->saldo -= importe;
    b->saldo += importe;
    mtx_unlock(&segundo->m);
    mtx_unlock(&primero->m);
}

La otra regla, más difícil de sostener y más valiosa, es no llamar a código ajeno con un candado tomado. Una devolución de llamada, un manejador de eventos o un método virtual invocado dentro de la sección crítica puede tomar candados que tú no conoces, y entonces el orden global que tanto cuidaste queda fuera de tu control. Copia lo que necesites, suelta el candado y llama después.

Todo esto es verificable. ThreadSanitizer detecta inversiones del orden de bloqueo aunque el interbloqueo no llegue a ocurrir en esa ejecución, y valgrind --tool=helgrind hace lo propio: te dicen que el ciclo es posible, no que sucedió.

La sección crítica es donde tu invariante tiene permiso para ser falsa

Hay una forma de mirar los mutex que reorganiza todo lo demás y que casi nunca se enseña. Un objeto correcto satisface, en reposo, una afirmación: el contador de elementos coincide con los elementos realmente presentes, el nodo apuntado por la cola no tiene siguiente, la suma de los saldos es constante. Esa afirmación es la invariante, y es lo único que hace que el objeto tenga sentido. Ahora bien, ninguna modificación no trivial puede mantenerla cierta en todo instante, porque modificar significa cambiar varios campos y entre uno y otro el objeto es, literalmente, un objeto inconsistente. En un programa de un solo hilo eso no importa: nadie mira. En un programa concurrente, ese hueco es todo lo que un segundo hilo necesita para leer basura o para escribir sobre una estructura a medio construir. Visto así, el mutex no es un guardia de tráfico ni un semáforo de acceso a una variable: es el mecanismo que declara un intervalo de tiempo durante el cual la invariante tiene permiso para ser falsa porque se garantiza que nadie más está mirando. Todas las reglas prácticas se deducen de ahí sin memorizar ninguna. El candado protege el conjunto de datos que la invariante relaciona, y por eso repartir un candado por campo es absurdo. El candado debe soltarse solo cuando la invariante ha vuelto a ser cierta, y por eso salir con return a mitad de la modificación es un desastre aunque desbloquees. Las variables de condición esperan sobre predicados y no sobre señales, porque el predicado es una afirmación sobre el estado y las señales son solo pistas de que quizá cambió. Y llamar a código ajeno dentro de la sección crítica es peligroso no solo por el orden de los candados, sino porque ese código puede observar tu objeto en el único momento de su vida en el que no tiene sentido. Quien piensa en invariantes deja de necesitar reglas; quien piensa en variables protegidas necesita todas y aun así se le escapan.

⚔️ Un canal que aguanta el cierre
  1. Implementa el Canal completo con cierre ordenado: añade un campo cerrado, haz que canal_recibir devuelva un indicador de fin cuando esté cerrado y vacío, y comprueba que ningún consumidor queda colgado.
  2. Lanza cuatro productores y dos consumidores durante un millón de tareas y verifica que la suma consumida coincide exactamente con la producida.
  3. Sustituye las dos variables de condición por una sola con cnd_signal y demuestra que el sistema se detiene; arréglalo con cnd_broadcast y mide la diferencia de rendimiento.
  4. Cambia el while del predicado por un if y ejecuta bajo carga hasta reproducir el fallo. Explica cuál de las tres causas de despertar sin condición lo provocó.
  5. Escribe la transferencia entre cuentas sin ordenar los candados, provoca el interbloqueo con dos hilos en sentidos opuestos y captura el ciclo con -fsanitize=thread y con helgrind. Corrígelo con el orden por dirección y confirma que las herramientas callan.