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:
- Exclusión mutua. Solo un hilo a la vez en la sección crítica.
- Progreso. Si nadie está en la sección y varios quieren entrar, alguien (no nadie) entra eventualmente.
- 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)owait(s): si , decrementá y seguí. Si , bloqueá.V(s)osignal(s): incrementá . 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 mutexm, duerme al hilo, y al despertarse re-adquierem.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, noif. 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:
- Exclusión mutua. Recursos no compartibles (mutex, escritura a archivo).
- Posesión y espera. Un hilo retiene un recurso mientras espera otro.
- No expropiación. No se puede arrebatar un recurso a un hilo.
- 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 (
tryLockcon 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
- Adquirí siempre los mutex en el mismo orden en todo el código.
- Mantené los lock-spans cortos. Cuanto menos tiempo retenés un lock, menos chance de cruces.
- Evitá tomar un lock mientras tenés otro si podés.
- Usá
tryLockcon 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 congdb -p <pid>ythread apply all btmuestra dónde está cada hilo. Si los ves esperando locks que se referencian mutuamente, deadlock.strace: si veés syscallsfutexinfinitas en hilos, mala señal.- Bases de datos:
SHOW ENGINE INNODB STATUSen 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 (
ConcurrentHashMapen 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?
Solución
Sección crítica: todo el bloque del if (lectura de balance, comparación, decremento, impresión).
Race: los dos hilos ven balance = 1000, pasan el if, decrementan a cada uno (en realidad porque cada uno lee 1000 y resta 800, sin ver al otro). Final: balance = . Cuenta sobregirada — bug grave.
Arreglo:
pthread_mutex_lock(&balance_lock);
if (balance >= monto) {
balance -= monto;
printf("Retirado %d\n", monto);
} else {
printf("Insuficiente\n");
}
pthread_mutex_unlock(&balance_lock);
Eso es literalmente lo que hace un sistema bancario por dentro.
✏️ 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.
Solución
Versión simple con Queue:
import queue
import threading
import time
q = queue.Queue(maxsize=10)
def productor():
for i in range(1, 101):
q.put(i)
time.sleep(0.01)
for _ in range(2):
q.put(None) # señal de fin para consumidores
def consumidor(nombre):
while True:
x = q.get()
if x is None:
break
print(f"{nombre}: {x}")
time.sleep(0.02)
threading.Thread(target=productor).start()
threading.Thread(target=consumidor, args=("A",)).start()
threading.Thread(target=consumidor, args=("B",)).start()
Queue ya es thread-safe internamente — usa locks por dentro.
✏️ 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);
Solución
Primero: NO hay deadlock. Ambos hilos toman los locks en el mismo orden (lock1 primero, lock2 segundo). Aunque B espere, A termina y libera; B avanza. No hay ciclo posible.
Segundo: SÍ hay deadlock posible. A toma lock1 y va por lock2; B toma lock2 y va por lock1. Si los timings son malos, se traban mutuamente.
Lección: la regla "siempre adquirir los locks en el mismo orden" es la prevención más efectiva.
✏️ 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.
Solución
void filósofo(int i) {
int izq = i, der = (i + 1) % 5;
while (1) {
pensar();
if (i == 0) { // el filósofo 0 va al revés
pthread_mutex_lock(&palillo[der]);
pthread_mutex_lock(&palillo[izq]);
} else {
pthread_mutex_lock(&palillo[izq]);
pthread_mutex_lock(&palillo[der]);
}
comer();
pthread_mutex_unlock(&palillo[izq]);
pthread_mutex_unlock(&palillo[der]);
}
}
Por qué funciona: el ciclo era F0→F1→F2→F3→F4→F0 esperando en sus palillos derechos. Si F0 espera primero el palillo 0 (su derecho, antes era su izquierdo), F4 ya no espera a F0 — F0 espera al palillo 0 que tampoco lo tiene F4. La cadena se rompe.
4.15 Para profundizar
- Tanenbaum & Bos cap. 2.3.
- OSTEP — la sección de "Concurrency" (capítulos 25-32) es de las mejores explicaciones gratuitas que existen.
- Herlihy & Shavit, The Art of Multiprocessor Programming — el libro definitivo sobre concurrencia avanzada (lock-free, transactional memory).
- Próximo capítulo: Memoria virtual — cómo el SO le da a cada proceso la ilusión de tener toda la RAM para sí solo.
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.