Concurrencia y sus problemas: condiciones de carrera, sección crítica, interbloqueo e inanición

Por: Artiko
sistemas-operativosadaelixirconcurrenciacondiciones-de-carreraseccion-criticaexclusion-mutuainterbloqueopeterson

Concurrencia y sus problemas: condiciones de carrera, sección crítica, interbloqueo e inanición

En el capítulo 6 quedó establecido que el sistema operativo puede detener un flujo de ejecución en casi cualquier punto, guardar su contexto y ceder el procesador a otro. Ese mecanismo es lo que permite que un computador con cuatro núcleos sostenga trescientos procesos. También es, exactamente, el origen de todos los problemas de este capítulo.

Cuando un solo flujo ejecuta un programa, el razonamiento es simple: la línea 10 corre después de la línea 9 y antes de la línea 11. Cuando hay dos flujos que comparten datos, esa garantía desaparece. Entre dos instrucciones cualesquiera de un hilo puede haber cero, una o diez mil instrucciones del otro hilo. El programa ya no tiene una historia: tiene un conjunto enorme de historias posibles, y basta con que una de ellas produzca un resultado incorrecto para que el programa sea incorrecto.

Este capítulo trata de identificar esas historias, ponerles nombre y neutralizarlas. Empieza desde cero —qué es concurrencia, qué es paralelismo— y termina resolviendo los tres problemas clásicos de sincronización con código que compila y corre.

Concurrencia y paralelismo no son lo mismo

Las dos palabras se usan como sinónimos en conversación informal, pero describen cosas distintas.

Concurrencia es una propiedad de la estructura del programa: hay varias actividades vivas al mismo tiempo, cada una con su propio avance, y el programa está escrito de modo que sus intercalados sean válidos. La concurrencia existe aunque haya un solo núcleo: el planificador alterna entre las actividades y ninguna termina antes de que otra empiece.

Paralelismo es una propiedad de la ejecución: dos o más instrucciones se ejecutan literalmente en el mismo instante físico, en unidades de cómputo distintas. El paralelismo exige hardware múltiple: varios núcleos, varios procesadores, varias máquinas.

De ahí se sigue una relación asimétrica: todo programa paralelo es concurrente, pero un programa concurrente puede ejecutarse sin ningún paralelismo. Y lo importante para este capítulo: los errores de concurrencia no necesitan paralelismo para aparecer. Un solo núcleo con conmutación de contexto los produce igual.

flowchart TB
    subgraph CONC["Concurrencia — 1 núcleo, intercalado en el tiempo"]
        direction LR
        C1["A: instr 1-3"] --> C2["B: instr 1-2"] --> C3["A: instr 4-6"] --> C4["B: instr 3-7"] --> C5["A: instr 7"]
    end

    subgraph PAR["Paralelismo — 2 núcleos, ejecución simultánea"]
        direction LR
        subgraph N0["Núcleo 0"]
            P1["A: instr 1-7"]
        end
        subgraph N1["Núcleo 1"]
            P2["B: instr 1-7"]
        end
    end

    CONC -.->|"misma estructura de programa"| PAR

    style CONC fill:#1e3a5f,color:#fff
    style PAR fill:#1e5f3a,color:#fff

La tabla siguiente resume las diferencias que importan al escribir código.

AspectoConcurrenciaParalelismo
Qué describeCómo está estructurado el programaCómo se ejecuta físicamente
Hardware mínimoUn núcleoDos o más núcleos
Objetivo principalModelar actividades independientes, no bloquearse en E/SReducir el tiempo total de cómputo
Ganancia típicaCapacidad de respuesta, uso del procesador durante esperasAceleración proporcional a los núcleos
Simultaneidad realNo garantizadaGarantizada
¿Genera condiciones de carrera?Sí, y con ventanas más grandes
EjemploServidor que atiende mil conexiones con ocho hilosMultiplicar dos matrices repartiendo filas entre núcleos

Un detalle que suele confundir: pasar de un núcleo a ocho no crea errores nuevos de concurrencia, pero sí hace muchísimo más probable que los errores latentes se manifiesten. Un error que en un núcleo aparecía una vez cada millón de ejecuciones puede aparecer una vez cada mil en ocho núcleos.

Por qué los programas concurrentes son difíciles

Hay tres razones concretas, y conviene nombrarlas antes de ver un solo ejemplo.

No determinismo

Un programa secuencial es determinista: mismas entradas, misma salida, siempre. Un programa concurrente no lo es. La salida depende de decisiones del planificador que no están bajo control del programa y que dependen de carga del sistema, interrupciones, temperatura del procesador y frecuencia de reloj.

Esto invierte la lógica habitual de las pruebas. En secuencial, si el programa pasa el test, el camino que ejercitó el test es correcto. En concurrente, si el programa pasa el test, lo único que sabes es que ese intercalado particular fue correcto. Los otros millones de intercalados siguen sin verificar.

Atomicidad no evidente

Una línea de código fuente no es una unidad indivisible. contador = contador + 1 son al menos tres operaciones de máquina: leer memoria, sumar, escribir memoria. El planificador puede interrumpir entre cualquiera de ellas. La atomicidad —la propiedad de que una operación ocurra completa o no ocurra— hay que construirla explícitamente; no viene por defecto.

Bohrbugs y Heisenbugs

Los errores de programas secuenciales suelen ser Bohrbugs: sólidos, reproducibles, siempre en el mismo lugar con las mismas entradas. Se llaman así por el modelo atómico de Bohr, con electrones en órbitas fijas y predecibles.

Los errores de concurrencia tienden a ser Heisenbugs: cambian o desaparecen cuando se los observa. Agregar un printf para depurar altera los tiempos y el error deja de reproducirse. Compilar sin optimizaciones cambia el código generado y el error se esconde. Correr bajo un depurador serializa la ejecución y el error nunca aparece. El nombre alude al principio de incertidumbre de Heisenberg.

Tipo de errorReproducibleSensible a la observaciónEstrategia de diagnóstico
BohrbugSí, siempreNoDepurador paso a paso, bisección del código
HeisenbugIntermitenteSí, muchoRazonamiento sobre intercalados, detectores dinámicos, revisión de invariantes

La consecuencia práctica es que la corrección de un programa concurrente se argumenta, no se prueba a fuerza de ejecuciones. Hay que identificar los datos compartidos, los invariantes que deben mantenerse y demostrar que ningún intercalado los rompe.

La condición de carrera, desarmada

Una condición de carrera ocurre cuando el resultado de un cómputo depende del orden relativo en que dos o más flujos acceden a un dato compartido, y al menos uno de esos accesos es una escritura.

El caso más pequeño posible es un contador.

/* contador_roto.c — compilar: gcc -O0 -pthread contador_roto.c -o contador_roto */
#include <pthread.h>
#include <stdio.h>

#define VUELTAS 1000000

static long contador = 0;

static void *sumar(void *arg)
{
    (void) arg;
    for (int i = 0; i < VUELTAS; i++) {
        contador = contador + 1;
    }
    return NULL;
}

int main(void)
{
    pthread_t h1, h2;

    pthread_create(&h1, NULL, sumar, NULL);
    pthread_create(&h2, NULL, sumar, NULL);
    pthread_join(h1, NULL);
    pthread_join(h2, NULL);

    printf("Esperado: %d\n", 2 * VUELTAS);
    printf("Obtenido: %ld\n", contador);
    printf("Perdidos: %ld\n", (long) (2 * VUELTAS) - contador);
    return 0;
}

El valor esperado es 2 000 000. En una máquina multinúcleo el resultado obtenido suele estar entre 1 000 000 y 2 000 000, y cambia en cada ejecución. Se compila con -O0 a propósito: con optimizaciones el compilador puede mantener el contador en un registro y el efecto se altera.

Qué instrucciones hay realmente debajo

En una arquitectura x86-64, la línea contador = contador + 1 se traduce aproximadamente a tres instrucciones:

mov    contador(%rip), %rax    ; LEER:     copiar memoria al registro
add    $1, %rax                ; MODIFICAR: sumar 1 en el registro
mov    %rax, contador(%rip)    ; ESCRIBIR: copiar el registro a memoria

Este patrón se llama leer-modificar-escribir, y es el corazón de casi toda condición de carrera. El problema es que entre la lectura y la escritura hay una ventana en la que el valor en memoria puede cambiar sin que este hilo se entere.

sequenceDiagram
    participant A as Hilo A
    participant M as Memoria — contador
    participant B as Hilo B

    Note over M: contador = 41
    A->>M: LEER
    M-->>A: 41
    Note over A: registro A = 41
    Note over A,B: cambio de contexto
    B->>M: LEER
    M-->>B: 41
    Note over B: registro B = 41
    Note over B: SUMAR → registro B = 42
    B->>M: ESCRIBIR 42
    Note over M: contador = 42
    Note over A,B: cambio de contexto
    Note over A: SUMAR → registro A = 42
    A->>M: ESCRIBIR 42
    Note over M: contador = 42 — se perdió un incremento

Dos incrementos ejecutados, un solo incremento visible. Esa es la actualización perdida, y es la forma más común de condición de carrera.

La variante verificar-y-actuar

La otra forma común no pierde datos: rompe una condición. El patrón es leer un valor, decidir algo en función de él y actuar suponiendo que el valor sigue siendo el mismo.

/* banco_roto.c — compilar: gcc -O0 -pthread banco_roto.c -o banco_roto */
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>

static int fondos = 100;

static void *retirar(void *arg)
{
    int monto = *(int *) arg;

    if (monto <= fondos) {            /* VERIFICAR */
        usleep(1000);                 /* ventana: el planificador puede intervenir */
        fondos = fondos - monto;      /* ACTUAR */
        printf("retira %d, saldo queda en %d\n", monto, fondos);
    } else {
        printf("rechaza %d: fondos insuficientes\n", monto);
    }
    return NULL;
}

int main(void)
{
    pthread_t h1, h2;
    int ana = 80, beto = 70;

    pthread_create(&h1, NULL, retirar, &ana);
    pthread_create(&h2, NULL, retirar, &beto);
    pthread_join(h1, NULL);
    pthread_join(h2, NULL);

    printf("Saldo final: %d\n", fondos);   /* -50: estado declarado imposible */
    return 0;
}

Ambos hilos verifican contra un saldo de 100, ambos pasan la verificación, ambos restan. El saldo final es -50, un estado que el programa declara imposible. El usleep no crea el error: sólo agranda una ventana que ya existía y que en producción se abre unas pocas veces al día.

El invariante violado es fondos >= 0. Y hay que decirlo con precisión: el invariante sí se cumple al inicio y sí se cumpliría si cada retiro se ejecutara completo antes del siguiente. Lo que lo rompe es el intercalado.

El mismo error sin memoria compartida

Podría parecer que este problema es exclusivo de lenguajes con memoria compartida. No lo es: reaparece en cualquier modelo donde el estado se consulte y se modifique en dos pasos separados. En Elixir, donde los procesos no comparten memoria, el error se traslada al protocolo de mensajes.

# banco_roto.exs — ejecutar: elixir banco_roto.exs
{:ok, cuenta} = Agent.start_link(fn -> 100 end)

retirar = fn quien, monto ->
  saldo = Agent.get(cuenta, & &1)          # mensaje 1: VERIFICAR
  Process.sleep(50)                        # ventana entre los dos mensajes

  if monto <= saldo do
    Agent.update(cuenta, &(&1 - monto))    # mensaje 2: ACTUAR
    IO.puts("#{quien} retira #{monto}")
  else
    IO.puts("#{quien} rechazado: fondos insuficientes")
  end
end

[
  Task.async(fn -> retirar.("Ana", 80) end),
  Task.async(fn -> retirar.("Beto", 70) end)
]
|> Enum.each(&Task.await/1)

IO.puts("Saldo final: #{Agent.get(cuenta, & &1)}")

Salida típica: ambos retiran y el saldo final es -50. El Agent serializa cada mensaje individualmente, pero nada serializa la pareja de mensajes. La corrección consiste en convertir verificar-y-actuar en una sola operación:

# banco_correcto.exs — ejecutar: elixir banco_correcto.exs
{:ok, cuenta} = Agent.start_link(fn -> 100 end)

retirar = fn quien, monto ->
  # Un solo mensaje: la decisión y la actualización viajan juntas.
  resultado =
    Agent.get_and_update(cuenta, fn saldo ->
      if monto <= saldo,
        do: {{:ok, saldo - monto}, saldo - monto},
        else: {{:rechazado, saldo}, saldo}
    end)

  case resultado do
    {:ok, nuevo} -> IO.puts("#{quien} retira #{monto}, saldo queda en #{nuevo}")
    {:rechazado, s} -> IO.puts("#{quien} rechazado: saldo #{s} < #{monto}")
  end
end

[Task.async(fn -> retirar.("Ana", 80) end), Task.async(fn -> retirar.("Beto", 70) end)]
|> Enum.each(&Task.await/1)

IO.puts("Saldo final: #{Agent.get(cuenta, & &1)}")

Ahora la decisión y la modificación viajan en el mismo mensaje y el proceso del Agent las ejecuta sin interrupción. El saldo final es 20 y el segundo retiro se rechaza. Esta es la lección general del capítulo, adelantada: la solución nunca es “hacer la ventana más pequeña”; es eliminar la ventana.

Sección crítica

El fragmento de código que accede a un recurso compartido y que no admite intercalado se llama sección crítica. No es una propiedad del recurso: es una propiedad del código que lo usa. El mismo arreglo puede tener secciones críticas en unos accesos y no en otros.

Todo hilo que use un recurso compartido tiene una estructura de cuatro partes:

stateDiagram-v2
    [*] --> Resto
    Resto: Sección restante — trabajo que no toca el recurso compartido
    Entrada: Protocolo de entrada — pedir permiso
    Critica: Sección crítica — acceso exclusivo al recurso
    Salida: Protocolo de salida — devolver el permiso

    Resto --> Entrada: necesita el recurso
    Entrada --> Entrada: permiso denegado, espera
    Entrada --> Critica: permiso concedido
    Critica --> Salida: terminó el acceso
    Salida --> Resto: recurso liberado

Todo el problema de la sincronización consiste en diseñar los protocolos de entrada y salida. La sección crítica y la sección restante las escribe el programador de la aplicación; los protocolos los aporta el sistema operativo, la biblioteca de hilos o el lenguaje.

Los cuatro requisitos de una solución correcta

Una solución al problema de la sección crítica se considera correcta si cumple los cuatro requisitos siguientes. Los tres primeros los formuló Dijkstra; el cuarto es una condición sobre el entorno de ejecución.

RequisitoEnunciadoQué falla si no se cumple
Exclusión mutuaA lo sumo un hilo dentro de la sección crítica en cada instanteCondiciones de carrera: el problema original queda sin resolver
ProgresoSi la sección crítica está libre y hay hilos queriendo entrar, la decisión de quién entra no puede postergarse indefinidamente, y sólo participan en ella los hilos que quieren entrarInterbloqueo: nadie entra aunque el recurso está libre
Espera limitadaExiste una cota al número de veces que otros hilos pueden entrar después de que un hilo pidió entrar y antes de que se le concedaInanición: un hilo espera para siempre mientras otros pasan
Sin suposiciones de velocidadLa solución no depende del número de procesadores ni de la velocidad relativa de los hilosLa solución funciona en el laboratorio y falla en producción

El requisito de progreso tiene una cláusula que se pasa por alto: sólo participan en la decisión los hilos que quieren entrar. Un hilo que está en su sección restante, o que terminó, no puede tener voto sobre quién entra. El primer intento fallido que veremos incumple exactamente esa cláusula.

Exclusión mutua: intentos por software

La pregunta histórica es si se puede resolver la sección crítica usando sólo lecturas y escrituras de memoria compartida, sin ninguna instrucción especial del procesador. La respuesta es sí, pero el camino hasta ahí pasa por varios intentos que fallan de maneras instructivas. Suponemos dos hilos, identificados como i y j, donde j = 1 - i.

Intento 1: turno estricto

La idea más directa: una variable compartida indica de quién es el turno.

/* Intento 1 — pseudocódigo en C, hilo i. Variable compartida: turno */
int turno = 0;

while (turno != i) { /* entrada: espera activa */ }
/* SECCIÓN CRÍTICA */
turno = j;                     /* salida */

Cumple exclusión mutua: turno tiene un solo valor, así que sólo un hilo puede pasar el while.

Incumple progreso. Si el hilo 0 sale de su sección crítica, pone turno = 1 y se dedica a su sección restante durante una hora, el hilo 1 puede entrar una vez, poner turno = 0 y quedar bloqueado indefinidamente aunque la sección crítica esté libre y el hilo 0 no la quiera. Un hilo que no está interesado está decidiendo por otro.

Además fuerza una alternancia estricta: si el hilo 0 necesita entrar diez veces y el hilo 1 sólo una, el hilo 0 no puede completar sus diez entradas.

Intento 2: banderas de interés

Se cambia el enfoque: cada hilo declara si quiere entrar.

/* Intento 2 — hilo i. Variable compartida: interes */
bool interes[2] = { false, false };

while (interes[j]) { /* entrada: espera a que el otro salga */ }
interes[i] = true;
/* SECCIÓN CRÍTICA */
interes[i] = false;            /* salida */

Incumple exclusión mutua, que es el requisito más importante. Basta este intercalado:

  1. El hilo 0 evalúa interes[1], que es false, y sale del bucle.
  2. Cambio de contexto antes de que el hilo 0 escriba su bandera.
  3. El hilo 1 evalúa interes[0], que sigue siendo false, y sale del bucle.
  4. Ambos ponen su bandera en true y ambos entran a la sección crítica.

La ventana entre “verificar” y “declarar” es la misma condición de carrera que se intentaba resolver.

Intento 3: declarar antes de verificar

La corrección obvia es invertir el orden: primero declarar el interés, después mirar el del otro.

/* Intento 3 — hilo i. Variable compartida: interes */
bool interes[2] = { false, false };

interes[i] = true;             /* entrada: declarar antes de mirar */
while (interes[j]) { /* espera */ }
/* SECCIÓN CRÍTICA */
interes[i] = false;            /* salida */

Cumple exclusión mutua: para que el hilo i entre, interes[j] tuvo que ser falso después de que interes[i] fuera verdadero, y eso no puede darse simétricamente.

Incumple progreso, por interbloqueo. Si ambos hilos ejecutan interes[i] = true antes de que cualquiera llegue al while, ambos ven la bandera del otro levantada y ambos esperan para siempre. Ninguno la bajará, porque bajarla ocurre después de la sección crítica, a la que nunca entrarán.

Es el interbloqueo en su forma más pura: cada uno retiene lo que el otro necesita y espera lo que el otro retiene.

Intento 4: ceder con espera aleatoria

Para romper el interbloqueo anterior, se agrega la posibilidad de ceder: si el otro está interesado, bajar la propia bandera, esperar un rato y volver a intentarlo.

/* Intento 4 — hilo i. Variable compartida: interes */
bool interes[2] = { false, false };

interes[i] = true;             /* entrada */
while (interes[j]) {
    interes[i] = false;        /* cedo el paso */
    esperar(aleatorio());
    interes[i] = true;         /* y lo vuelvo a intentar */
}
/* SECCIÓN CRÍTICA */
interes[i] = false;            /* salida */

Cumple exclusión mutua y no produce interbloqueo permanente, porque la aleatoriedad rompe la simetría con probabilidad 1.

Introduce en cambio un bloqueo vivo (livelock): ambos hilos pueden entrar en un patrón de cortesía mutua donde suben y bajan banderas indefinidamente sin que ninguno entre. Los hilos no están detenidos —consumen procesador, cambian de estado, avanzan instrucciones— pero el trabajo útil es nulo. Formalmente no hay garantía de progreso en tiempo acotado: sólo una garantía probabilística.

Este intento también deja abierta la inanición: si un hilo tiene sistemáticamente peor suerte con los tiempos aleatorios, puede quedar postergado indefinidamente.

flowchart TD
    I1["Intento 1<br/>Turno estricto"] -->|"un hilo no interesado<br/>bloquea al otro"| F1["Falla: progreso"]
    I2["Intento 2<br/>Verificar y luego declarar"] -->|"ventana entre<br/>verificar y declarar"| F2["Falla: exclusión mutua"]
    I3["Intento 3<br/>Declarar y luego verificar"] -->|"ambas banderas<br/>arriba a la vez"| F3["Falla: interbloqueo"]
    I4["Intento 4<br/>Ceder con espera aleatoria"] -->|"cortesía mutua<br/>sin avance"| F4["Falla: bloqueo vivo e inanición"]

    F1 --> SOL["Peterson<br/>banderas + turno como desempate"]
    F2 --> SOL
    F3 --> SOL
    F4 --> SOL
    SOL --> OK["Cumple los cuatro requisitos<br/>para dos hilos"]
    SOL --> PAN["Panadería de Lamport<br/>generalización a N hilos"]

    style F1 fill:#5f1e1e,color:#fff
    style F2 fill:#5f1e1e,color:#fff
    style F3 fill:#5f1e1e,color:#fff
    style F4 fill:#5f1e1e,color:#fff
    style OK fill:#1e5f3a,color:#fff
    style PAN fill:#1e5f3a,color:#fff

Algoritmo de Dekker

Dekker fue el primero en resolverlo, en 1962. Combina las banderas del intento 3 con la variable de turno del intento 1, pero usando el turno sólo como desempate cuando ambos hilos están interesados.

/* Dekker — hilo i, con j = 1 - i */
bool interes[2] = { false, false };
int  turno = 0;

interes[i] = true;                    /* entrada */
while (interes[j]) {
    if (turno != i) {                 /* el turno es del otro: cedo de verdad */
        interes[i] = false;
        while (turno != i) { /* espero hasta que me toque */ }
        interes[i] = true;
    }
}
/* SECCIÓN CRÍTICA */
turno = j;                            /* salida */
interes[i] = false;

La diferencia con el intento 4 es que el hilo que cede no espera un tiempo aleatorio: espera hasta que el turno sea suyo, y el turno sólo cambia cuando el otro sale de la sección crítica. Eso convierte el bloqueo vivo en una espera con final garantizado. Cumple los cuatro requisitos, pero el protocolo de entrada tiene dos bucles anidados y su corrección no es evidente a simple vista.

Algoritmo de Peterson

En 1981 Peterson publicó una versión de una décima parte del tamaño. La idea es que el hilo cede el turno al otro inmediatamente después de declarar su interés: se declara interesado y a la vez declara que si hay empate, gana el otro.

/* peterson.c — compilar: gcc -O2 -pthread peterson.c -o peterson */
#include <pthread.h>
#include <stdatomic.h>
#include <stdbool.h>
#include <stdio.h>

#define VUELTAS 200000

static atomic_bool interes[2];
static atomic_int  turno;
static long        contador = 0;   /* protegido por el algoritmo */

static void *tarea(void *arg)
{
    int yo = *(int *) arg, otro = 1 - yo;

    for (int k = 0; k < VUELTAS; k++) {
        atomic_store(&interes[yo], true);          /* entrada: quiero entrar */
        atomic_store(&turno, otro);                /* si hay empate, gana el otro */
        while (atomic_load(&interes[otro]) && atomic_load(&turno) == otro) { }

        contador = contador + 1;                   /* sección crítica */

        atomic_store(&interes[yo], false);         /* salida */
    }
    return NULL;
}

int main(void)
{
    pthread_t h[2];
    int ids[2] = { 0, 1 };

    atomic_init(&interes[0], false);
    atomic_init(&interes[1], false);
    atomic_init(&turno, 0);

    pthread_create(&h[0], NULL, tarea, &ids[0]);
    pthread_create(&h[1], NULL, tarea, &ids[1]);
    pthread_join(h[0], NULL);
    pthread_join(h[1], NULL);

    printf("Esperado: %d\nObtenido: %ld\n", 2 * VUELTAS, contador);
    return 0;
}

La condición de espera es interes[otro] && turno == otro: el hilo espera sólo si el otro quiere entrar y además el turno es del otro. Si los dos declaran interés a la vez, los dos escriben turno, pero la escritura que queda es una sola, y esa escritura decide quién espera. La asimetría la produce la memoria, no la suerte.

Sobre stdatomic.h: el algoritmo de Peterson en su formulación original supone que las lecturas y escrituras de memoria se observan en el orden en que se escribieron. Los procesadores modernos y los compiladores optimizadores no garantizan eso: pueden reordenar una escritura respecto de una lectura posterior a otra dirección. Usar atomic_store y atomic_load, que por omisión tienen consistencia secuencial, es lo que hace que el programa funcione en hardware real. Escrito con bool y int corrientes, el programa puede fallar aunque el algoritmo sea correcto sobre el papel. Este punto es importante y se retoma más abajo.

Algoritmo de la panadería de Lamport

Peterson resuelve el caso de dos hilos. Para N hilos, Lamport propuso en 1974 una solución con la metáfora de la panadería: al entrar, cada cliente toma un número, y se atiende en orden creciente.

/* Panadería — pseudocódigo para N hilos, hilo i */
bool eligiendo[N] = { false };   /* está calculando su número */
int  numero[N]    = { 0 };       /* 0 significa "no quiere entrar" */

eligiendo[i] = true;                                   /* entrada */
numero[i] = 1 + maximo_de(numero, N);
eligiendo[i] = false;

for (int j = 0; j < N; j++) {
    while (eligiendo[j]) { /* espera a que j publique su número */ }
    /* espera a que j pase primero si su par (numero, id) es menor */
    while (numero[j] != 0 &&
           (numero[j] < numero[i] || (numero[j] == numero[i] && j < i))) { }
}
/* SECCIÓN CRÍTICA */
numero[i] = 0;                                         /* salida */

Dos detalles hacen que funcione:

  • El cálculo del máximo no es atómico, así que dos hilos pueden obtener el mismo número. El desempate es por identificador: el par (numero[i], i) sí es único, y la comparación lexicográfica sobre ese par define un orden total.
  • El arreglo eligiendo cubre la ventana en la que un hilo ya empezó a calcular su número pero todavía no lo publicó. Sin él, otro hilo podría ver numero[j] == 0 y adelantarse indebidamente.

La espera limitada se cumple con una cota clara: un hilo con número n espera a lo sumo a los hilos que tomaron número antes que él. Nadie que llegue después puede adelantarlo, porque tomará un número mayor.

Comparación de las soluciones por software

AlgoritmoHilos soportadosExclusión mutuaProgresoEspera limitadaComplejidad del protocolo
Turno estricto2NoNo aplicaTrivial
Banderas, verificar primero2NoNoTrivial
Banderas, declarar primero2No (interbloqueo)No aplicaTrivial
Ceder aleatoriamente2Sólo probabilísticoNoBaja
Dekker2Media, bucles anidados
Peterson2 (extensible con filtro)Baja
Panadería de LamportNSí, cota explícitaMedia, O(N) por entrada

Por qué en la práctica se usa hardware

Todas las soluciones anteriores comparten dos costos:

Espera activa. El hilo que no puede entrar quema ciclos de procesador en un bucle. En un sistema con un solo núcleo esto es especialmente costoso: el hilo que espera consume su cuanto completo impidiendo que corra el hilo que está dentro de la sección crítica y que es el único que puede liberarla. Este caso concreto tiene nombre: inversión de prioridad por espera activa.

Orden de memoria. Como se vio en Peterson, los procesadores fuera de orden y los compiladores optimizadores rompen las suposiciones sobre el orden de las operaciones de memoria.

Por eso todos los procesadores modernos ofrecen instrucciones atómicas de leer-modificar-escribir, que la memoria ejecuta como una sola operación indivisible. Las dos familias principales son:

  • Test-and-set: escribe un valor y devuelve el anterior, en una sola operación. En x86 corresponde a xchg; en C11 se expone como atomic_flag_test_and_set.
  • Compare-and-swap: compara el contenido con un valor esperado y, sólo si coincide, escribe uno nuevo; devuelve si tuvo éxito. En x86 corresponde a lock cmpxchg; en C11 se expone como atomic_compare_exchange_strong.
/* cerrojo_tas.c — cerrojo con test-and-set
   compilar: gcc -O2 -pthread cerrojo_tas.c -o cerrojo_tas */
#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>

#define VUELTAS 200000

static atomic_flag cerrojo = ATOMIC_FLAG_INIT;
static long contador = 0;

/* test-and-set devuelve el valor previo: si era false, el cerrojo es nuestro */
static void tomar(void)  { while (atomic_flag_test_and_set(&cerrojo)) { } }
static void soltar(void) { atomic_flag_clear(&cerrojo); }

static void *tarea(void *arg)
{
    (void) arg;
    for (int k = 0; k < VUELTAS; k++) {
        tomar();
        contador = contador + 1;   /* sección crítica */
        soltar();
    }
    return NULL;
}

int main(void)   /* cuatro hilos sobre el mismo contador */
{
    pthread_t h[4];
    for (int i = 0; i < 4; i++) pthread_create(&h[i], NULL, tarea, NULL);
    for (int i = 0; i < 4; i++) pthread_join(h[i], NULL);
    printf("Esperado: %d\nObtenido: %ld\n", 4 * VUELTAS, contador);
    return 0;
}

Este cerrojo cumple exclusión mutua y progreso para cualquier número de hilos, en cuatro líneas. No cumple espera limitada: nada impide que un hilo con mala suerte pierda la carrera del test-and-set indefinidamente. Y sigue haciendo espera activa.

El paso siguiente —convertir la espera activa en bloqueo real, con el hilo fuera de la cola de listos— es el trabajo del sistema operativo mediante semáforos y mutex, y es el tema del capítulo 8.

Interbloqueo, bloqueo vivo e inanición

Los tres nombres describen situaciones donde el trabajo útil se detiene, pero por causas distintas. Distinguirlos es lo que permite elegir la corrección adecuada.

FenómenoEstado de los hilos¿Consume procesador?Causa¿Se resuelve solo?
Interbloqueo (deadlock)BloqueadosNoEspera circular de recursosNunca
Bloqueo vivo (livelock)Ejecutables, cambiando de estadoReacción mutua que impide avanzarSólo por azar, sin garantía
Inanición (starvation)Ejecutable, pero postergadoSí, sin avanzarPolítica de asignación injustaSólo si cambia la carga

Una analogía que ayuda: el interbloqueo es un cruce de calles donde cuatro autos se bloquean mutuamente y ninguno se mueve; el bloqueo vivo es dos personas en un pasillo que se esquivan hacia el mismo lado una y otra vez; la inanición es esperar en una fila donde siempre llega alguien con prioridad y te adelanta.

Las cuatro condiciones de Coffman

Coffman, Elphick y Shoshani caracterizaron en 1971 el interbloqueo con cuatro condiciones que deben cumplirse todas a la vez. Si falta una sola, no hay interbloqueo. Esto es lo que las hace útiles: dan cuatro puntos de ataque.

CondiciónEnunciadoCómo se rompeCosto de romperla
Exclusión mutuaEl recurso sólo puede ser usado por un hilo a la vezHacer el recurso compartible o virtualizarlo (spooling de impresora, copias de sólo lectura)No aplica a recursos intrínsecamente exclusivos
Retención y esperaUn hilo retiene recursos mientras pide otrosPedir todos los recursos de una vez al inicio, o soltar todo antes de pedir másBaja utilización: se reservan recursos que quizá no se usen
No apropiaciónUn recurso no puede quitarse por la fuerza a quien lo tienePermitir revocar el recurso y reintentar la operaciónExige poder deshacer trabajo parcial
Espera circularExiste una cadena cerrada donde cada hilo espera un recurso del siguienteImponer un orden total sobre los recursos y exigir que se pidan en ese ordenRequiere conocer todos los recursos de antemano

En la práctica, la técnica más usada es romper la espera circular con un orden total de adquisición, porque no reduce la utilización y no exige deshacer trabajo. Es la que aplicaremos a los filósofos comensales.

Interbloqueo en código real

/* interbloqueo.c — compilar: gcc -O2 -pthread interbloqueo.c -o interbloqueo
   Este programa se cuelga con alta probabilidad. Interrumpir con Ctrl-C. */
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>

static pthread_mutex_t cuenta_a = PTHREAD_MUTEX_INITIALIZER;
static pthread_mutex_t cuenta_b = PTHREAD_MUTEX_INITIALIZER;

static void *transferir_a_b(void *arg)   /* toma A, luego pide B */
{
    (void) arg;
    for (int i = 0; i < 100000; i++) {
        pthread_mutex_lock(&cuenta_a);
        usleep(1);
        pthread_mutex_lock(&cuenta_b);
        pthread_mutex_unlock(&cuenta_b);
        pthread_mutex_unlock(&cuenta_a);
    }
    return NULL;
}

static void *transferir_b_a(void *arg)   /* toma B, luego pide A */
{
    (void) arg;
    for (int i = 0; i < 100000; i++) {
        pthread_mutex_lock(&cuenta_b);
        usleep(1);
        pthread_mutex_lock(&cuenta_a);
        pthread_mutex_unlock(&cuenta_a);
        pthread_mutex_unlock(&cuenta_b);
    }
    return NULL;
}

int main(void)
{
    pthread_t h1, h2;
    pthread_create(&h1, NULL, transferir_a_b, NULL);
    pthread_create(&h2, NULL, transferir_b_a, NULL);
    pthread_join(h1, NULL);
    pthread_join(h2, NULL);
    puts("Terminó sin interbloqueo (poco probable)");
    return 0;
}

El grafo de asignación de recursos deja el ciclo a la vista.

flowchart LR
    H1(["Hilo 1"])
    H2(["Hilo 2"])
    RA["Recurso A<br/>mutex cuenta_a"]
    RB["Recurso B<br/>mutex cuenta_b"]

    RA -->|"asignado a"| H1
    H1 -->|"solicita"| RB
    RB -->|"asignado a"| H2
    H2 -->|"solicita"| RA

    style H1 fill:#1e3a5f,color:#fff
    style H2 fill:#1e3a5f,color:#fff
    style RA fill:#5f1e1e,color:#fff
    style RB fill:#5f1e1e,color:#fff

Con una instancia por recurso, un ciclo en este grafo es condición necesaria y suficiente de interbloqueo. La corrección es imponer un orden y respetarlo en ambas funciones:

/* Corrección: se define el orden A < B y ambos hilos piden siempre A antes que B,
   aunque la lógica de negocio del segundo hilo sea "de B hacia A". */
static void *transferir_b_a_ordenado(void *arg)
{
    (void) arg;
    for (int i = 0; i < 100000; i++) {
        pthread_mutex_lock(&cuenta_a);
        usleep(1);
        pthread_mutex_lock(&cuenta_b);
        pthread_mutex_unlock(&cuenta_b);
        pthread_mutex_unlock(&cuenta_a);
    }
    return NULL;
}

Con este cambio, el ciclo del grafo es imposible: nadie puede tener B y pedir A, porque A siempre se pide primero.

Estrategias frente al interbloqueo

Existen cuatro posturas, y los sistemas reales combinan varias.

EstrategiaEn qué consisteDónde se usa
PrevenciónDiseñar el sistema para que alguna condición de Coffman nunca se cumplaOrden de adquisición de cerrojos en el kernel de Linux, documentado por subsistema
EvitaciónAnalizar cada solicitud y concederla sólo si el sistema queda en estado seguro, según necesidades máximas declaradasRara en propósito general: exige declarar por adelantado el uso máximo de recursos
Detección y recuperaciónPermitir el interbloqueo, detectar ciclos periódicamente y romperlos abortando o revirtiendoGestores de bases de datos: detectan el ciclo y abortan la transacción más barata de deshacer
IgnorarSuponer que el interbloqueo es raro y dejar que el operador reinicieSistemas de propósito general para recursos donde el costo del control supera al del incidente

Los tres problemas clásicos

Estos tres problemas se estudian porque cada uno aísla un patrón de sincronización distinto, y porque casi todo problema real es una variante de alguno.

ProblemaPatrón que aíslaRiesgo principalAparece en
Productor-consumidorSincronización por disponibilidad de datos y de espacioSobreescritura, lectura de vacío, espera activaColas de trabajo, tuberías, buffers de E/S
Lectores-escritoresExclusión asimétrica: muchos leen, uno escribeInanición de escritores o de lectoresCachés, índices, tablas de configuración
Filósofos comensalesAdquisición de múltiples recursos con solapamientoInterbloqueo por espera circularTransferencias entre cuentas, bloqueo de filas en bases de datos

Productor-consumidor con buffer acotado

Un conjunto de hilos produce elementos y los deposita en un buffer de capacidad fija; otro conjunto los retira y los procesa. Hay tres condiciones que coordinar:

  1. Exclusión mutua sobre la estructura del buffer, para que dos operaciones no corrompan los índices.
  2. El consumidor no puede retirar de un buffer vacío: debe esperar.
  3. El productor no puede depositar en un buffer lleno: debe esperar.

Las dos últimas no son exclusión mutua sino sincronización condicional: esperar a que se cumpla un predicado sobre el estado compartido.

sequenceDiagram
    participant P as Productor
    participant B as Buffer — capacidad 3
    participant C as Consumidor

    Note over B: [ _ _ _ ] cantidad = 0
    C->>B: Retirar
    Note over C: barrera cantidad > 0 falsa → queda en cola
    P->>B: Depositar 10
    Note over B: [10 _ _ ] cantidad = 1
    B-->>C: barrera se abre, entrega 10
    Note over B: [ _ _ _ ] cantidad = 0
    P->>B: Depositar 20
    P->>B: Depositar 30
    P->>B: Depositar 40
    Note over B: [20 30 40] cantidad = 3, lleno
    P->>B: Depositar 50
    Note over P: barrera cantidad < 3 falsa → queda en cola
    C->>B: Retirar
    B-->>C: entrega 20
    Note over B: barrera se abre, entra el depósito pendiente
    Note over B: [30 40 50] cantidad = 3

Ada resuelve esto con objetos protegidos: una construcción del lenguaje que combina exclusión mutua automática con barreras condicionales en las entradas. Una entrada sólo se ejecuta cuando su barrera es verdadera; mientras tanto, la tarea queda encolada sin consumir procesador.

--  productor_consumidor.adb
--  compilar y ejecutar:  gnatmake productor_consumidor.adb && ./productor_consumidor
with Ada.Text_IO; use Ada.Text_IO;

procedure Productor_Consumidor is

   type Vector_Enteros is array (Natural range <>) of Integer;

   --------------------------------------------------------------------
   --  Buffer circular acotado. Las barreras "when" son la
   --  sincronizacion condicional; la exclusion mutua la garantiza
   --  el propio objeto protegido.
   --------------------------------------------------------------------
   protected type Buffer_Acotado (Capacidad : Positive) is
      entry Depositar (Item : in  Integer);
      entry Retirar   (Item : out Integer);
   private
      Datos    : Vector_Enteros (0 .. Capacidad - 1);
      Cabeza   : Natural := 0;
      Cola     : Natural := 0;
      Cantidad : Natural := 0;
   end Buffer_Acotado;

   protected body Buffer_Acotado is

      entry Depositar (Item : in Integer) when Cantidad < Capacidad is
      begin
         Datos (Cola) := Item;
         Cola     := (Cola + 1) mod Capacidad;
         Cantidad := Cantidad + 1;
      end Depositar;

      entry Retirar (Item : out Integer) when Cantidad > 0 is
      begin
         Item     := Datos (Cabeza);
         Cabeza   := (Cabeza + 1) mod Capacidad;
         Cantidad := Cantidad - 1;
      end Retirar;

   end Buffer_Acotado;

   Buffer : Buffer_Acotado (Capacidad => 3);

   Total_Items : constant := 12;

   task Productor;
   task Consumidor;

   task body Productor is
   begin
      for I in 1 .. Total_Items loop
         Buffer.Depositar (I * 10);
         Put_Line ("Produce  ->" & Integer'Image (I * 10));
      end loop;
   end Productor;

   task body Consumidor is
      Item : Integer;
   begin
      for I in 1 .. Total_Items loop
         Buffer.Retirar (Item);
         Put_Line ("   Consume <-" & Integer'Image (Item));
         delay 0.05;   --  el consumidor es mas lento: el buffer se llena
      end loop;
   end Consumidor;

begin
   null;   --  el procedimiento espera a que ambas tareas terminen
end Productor_Consumidor;

Puntos a observar en este programa:

  • No aparece ningún cerrojo explícito. El objeto protegido garantiza que a lo sumo una tarea ejecute una de sus operaciones a la vez.
  • when Cantidad < Capacidad y when Cantidad > 0 son barreras: se evalúan al llegar y cada vez que el estado del objeto cambia. Si son falsas, la tarea que llama se encola sin ejecutar espera activa.
  • El productor es más rápido que el consumidor, así que el buffer se llena y el productor se bloquea. Ese bloqueo es la contrapresión que evita crecimiento ilimitado de memoria.
  • Ada.Text_IO no está garantizado como reentrante por el estándar; en GNAT lo es. Para código portable, la salida se centraliza en una única tarea.

En Elixir el mismo problema se resuelve sin memoria compartida: el buffer es un proceso y las dos condiciones se expresan con el par {:noreply, estado} más GenServer.reply/2. Cuando un consumidor pide un dato y el buffer está vacío, el servidor no responde: guarda la identidad del solicitante en una cola interna y devuelve {:noreply, estado}. Como GenServer.call deja al llamante bloqueado esperando la respuesta, el efecto es idéntico al de una barrera de Ada, construido sólo con mensajes. Cuando llega un depósito, el servidor saca al primer consumidor de la cola y le responde con GenServer.reply/2. El caso simétrico —productor bloqueado con el buffer lleno— usa exactamente el mismo mecanismo. Este patrón aparece completo en el código de los filósofos comensales, más adelante en este mismo capítulo.

Lectores-escritores

Un dato compartido admite muchos lectores simultáneos, porque leer no modifica nada, pero un escritor necesita acceso exclusivo. Formalmente:

  • Pueden coexistir N lectores.
  • Un escritor excluye a todos los lectores y a los demás escritores.

La dificultad está en la política, no en el mecanismo. Hay tres variantes clásicas:

VarianteReglaConsecuencia
Prioridad a lectoresUn lector entra si no hay escritor escribiendo, aunque haya escritores esperandoCon lectores frecuentes, los escritores nunca entran: inanición de escritores
Prioridad a escritoresUn lector espera si hay algún escritor esperandoCon escritores frecuentes, los lectores se postergan: inanición de lectores
Sin inaniciónSe respeta el orden de llegada entre gruposMenor concurrencia de lectura, pero espera limitada para todos

La segunda variante se implementa en Ada de manera muy directa, porque el lenguaje permite consultar el número de tareas encoladas en una entrada mediante el atributo 'Count.

--  lectores_escritores.adb
--  compilar y ejecutar:  gnatmake lectores_escritores.adb && ./lectores_escritores
with Ada.Text_IO; use Ada.Text_IO;

procedure Lectores_Escritores is

   --------------------------------------------------------------------
   --  Control de acceso con prioridad a escritores.
   --  Iniciar_Lectura sólo abre si no hay escritor activo Y no hay
   --  escritores encolados: eso impide la inanición de escritores.
   --------------------------------------------------------------------
   protected Control is
      entry     Iniciar_Lectura;
      procedure Terminar_Lectura;
      entry     Iniciar_Escritura;
      procedure Terminar_Escritura;
      function  Lectores_Activos return Natural;
   private
      Lectores    : Natural := 0;
      Escribiendo : Boolean := False;
   end Control;

   protected body Control is

      entry Iniciar_Lectura
        when not Escribiendo and Iniciar_Escritura'Count = 0 is
      begin
         Lectores := Lectores + 1;
      end Iniciar_Lectura;

      procedure Terminar_Lectura is
      begin
         Lectores := Lectores - 1;
      end Terminar_Lectura;

      entry Iniciar_Escritura when not Escribiendo and Lectores = 0 is
      begin
         Escribiendo := True;
      end Iniciar_Escritura;

      procedure Terminar_Escritura is
      begin
         Escribiendo := False;
      end Terminar_Escritura;

      function Lectores_Activos return Natural is
      begin
         return Lectores;
      end Lectores_Activos;

   end Control;

   Dato : Integer := 0;   --  recurso compartido protegido por Control

   task type Lector (Id : Positive; Vueltas : Positive);
   task type Escritor (Id : Positive; Vueltas : Positive);

   task body Lector is
      Copia : Integer;
   begin
      for K in 1 .. Vueltas loop
         Control.Iniciar_Lectura;
         Copia := Dato;
         Put_Line ("Lector" & Positive'Image (Id) & " lee" & Integer'Image (Copia)
                   & " (lectores activos:"
                   & Natural'Image (Control.Lectores_Activos) & " )");
         delay 0.02;
         Control.Terminar_Lectura;
         delay 0.03;
      end loop;
   end Lector;

   task body Escritor is
   begin
      for K in 1 .. Vueltas loop
         Control.Iniciar_Escritura;
         Dato := Dato + 1;
         Put_Line ("ESCRITOR" & Positive'Image (Id) & " escribe" & Integer'Image (Dato));
         delay 0.05;
         Control.Terminar_Escritura;
         delay 0.10;
      end loop;
   end Escritor;

begin
   declare
      L1 : Lector   (Id => 1, Vueltas => 6);
      L2 : Lector   (Id => 2, Vueltas => 6);
      L3 : Lector   (Id => 3, Vueltas => 6);
      E1 : Escritor (Id => 1, Vueltas => 3);
      E2 : Escritor (Id => 2, Vueltas => 3);
   begin
      null;   --  el bloque espera a que todas las tareas terminen
   end;
   Put_Line ("Valor final del dato:" & Integer'Image (Dato));
end Lectores_Escritores;

La línea decisiva es la barrera de Iniciar_Lectura:

when not Escribiendo and Iniciar_Escritura'Count = 0

Iniciar_Escritura'Count es el número de tareas encoladas esperando en esa entrada. Al incluirlo en la barrera de lectura, un lector nuevo no entra si hay algún escritor esperando, aunque en ese momento haya otros lectores dentro. Los lectores ya admitidos terminan, el contador llega a cero, la barrera del escritor se abre y el escritor pasa. La inanición de escritores queda descartada por construcción.

Sin ese término, la barrera sería sólo when not Escribiendo, y con tres lectores que se solapan el contador Lectores podría no llegar nunca a cero.

Filósofos comensales

Cinco filósofos alrededor de una mesa. Entre cada par hay un tenedor, cinco en total. Un filósofo alterna entre pensar y comer, y para comer necesita los dos tenedores adyacentes. El problema fue planteado por Dijkstra en 1965 como ejercicio de asignación de múltiples recursos.

flowchart TB
    subgraph MESA["Mesa: cada filósofo necesita sus dos tenedores adyacentes"]
        F0(["F0"]) --- T0["T0"] --- F1(["F1"]) --- T1["T1"] --- F2(["F2"])
        F2 --- T2["T2"] --- F3(["F3"]) --- T3["T3"] --- F4(["F4"]) --- T4["T4"] --- F0
    end

    subgraph CICLO["Espera circular: todos toman el izquierdo primero"]
        C0(["F0 tiene T0<br/>espera T4"]) --> C4(["F4 tiene T4<br/>espera T3"])
        C4 --> C3(["F3 tiene T3<br/>espera T2"])
        C3 --> C2(["F2 tiene T2<br/>espera T1"])
        C2 --> C1(["F1 tiene T1<br/>espera T0"])
        C1 --> C0
    end

    style MESA fill:#1e3a5f,color:#fff
    style CICLO fill:#5f1e1e,color:#fff

La solución ingenua —cada filósofo toma primero el tenedor de su izquierda y luego el de su derecha— cumple las cuatro condiciones de Coffman y produce interbloqueo si los cinco toman su tenedor izquierdo antes de que ninguno tome el derecho. La probabilidad no es despreciable: es el estado al que converge el sistema si todos los filósofos son igual de rápidos.

Hay varias correcciones conocidas, y cada una ataca una condición distinta de Coffman:

SoluciónCondición de Coffman que rompeCómo
Orden total de tenedoresEspera circularCada filósofo toma primero el tenedor de menor identificador, sin importar si es el izquierdo o el derecho
Un filósofo zurdoEspera circularCuatro filósofos toman izquierdo-derecho y uno toma derecho-izquierdo, rompiendo la simetría del ciclo
Portero o árbitroRetención y esperaUn componente central admite a lo sumo cuatro filósofos a la mesa; con cuatro comensales y cinco tenedores siempre hay uno que puede comer
Tomar ambos o ningunoRetención y esperaLa adquisición de los dos tenedores se hace en una sola operación atómica; si no están ambos, no se toma ninguno
Revocación con reintentoNo apropiaciónSi el segundo tenedor no está disponible, se suelta el primero y se reintenta más tarde

La implementación siguiente usa el orden total, que es la que menos reduce la concurrencia: hasta dos filósofos comen a la vez, igual que en la versión ingenua, sin riesgo de interbloqueo.

# filosofos.exs — ejecutar: elixir filosofos.exs
# Cada tenedor es un proceso. `tomar/1` bloquea al llamante hasta que el tenedor
# esté libre: el servidor guarda al solicitante en una cola y le responde recién
# cuando el tenedor se suelta. Es una barrera construida sólo con mensajes.
defmodule Tenedor do
  use GenServer

  def start_link, do: GenServer.start_link(__MODULE__, nil)
  def tomar(pid), do: GenServer.call(pid, :tomar, :infinity)
  def soltar(pid), do: GenServer.cast(pid, :soltar)

  @impl true
  def init(_), do: {:ok, %{libre: true, cola: :queue.new()}}

  @impl true
  def handle_call(:tomar, _desde, %{libre: true} = e), do: {:reply, :ok, %{e | libre: false}}
  def handle_call(:tomar, desde, e), do: {:noreply, %{e | cola: :queue.in(desde, e.cola)}}

  @impl true
  def handle_cast(:soltar, e) do
    case :queue.out(e.cola) do
      # el tenedor pasa directo al siguiente en la cola: no se libera
      {{:value, siguiente}, resto} ->
        GenServer.reply(siguiente, :ok)
        {:noreply, %{e | cola: resto}}

      {:empty, _} ->
        {:noreply, %{e | libre: true}}
    end
  end
end

# Rompe la espera circular tomando siempre primero el tenedor de menor
# identificador. Los pid tienen un orden total en Erlang, así que `Enum.sort`
# basta: si todos respetan ese orden, no puede formarse un ciclo.
defmodule Filosofo do
  def iniciar(nombre, izq, der, rondas), do: spawn(fn -> ciclo(nombre, izq, der, rondas) end)

  defp ciclo(nombre, _izq, _der, 0), do: IO.puts("#{nombre} se retira de la mesa")

  defp ciclo(nombre, izq, der, rondas) do
    IO.puts("#{nombre} piensa")
    Process.sleep(Enum.random(20..80))

    [primero, segundo] = Enum.sort([izq, der])
    Tenedor.tomar(primero)
    Tenedor.tomar(segundo)

    IO.puts("#{nombre} COME (rondas restantes: #{rondas})")
    Process.sleep(Enum.random(20..80))

    Tenedor.soltar(segundo)
    Tenedor.soltar(primero)
    ciclo(nombre, izq, der, rondas - 1)
  end
end

# --- Programa principal ---
cantidad = 5
tenedores = Enum.map(1..cantidad, fn _ -> elem(Tenedor.start_link(), 1) end)
nombres = ["Aristoteles", "Platon", "Socrates", "Kant", "Hume"]

referencias =
  nombres
  |> Enum.with_index()
  |> Enum.map(fn {nombre, i} ->
    izq = Enum.at(tenedores, i)
    der = Enum.at(tenedores, rem(i + 1, cantidad))
    Process.monitor(Filosofo.iniciar(nombre, izq, der, 4))
  end)

# Espera a que los cinco filósofos terminen sus rondas.
Enum.each(referencias, fn ref ->
  receive do
    {:DOWN, ^ref, :process, _pid, _razon} -> :ok
  end
end)

IO.puts("La cena terminó sin interbloqueo")

Si se reemplaza la línea del orden total por la versión ingenua:

# Versión que SÍ produce interbloqueo: todos toman su izquierdo primero.
Tenedor.tomar(izq)
Process.sleep(20)      # ensancha la ventana para que el ciclo se forme
Tenedor.tomar(der)

el programa se detiene sin terminar. Ninguno de los cinco procesos consume procesador: los cinco están bloqueados en GenServer.call con :infinity, cada uno esperando un tenedor que sostiene su vecino. Este contraste es el mejor experimento del capítulo, porque muestra que el interbloqueo no produce carga ni errores: produce silencio.

Modelos de sincronización comparados

Los ejemplos anteriores usaron dos modelos distintos, y conviene ponerlos lado a lado.

AspectoMemoria compartida (C con hilos, Ada con objetos protegidos)Paso de mensajes (Elixir con procesos)
Unidad de estadoVariables accesibles por todos los flujosEstado privado de cada proceso
ComunicaciónEscribir y leer la misma direcciónEnviar y recibir mensajes
Mecanismo de exclusiónCerrojos, mutex, semáforos, objetos protegidosSerialización natural: un proceso atiende un mensaje a la vez
Origen típico del errorDos flujos tocando la misma dirección sin protecciónProtocolo dividido en varios mensajes que deberían ser uno
Costo de una operaciónMuy bajo si no hay contenciónMayor: copia del mensaje y cambio de contexto
Escala naturalUn nodo, memoria coherenteVarios nodos, red
¿Puede haber interbloqueo?Sí: dos procesos que se llaman mutuamente y esperan respuesta
¿Puede haber condición de carrera?Sí, sobre memoriaSí, sobre el orden de los mensajes

La conclusión que interesa es que el paso de mensajes elimina una clase de errores —las actualizaciones perdidas sobre memoria— pero no elimina la sincronización. El error de verificar-y-actuar reaparece intacto cuando el protocolo se parte en dos mensajes, como mostró el ejemplo bancario en Elixir.

Errores comunes

ErrorCausaCómo se manifiestaSolución
Suponer que una línea de código es atómicax++ o x = x + 1 son leer-modificar-escribirActualizaciones perdidas, contadores por debajo del valor realProteger con exclusión mutua o usar tipos atómicos del lenguaje
Reducir la ventana en lugar de cerrarlaSe agrega un sleep o se acorta el código entre verificar y actuarEl error baja de frecuencia y reaparece bajo cargaConvertir verificar-y-actuar en una operación indivisible
”No apareció en las pruebas, entonces está bien”Las pruebas ejercitan unos pocos intercaladosFallo en producción, no reproducible en desarrolloArgumentar la corrección sobre los invariantes; usar detectores dinámicos de carreras
Depurar con printfLa E/S cambia los tiempos y suele estar sincronizada internamenteEl error desaparece al instrumentar: HeisenbugRegistrar en buffers en memoria y volcar al final, o usar herramientas de trazado
Adquirir cerrojos en órdenes distintosCada función pide los recursos en el orden que le resulta naturalInterbloqueo intermitente al aumentar la concurrenciaDefinir un orden total de adquisición y documentarlo
Retornar de la sección crítica sin liberarUn return temprano, una excepción o un break salta el protocolo de salidaInterbloqueo permanente en la siguiente entradaUn solo punto de salida, o construcciones que liberen automáticamente al salir del ámbito
Espera activa donde corresponde bloqueoBucle que consulta una condición sin ceder el procesadorUn núcleo al 100 % sin trabajo útil; el hilo que debe liberar no alcanza a correrUsar primitivas de bloqueo del sistema operativo o barreras del lenguaje
Peterson con variables ordinariasEl compilador y el procesador reordenan accesos a memoriaEl algoritmo falla pese a ser correcto sobre el papelUsar tipos atómicos con consistencia secuencial, o primitivas de la biblioteca
Un cerrojo por variable en lugar de por invarianteSe protege cada campo por separadoEl invariante que relaciona dos campos se observa roto entre ambos cerrojosProteger con un solo cerrojo todo el conjunto de datos que forma un invariante
Buffer sin límite entre productor y consumidorSe omite la condición de “lleno” para simplificarLa memoria crece hasta agotarse si el productor es más rápidoAcotar la capacidad y bloquear al productor: la contrapresión es parte del diseño
Barrera de lectores sin considerar escritores encoladosSólo se verifica que no haya escritor activoLos escritores nunca entran mientras haya lectura continuaIncluir en la barrera de lectura el conteo de escritores en espera
Confundir interbloqueo con lentitudAmbos se ven como “el programa no responde”Se reinicia sin diagnosticar y el problema vuelveRevisar el estado de los hilos: bloqueados indica interbloqueo, ejecutables indica bloqueo vivo o carga

Ejercicios propuestos

Los primeros ejercicios se resuelven observando y midiendo; los últimos requieren escribir y argumentar.

Ejercicio 1 — Medir la pérdida. Compila contador_roto.c con -O0 y ejecútalo veinte veces guardando cada resultado. Calcula el mínimo, el máximo y la media de incrementos perdidos. Repite con VUELTAS igual a 1000 y explica por qué el porcentaje de pérdida cambia.

Ejercicio 2 — El efecto del optimizador. Compila el mismo programa con -O2 y compara los resultados con los de -O0. Explica qué transformación del compilador puede alterar el comportamiento y por qué eso no significa que el programa sea correcto.

Ejercicio 3 — Cerrar la ventana. Modifica banco_roto.c para que el invariante fondos >= 0 se mantenga en todos los intercalados posibles, usando pthread_mutex_t. Argumenta en un párrafo por qué tu solución cubre todos los intercalados y no sólo los que observaste.

Ejercicio 4 — Refutar el intento 2. Escribe la secuencia exacta de operaciones de los dos hilos que hace que ambos entren a la sección crítica en el intento 2 de exclusión mutua. Numera cada paso indicando qué hilo lo ejecuta y qué valor lee o escribe.

Ejercicio 5 — Progreso en el intento 1. Construye un escenario concreto con el turno estricto en el que la sección crítica está libre y un hilo que la necesita no puede entrar. Identifica cuál de los cuatro requisitos se incumple y cita la cláusula precisa.

Ejercicio 6 — Peterson sin átomos. Reescribe peterson.c usando bool e int corrientes en lugar de los tipos atómicos, compila con -O2 y ejecútalo cincuenta veces. Anota cuántas veces el resultado difiere del esperado. Explica el rol del orden de memoria en lo que observaste.

Ejercicio 7 — Panadería en código. Implementa el algoritmo de la panadería en C para cuatro hilos que incrementen un contador compartido un millón de veces cada uno. Verifica el total y mide el tiempo de ejecución comparándolo con la versión que usa pthread_mutex_t.

Ejercicio 8 — Coffman aplicado. Toma interbloqueo.c y produce cuatro versiones corregidas, cada una rompiendo una condición distinta de Coffman. Para cada versión, indica qué se pierde respecto de la original en utilización, complejidad o latencia.

Ejercicio 9 — Detectar el ciclo. Dibuja en Mermaid el grafo de asignación de recursos de un escenario con tres hilos y tres mutex donde exista interbloqueo. Luego dibuja el mismo escenario con un orden total de adquisición y muestra por qué el ciclo no puede formarse.

Ejercicio 10 — Inanición medible. Modifica el programa de lectores-escritores en Ada quitando el término Iniciar_Escritura'Count = 0 de la barrera de lectura, sube a ocho lectores con delay corto y cuenta cuántas escrituras se completan en diez segundos. Compara con la versión original.

Ejercicio 11 — Contrapresión. Ejecuta productor_consumidor.adb con Capacidad => 1 y con Capacidad => 100. Mide el tiempo total en cada caso y explica qué cambia en el comportamiento del productor.

Ejercicio 12 — Provocar el interbloqueo. Modifica filosofos.exs para que cada filósofo tome siempre su tenedor izquierdo primero, con una pausa entre ambas tomas. Confirma que el programa se detiene. Usando :observer.start() o Process.info/2, verifica que los cinco procesos están bloqueados y no consumiendo procesador.

Ejercicio 13 — Portero. Implementa la variante del árbitro para los filósofos: un proceso que admite a lo sumo cuatro comensales simultáneos. Argumenta por qué cuatro es suficiente para descartar el interbloqueo y por qué tres reduciría la concurrencia sin beneficio adicional.

Ejercicio 14 — Traducir el modelo. Toma la solución de lectores-escritores en Ada y escríbela en Elixir sin usar memoria compartida ni ETS: un proceso guarda el dato y otro controla el acceso. Indica qué parte del problema desaparece y qué parte se traslada al protocolo de mensajes.

Ejercicio 15 — Carreras sin memoria compartida. Escribe un programa en Elixir donde dos procesos produzcan un resultado incorrecto por partir en dos mensajes una operación que debería ser una sola. Luego corrígelo y describe en una frase la regla general que aplicaste.

Lo que viene

Este capítulo dejó identificado el problema completo. La concurrencia introduce no determinismo, el no determinismo expone las operaciones de leer-modificar-escribir, y de ahí salen las condiciones de carrera. Delimitar el código afectado da la sección crítica, y protegerla exige exclusión mutua con cuatro requisitos: exclusión, progreso, espera limitada e independencia de la velocidad relativa. Vimos que se puede lograr sólo con memoria compartida —Dekker, Peterson, la panadería de Lamport— y por qué el hardware terminó ofreciendo instrucciones atómicas. Nombramos las tres formas de detención: interbloqueo, con sus cuatro condiciones de Coffman y sus cuatro puntos de ataque; bloqueo vivo, donde hay actividad sin avance; e inanición, donde la política de asignación posterga indefinidamente a alguien. Y resolvimos los tres problemas clásicos con objetos protegidos de Ada y procesos de Elixir.

Queda pendiente un cabo suelto importante: todas las soluciones por software de este capítulo hacen espera activa. Un hilo que no puede entrar consume su cuanto de procesador consultando una variable, y en el peor caso impide correr precisamente al hilo que liberaría el recurso. Eso no se arregla con un algoritmo más ingenioso: hace falta que el sistema operativo participe, sacando al hilo de la cola de listos mientras espera y devolviéndolo cuando la condición se cumple.

Esa primitiva existe desde 1965 y es la abstracción central de la sincronización: el semáforo. En el capítulo 8 se estudia en detalle: las operaciones wait y signal, la diferencia entre semáforo binario y contador, cómo se implementa por dentro con las instrucciones atómicas que vimos aquí, y cómo se reescriben con semáforos los tres problemas clásicos que acabamos de resolver. También veremos por qué el semáforo, siendo suficiente para todo, es difícil de usar correctamente, y qué construcciones de más alto nivel aparecieron después.

El temario completo del curso está en el índice general.