Sincronización entre hilos

"Si dos hilos pueden correr en cualquier orden, encontrarás el orden que rompa todo." — corolario de Murphy aplicado a concurrencia.

Qué vas a aprender en este capítulo

En el capítulo de procesos e hilos viste tu primer race condition: un contador compartido que daba resultados aleatorios. Ahora aprendés cómo se resuelve. Vas a entender qué es una región crítica, vas a usar las herramientas clásicas (mutex, semáforos, variables de condición), y vas a conocer el demonio mayor de la concurrencia: el deadlock. Al final vas a resolver los tres problemas clásicos: productor-consumidor, lectores-escritores y la cena de los filósofos.

4.1 La idea: turnos para evitar choques

💡 Intuición

Imaginate dos personas escribiendo en una sola libreta de notas, simultáneamente, sin coordinarse. Una escribe "comprar pupusas", la otra "comprar refresco". Lo que termina escrito puede ser:

  • "comprar pupusas comprar refresco" (suerte: lo de uno antes que lo del otro).
  • "comprar refresco comprar pupusas" (mismo).
  • "comprar puprefrescopusas" (las letras se intercalan).
  • O peor — caracteres garabateados.

La libreta es un recurso compartido que no soporta que dos manos escriban a la vez. Para coordinarse, las dos personas necesitan un turno: una escribe primero, la otra espera; cuando termina, le toca a la otra.

En programación concurrente, el "turno" se llama exclusión mutua (mutual exclusion, mutex). Un mutex es una cerradura: solo un hilo puede tenerla a la vez. Quien la suelta, deja entrar al siguiente.

Casi toda la sincronización del mundo se construye sobre esta idea simple: garantizar que solo un hilo a la vez acceda a algo compartido. Lo difícil es cómo: hay docenas de mecanismos, con distintos costos, y elegir mal puede arruinar el rendimiento o, peor, congelar el programa.

4.2 Sección crítica y exclusión mutua

📐 Fundamento

Sección crítica (o región crítica): un fragmento de código que accede a estado compartido y por lo tanto no debe ejecutarse simultáneamente por más de un hilo.

Ejemplo del cap 2 (race condition):

contador++;     // ← sección crítica

Aunque parece una instrucción, son tres operaciones (leer, sumar, escribir). Si dos hilos las intercalan, se pisan.

Requisitos de un buen esquema de sincronización:

  1. Exclusión mutua. Solo un hilo a la vez en la sección crítica.
  2. Progreso. Si nadie está en la sección y varios quieren entrar, alguien (no nadie) entra eventualmente.
  3. Espera acotada. Ningún hilo espera infinitamente — eventualmente todos pasan (sin starvation).

Lo que NO querés:

  • Race condition — dos hilos pisándose, comportamiento dependiente del orden.
  • Deadlock — todos esperan algo que nadie puede dar.
  • Starvation — un hilo nunca obtiene su turno aunque siempre haya turnos.
  • Livelock — hilos en movimiento pero sin progreso (como dos personas tratando de pasarse en un pasillo y bailando lateralmente).

Atomicidad. La operación es atómica si se ejecuta "todo o nada" — sin posibilidad de que otro hilo la vea a medio hacer. La CPU moderna ofrece instrucciones atómicas (CAS, compare-and-swap) que son la base de toda primitiva de sincronización superior.

4.3 Soluciones simples (que no funcionan)

📐 Fundamento

Antes de las primitivas correctas, vale ver intentos ingenuos:

Intento 1: variable booleana (mal)

bool ocupado = false;

void entrar() {
    while (ocupado) { /* espera */ }
    ocupado = true;
    // sección crítica
    ocupado = false;
}

Falla. Dos hilos pueden ver ocupado=false al mismo tiempo, los dos pasan el while, los dos ponen ocupado=true, los dos entran. Race condition adentro de la propia "solución".

Intento 2: deshabilitar interrupciones (mal)

void entrar() {
    cli();   // deshabilita interrupciones
    // sección crítica
    sti();   // las habilita
}

Falla. En multinúcleo, deshabilitar interrupciones en un núcleo no detiene otros núcleos. Y en modo usuario, cli no es siquiera permitido.

Intento 3: algoritmo de Peterson (correcto pero impráctico)

Hay algoritmos puramente en software (Peterson, Dekker, Lamport) que resuelven exclusión mutua para 2 o N hilos sin hardware especial. Funcionan, pero:

  • Asumen modelos de memoria fuertes que las CPU modernas no respetan (necesitan memory barriers).
  • Son busy-wait (hilo gasta CPU haciendo while).
  • Difíciles de generalizar.

Conclusión: la sincronización correcta requiere soporte del hardware (instrucciones atómicas) y del SO (poner hilos a dormir cuando esperan).

4.4 Mutex (cerradura)

📐 Fundamento

Mutex = abreviatura de "mutual exclusion". Es la primitiva más usada.

Operaciones:

  • lock(m) — adquirir la cerradura. Si ya está tomada, bloquearse hasta que se libere.
  • unlock(m) — liberar.

Garantías: solo un hilo posee la cerradura a la vez.

Ejemplo en C con pthreads:

#include <pthread.h>

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int contador = 0;

void *incrementar(void *arg) {
    for (int i = 0; i < 1000000; i++) {
        pthread_mutex_lock(&lock);
        contador++;                  // ← protegido
        pthread_mutex_unlock(&lock);
    }
    return NULL;
}

Ahora dos hilos lanzados con incrementar dan siempre 2,000,000. La race condition desapareció.

Mismo código en Python:

import threading

lock = threading.Lock()
contador = 0

def incrementar():
    global contador
    for _ in range(1_000_000):
        with lock:
            contador += 1

with lock: es azúcar sintáctica equivalente a lock.acquire(); ...; lock.release(). Garantiza que se libera incluso si hay excepciones.

Implementación interna del mutex. El SO usa instrucciones atómicas como CAS (compare-and-swap):

CAS(addr, esperado, nuevo):
    si memoria[addr] == esperado:
        memoria[addr] = nuevo
        devolvé true
    sino:
        devolvé false

CAS es una sola instrucción del hardware, atómica. El mutex (simplificado) hace:

lock(m):
    while not CAS(&m, UNLOCKED, LOCKED):
        // dormir o reintentar

Si la cerradura está libre, CAS triunfa y el hilo entra. Si está ocupada, fracasa y reintenta (o el SO duerme al hilo y lo despierta cuando se libere).

Costo del mutex:

  • Contención baja (poca pelea): muy barato (~10-50 ns).
  • Contención alta: hilos se duermen, hay context switches, costo aumenta mucho.

Regla práctica: las secciones críticas deben ser lo más cortas posible. Cuanto más tiempo retenés el lock, más esperan los demás.

// ❌ Sección crítica gigante, mata el rendimiento
pthread_mutex_lock(&lock);
hacer_calculo_pesado();    // 5 segundos
escribir_resultado();      // 1 µs
pthread_mutex_unlock(&lock);

// ✅ Solo lo compartido va adentro
hacer_calculo_pesado();    // sin lock
pthread_mutex_lock(&lock);
escribir_resultado();
pthread_mutex_unlock(&lock);

4.5 Semáforos

📐 Fundamento

Semáforo (Dijkstra, 1965): generalización del mutex. En lugar de "abierto/cerrado", lleva un contador entero.

Operaciones (notación clásica):

  • P(s) o wait(s): si s>0s > 0, decrementá y seguí. Si s=0s = 0, bloqueá.
  • V(s) o signal(s): incrementá ss. Si hay hilos bloqueados, despertá uno.

(P y V vienen del holandés proberen y verhogen.)

Casos:

  • Semáforo binario (valor 0 o 1): equivale a un mutex.
  • Semáforo de conteo: para limitar número de accesos concurrentes. Por ejemplo, "máximo 5 conexiones a la base de datos".

Ejemplo: pool de recursos limitados.

#include <semaphore.h>

sem_t pool;
sem_init(&pool, 0, 5);   // 5 recursos disponibles

void *trabajador(void *arg) {
    sem_wait(&pool);     // toma uno (espera si no hay)
    usar_recurso();
    sem_post(&pool);     // devuelve
    return NULL;
}

Si hay 100 hilos y pool=5, solo 5 usan el recurso al mismo tiempo. Los otros 95 esperan en sem_wait.

Otro uso: señalización entre hilos. Un hilo espera, otro avisa.

sem_t listo;
sem_init(&listo, 0, 0);  // empieza en 0

// Hilo A
sem_wait(&listo);        // bloquea hasta que B avise
hacer_algo();

// Hilo B
preparar_datos();
sem_post(&listo);        // avisa a A

Mutex vs semáforo:

Característica Mutex Semáforo
Quién libera El que adquirió (idealmente) Cualquiera
Para qué Exclusión mutua Recursos limitados, señalización
Errores típicos Olvidar unlock Más complejos, más errores
Recomendación Default Cuando mutex no alcanza

El semáforo es más flexible pero más fácil de mal usar. Los mutex son la opción default; los semáforos para casos específicos.

4.6 Variables de condición

📐 Fundamento

A veces no querés solo "exclusión mutua" — querés esperar a que algo pase. Por ejemplo: "espero a que la cola no esté vacía".

Variable de condición = primitiva para que un hilo se duerma esperando una condición, y otro hilo lo despierte cuando la condición se cumpla. Siempre va asociada a un mutex.

Operaciones:

  • cond_wait(c, m) — atómicamente: libera el mutex m, duerme al hilo, y al despertarse re-adquiere m.
  • cond_signal(c) — despierta a uno de los esperando.
  • cond_broadcast(c) — despierta a todos.

Patrón canónico:

pthread_mutex_lock(&m);
while (!condicion) {
    pthread_cond_wait(&c, &m);
}
// la condición se cumple acá
hacer_algo();
pthread_mutex_unlock(&m);

Notá:

  • while, no if. Cuando despertás, la condición puede haber cambiado (otro hilo se adelantó). Volvé a verificar.
  • El mutex se libera durante la espera y se readquiere al despertar — todo atómicamente.

Ejemplo: cola con bloqueo.

typedef struct {
    int datos[N];
    int cantidad;
    pthread_mutex_t lock;
    pthread_cond_t no_vacia;
    pthread_cond_t no_llena;
} Cola;

void poner(Cola *c, int x) {
    pthread_mutex_lock(&c->lock);
    while (c->cantidad == N)
        pthread_cond_wait(&c->no_llena, &c->lock);
    c->datos[c->cantidad++] = x;
    pthread_cond_signal(&c->no_vacia);
    pthread_mutex_unlock(&c->lock);
}

int sacar(Cola *c) {
    pthread_mutex_lock(&c->lock);
    while (c->cantidad == 0)
        pthread_cond_wait(&c->no_vacia, &c->lock);
    int x = c->datos[--c->cantidad];
    pthread_cond_signal(&c->no_llena);
    pthread_mutex_unlock(&c->lock);
    return x;
}

Esa es la cola productor-consumidor clásica. Productores llaman poner, consumidores sacar. Las dos colas (no_vacia, no_llena) son señales mutuas — quien pone avisa a quien saca, y viceversa.

4.7 Monitores

📐 Fundamento

Un monitor es un constructo de lenguaje que encapsula un mutex + variables de condición de manera que es difícil olvidar adquirir/liberar.

Idea: todos los métodos de un objeto se ejecutan bajo un mutex implícito del propio objeto. Solo un hilo a la vez está "dentro" del monitor.

En Java:

class ContadorSeguro {
    private int valor = 0;

    public synchronized void incrementar() {
        valor++;
    }

    public synchronized int leer() {
        return valor;
    }
}

synchronized automáticamente toma el mutex del objeto al entrar y lo libera al salir. No tenés que llamar lock/unlock.

Variables de condición en Java:

synchronized void poner(int x) throws InterruptedException {
    while (cantidad == N) wait();    // libera mutex y duerme
    datos[cantidad++] = x;
    notifyAll();                      // avisa a todos
}

wait() y notifyAll() son los equivalentes a cond_wait y cond_broadcast.

Por qué los monitores son lindos: evitan el error #1 de mutex en C — olvidar unlock al salir por excepción o return temprano. En lenguajes como Java, C# o Python (with), eso pasa automáticamente.

Crítica: un monitor obliga a serializar todos los accesos al objeto, incluso los de solo-lectura. Eso es ineficiente. Read-write locks y lock-free son alternativas más sofisticadas para casos de alta lectura concurrente.

4.8 El problema del productor-consumidor

🛠️ En la práctica

Setup: un buffer compartido finito. Uno o más productores ponen items; uno o más consumidores los sacan.

Restricciones:

  • Si el buffer está lleno, productores esperan.
  • Si está vacío, consumidores esperan.
  • No se pierden items, no se duplican.

Solución completa con mutex + 2 condiciones (ya mostrada arriba). Es el patrón de toda cola con bloqueo (BlockingQueue, queue.Queue de Python).

Variantes:

  • Sin lock — con anillo lock-free: usando CAS en posiciones del buffer. Más rápido en alta contención. Implementaciones reales: SPSC (Single Producer Single Consumer), MPMC, etc.
  • Con semáforos: dos semáforos (vacios, llenos) y un mutex. Equivalente, sintaxis distinta.
sem_t vacios, llenos;
pthread_mutex_t mtx;

void poner(int x) {
    sem_wait(&vacios);
    pthread_mutex_lock(&mtx);
    buffer.push(x);
    pthread_mutex_unlock(&mtx);
    sem_post(&llenos);
}

4.9 El problema de los lectores-escritores

🛠️ En la práctica

Setup: una base de datos compartida. Algunos hilos solo leen; otros escriben.

Idea: múltiples lectores pueden coexistir (leer no rompe nada), pero un escritor necesita exclusión total.

Solución con mutex:

int lectores = 0;
pthread_mutex_t lock_cont;
pthread_mutex_t lock_escritura;

void leer() {
    pthread_mutex_lock(&lock_cont);
    if (++lectores == 1)
        pthread_mutex_lock(&lock_escritura);   // primer lector bloquea escritores
    pthread_mutex_unlock(&lock_cont);

    // leer la base
    
    pthread_mutex_lock(&lock_cont);
    if (--lectores == 0)
        pthread_mutex_unlock(&lock_escritura); // último lector libera
    pthread_mutex_unlock(&lock_cont);
}

void escribir() {
    pthread_mutex_lock(&lock_escritura);
    // escribir
    pthread_mutex_unlock(&lock_escritura);
}

Trampa: esta versión favorece a los lectores. Si llegan lectores sin parar, los escritores se mueren de hambre (starvation). Variantes "con prioridad de escritor" o "justas" lo arreglan.

En la práctica, los SO ofrecen read-write locks ya implementados:

  • C/POSIX: pthread_rwlock_t.
  • Python: no nativos, hay implementaciones en bibliotecas.
  • Java: ReadWriteLock.
  • Rust: RwLock<T>.

Cuándo usar: si las lecturas son MUCHO más frecuentes que las escrituras (>>10:1), los rwlocks ganan rendimiento. Si la proporción es similar, el mutex simple es más rápido.

4.10 Deadlock

⚠️ Trampa común

Un deadlock es una situación en la que dos o más hilos esperan circularmente algo que nadie puede dar.

Ejemplo clásico:

// Hilo A
pthread_mutex_lock(&lock1);
pthread_mutex_lock(&lock2);    // espera...

// Hilo B
pthread_mutex_lock(&lock2);
pthread_mutex_lock(&lock1);    // espera...

A tiene lock1 y quiere lock2. B tiene lock2 y quiere lock1. Ninguno avanza, ninguno suelta. El programa se cuelga para siempre.

Las cuatro condiciones de Coffman

Para que ocurra deadlock, las cuatro tienen que cumplirse:

  1. Exclusión mutua. Recursos no compartibles (mutex, escritura a archivo).
  2. Posesión y espera. Un hilo retiene un recurso mientras espera otro.
  3. No expropiación. No se puede arrebatar un recurso a un hilo.
  4. Espera circular. Hay una cadena de hilos A→B→...→A, cada uno esperando al siguiente.

Si ROMPÉS UNA de las cuatro, no puede haber deadlock.

Estrategias contra deadlock

Prevención. Diseñá para que alguna condición no ocurra:

  • Romper espera circular: ordenar los recursos. Siempre adquirir locks en el mismo orden global. (Ej: si numerás los locks por dirección de memoria, siempre tomá primero el de menor dirección.)
  • Romper posesión y espera: adquirir todos los locks de una vez (tryLock con rollback si falla alguno).

Detección y recuperación. Permitir deadlocks pero tener un detector que los encuentra y mata uno de los hilos para romper el ciclo. Linux casi no usa esto a nivel SO, pero sí lo hacen las bases de datos (MySQL, PostgreSQL detectan deadlocks de transacciones y abortan una).

Ignorar (algoritmo del avestruz). Asumir que no van a ocurrir y rezar. Es lo que hace casi todo SO de propósito general en muchos casos. Si estás escribiendo un sistema crítico, no es opción.

Buenas prácticas para evitarlo

  1. Adquirí siempre los mutex en el mismo orden en todo el código.
  2. Mantené los lock-spans cortos. Cuanto menos tiempo retenés un lock, menos chance de cruces.
  3. Evitá tomar un lock mientras tenés otro si podés.
  4. Usá tryLock con timeout cuando puedas: si no obtenés el lock en X ms, soltá los que tenés y reintentá. (Patrón "back-off".)

Cómo detectar un deadlock en producción

  • gdb: si el programa se cuelga, attach con gdb -p <pid> y thread apply all bt muestra dónde está cada hilo. Si los ves esperando locks que se referencian mutuamente, deadlock.
  • strace: si veés syscalls futex infinitas en hilos, mala señal.
  • Bases de datos: SHOW ENGINE INNODB STATUS en MySQL muestra deadlocks recientes.

4.11 La cena de los filósofos

🛠️ En la práctica

Problema clásico (Dijkstra, 1965). Cinco filósofos sentados en una mesa redonda. Entre cada par, un palillo. Para comer, el filósofo necesita dos palillos: el de su izquierda y el de su derecha. Cuando no come, piensa.

       F0
   p4      p0
F4              F1
   p3      p1
       F2 — p2 — F3

(Cinco filósofos, cinco palillos.)

¿Cómo programarlo? Intento ingenuo:

void filósofo(int i) {
    while (1) {
        pensar();
        pthread_mutex_lock(&palillo[i]);                    // izq
        pthread_mutex_lock(&palillo[(i + 1) % 5]);          // der
        comer();
        pthread_mutex_unlock(&palillo[i]);
        pthread_mutex_unlock(&palillo[(i + 1) % 5]);
    }
}

Problema: si los 5 toman su palillo izquierdo al mismo tiempo, todos esperan el derecho que tiene su vecino — deadlock circular.

Soluciones

Solución 1: ordenar palillos. Numerar los palillos. Filósofo siempre toma el de menor número primero. Rompe la espera circular.

int izq = i, der = (i + 1) % 5;
int primero = (izq < der) ? izq : der;
int segundo = (izq < der) ? der : izq;
pthread_mutex_lock(&palillo[primero]);
pthread_mutex_lock(&palillo[segundo]);

Ahora el filósofo 4 (entre palillos 4 e 0) toma primero el 0 — no hay ciclo.

Solución 2: limitar comensales. Solo 4 filósofos pueden intentar comer simultáneamente. Garantiza que al menos uno tenga ambos palillos. Implementable con un semáforo de conteo.

sem_t comedor;
sem_init(&comedor, 0, 4);

void filósofo(int i) {
    while (1) {
        pensar();
        sem_wait(&comedor);          // entrar al comedor (máx 4)
        pthread_mutex_lock(&palillo[i]);
        pthread_mutex_lock(&palillo[(i + 1) % 5]);
        comer();
        pthread_mutex_unlock(&palillo[i]);
        pthread_mutex_unlock(&palillo[(i + 1) % 5]);
        sem_post(&comedor);
    }
}

Solución 3: monitor. Cada filósofo tiene tres estados: pensando, hambriento, comiendo. Variables de condición permiten que pida los dos palillos atómicamente o espere. Más elegante, más complejo.

Por qué importa. El problema modela cualquier escenario de recursos limitados con dependencia circular: redes, bases de datos, transacciones. Aprender a evitar el deadlock acá te entrena para todo.

4.12 Concurrencia sin locks

📐 Fundamento

Los locks tienen costo y peligro. Hay alternativas:

Operaciones atómicas

Para casos simples (incrementar contador, fijar bandera), las CPU modernas ofrecen instrucciones atómicas. C11 y C++11 las exponen:

#include <stdatomic.h>
atomic_int contador = 0;

// Sin lock, atómicamente:
atomic_fetch_add(&contador, 1);

Equivalente Java: AtomicInteger. Python: el módulo threading no expone atómicos directos pero queue.Queue y similares los usan internamente.

Estructuras lock-free

Estructuras de datos que no usan locks sino CAS y otros trucos:

  • Cola lock-free (Michael & Scott, 1996).
  • Stack lock-free (Treiber).
  • Hashmaps concurrentes (ConcurrentHashMap en Java).

Ventajas: alta concurrencia, no hay deadlock. Desventajas: muy difíciles de escribir bien (errores sutiles, livelock posible).

Mensajes en lugar de memoria compartida

Cambiar el modelo: en lugar de hilos compartiendo memoria, procesos/actores que se mandan mensajes. Sin estado compartido, no hay race conditions (solo orden de mensajes).

  • Erlang/Elixir — el actor model en su esencia.
  • Go — goroutines + channels.
  • Rust — channels en std::sync::mpsc.
// Go: dos goroutines comunicándose por channel
ch := make(chan int)
go func() { ch <- 42 }()
val := <- ch    // bloquea hasta recibir

Rob Pike (uno de los autores de Go) lo resumió: "Don't communicate by sharing memory; share memory by communicating."

Tendencia moderna: los lenguajes nuevos (Go, Rust, Elixir) prefieren mensajes. Los viejos (Java, C++) prefieren memoria compartida + locks. Las dos enfoques son válidos, con casos donde uno gana al otro.

4.13 Resumen visual

Primitiva Para qué
Mutex Exclusión mutua simple
Semáforo binario Equivale a mutex
Semáforo de conteo Recursos limitados
Variable de condición Esperar a que algo pase
Monitor Mutex + condición integrados en lenguaje
Read-write lock Muchos lectores, pocos escritores
Atomic Operaciones simples sin lock
Channel Comunicación por mensajes
Peligros Cómo evitarlos
Race condition Proteger sección crítica con mutex
Deadlock Ordenar locks, lock-spans cortos, tryLock
Starvation Aging, prioridades justas
Sección crítica gigante Mantenerla pequeña
Olvidar unlock Usar with (Python), synchronized (Java), defer (Go), monitor

4.14 Ejercicios

✏️ Ejercicio 4.1 — Detectar la sección crítica

Identificá la sección crítica:

int balance = 1000;

void retirar(int monto) {
    if (balance >= monto) {
        balance -= monto;
        printf("Retirado %d\n", monto);
    } else {
        printf("Insuficiente\n");
    }
}

¿Qué pasa si dos hilos llaman retirar(800) simultáneamente?

✏️ Ejercicio 4.2 — Productor-consumidor en Python

Implementá un productor que pone números 1..100 en una cola, y dos consumidores que los sacan e imprimen. Usá queue.Queue (que es thread-safe) o threading.Lock + Condition a mano.

✏️ Ejercicio 4.3 — Detectar deadlock

¿Este código tiene deadlock? Justificá.

// Hilo A
pthread_mutex_lock(&lock1);
pthread_mutex_lock(&lock2);
// trabajo
pthread_mutex_unlock(&lock2);
pthread_mutex_unlock(&lock1);

// Hilo B
pthread_mutex_lock(&lock1);
pthread_mutex_lock(&lock2);
// trabajo
pthread_mutex_unlock(&lock2);
pthread_mutex_unlock(&lock1);

¿Y este otro?

// Hilo A
pthread_mutex_lock(&lock1);
pthread_mutex_lock(&lock2);
// trabajo
pthread_mutex_unlock(&lock2);
pthread_mutex_unlock(&lock1);

// Hilo B
pthread_mutex_lock(&lock2);
pthread_mutex_lock(&lock1);
// trabajo
pthread_mutex_unlock(&lock1);
pthread_mutex_unlock(&lock2);

✏️ Ejercicio 4.4 — Filósofos asimétricos

Modificá el código de los filósofos para que uno de los cinco tome los palillos en orden inverso (derecho primero, izquierdo después). Demostrá que esto rompe la espera circular y por tanto previene deadlock.

4.15 Para profundizar


Definiciones nuevas: sección crítica, exclusión mutua, mutex, semáforo (binario, de conteo), variable de condición, monitor, deadlock, condiciones de Coffman, starvation, livelock, atomic, lock-free, productor-consumidor, lectores-escritores, filósofos.