1. Qué es un mutex
Un mutex (mutual exclusion) es un candado que garantiza que solo un hilo a la vez puede ejecutar la sección crítica. A diferencia del spinlock, el mutex bloquea al hilo si el candado está ocupado: el hilo se duerme y cede la CPU, en lugar de girar consumiendo CPU.
El mutex es el mecanismo de sincronización más usado en la práctica porque:
- Es simple: solo tiene lock y unlock.
- Es eficiente: si no hay contención, se adquiere en una operación atómica (sin entrar al núcleo).
- Bloquea: si hay contención, el hilo se duerme y cede la CPU (no desperdicia ciclos).
- Cumple los tres requisitos: exclusión mutua, progreso y espera acotada (con implementaciones correctas).
El spinlock gira consumiendo CPU; el mutex bloquea y cede la CPU. El spinlock es mejor para secciones muy cortas; el mutex es mejor para secciones largas o con alta contención.
2. La API de pthreads
La API de mutex de pthreads tiene estas funciones:
| Función | Descripción |
|---|---|
pthread_mutex_init | Inicializa un mutex (o usar PTHREAD_MUTEX_INITIALIZER). |
pthread_mutex_destroy | Destruye un mutex. |
pthread_mutex_lock | Adquiere el mutex (bloquea si está ocupado). |
pthread_mutex_unlock | Libera el mutex. |
pthread_mutex_trylock | Intenta adquirir sin bloquear (devuelve EBUSY si está ocupado). |
pthread_mutex_timedlock | Intenta adquirir con timeout. |
3. Inicialización
Un mutex se puede inicializar de dos formas:
/* Forma 1: inicialización estática (en tiempo de compilación). */
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
/* Forma 2: inicialización dinámica. */
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL);
/* Destruir cuando ya no se use. */
pthread_mutex_destroy(&mutex);
La inicialización estática es la más común y la más simple. La dinámica se usa cuando el mutex se crea en tiempo de ejecución (por ejemplo, en una estructura de datos).
4. lock y unlock
pthread_mutex_lock adquiere el mutex. Si el mutex está libre, lo adquiere y retorna inmediatamente. Si está ocupado, el hilo se bloquea hasta que se libere.
pthread_mutex_unlock libera el mutex. Si hay hilos esperando, uno de ellos se despierta y adquiere el mutex.
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
/* Adquirir el mutex. */
pthread_mutex_lock(&mutex);
/* Sección crítica. */
counter++;
/* Liberar el mutex. */
pthread_mutex_unlock(&mutex);
La regla fundamental es: todo lock debe tener su unlock correspondiente. Si un hilo adquiere un mutex y no lo libera, los demás hilos que intenten adquirirlo se bloquearán para siempre (deadlock).
Si un hilo adquiere un mutex y no lo libera (por ejemplo, por un return anticipado o una excepción), los demás hilos se bloquean para siempre. Siempre asegúrate de que cada lock tenga su unlock, incluso en los caminos de error.
5. El contador corregido
El contador del tema 3 se puede corregir con un mutex:
#define _POSIX_C_SOURCE 200809L
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
static int counter = 0; /* compartido */
static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
static void *incrementar(void *arg)
{
int n = *(int *)arg;
for (int i = 0; i < n; i++) {
pthread_mutex_lock(&mutex);
counter++; /* sección crítica protegida */
pthread_mutex_unlock(&mutex);
}
return NULL;
}
int main(int argc, char *argv[])
{
int n = argc > 1 ? atoi(argv[1]) : 100000;
int hilos = argc > 2 ? atoi(argv[2]) : 4;
if (hilos < 1 || hilos > 64)
return 1;
pthread_t t[64];
for (int i = 0; i < hilos; i++)
if (pthread_create(&t[i], NULL, incrementar, &n) != 0) {
fprintf(stderr, "no se pudo crear el hilo %d\n", i);
return 1;
}
for (int i = 0; i < hilos; i++)
pthread_join(t[i], NULL);
printf("esperado: %d obtenido: %d\n", hilos * n, counter);
return 0;
}
Compilamos y ejecutamos:
$ gcc -std=c17 -Wall -Wextra -pthread -o mutex_counter mutex_counter.c
$ for i in 1 2 3 4 5; do ./mutex_counter 100000 4; done
esperado: 400000 obtenido: 400000
esperado: 400000 obtenido: 400000
esperado: 400000 obtenido: 400000
esperado: 400000 obtenido: 400000
esperado: 400000 obtenido: 400000
El resultado es siempre 400.000. El mutex garantiza que solo un hilo a la vez ejecute counter++, eliminando la condición de carrera.
6. Cómo funciona por dentro
El mutex tiene dos modos de operación:
- Sin contención: el mutex está libre.
pthread_mutex_lockadquiere el mutex con una operación atómica (CAS) y retorna inmediatamente. No entra al núcleo. - Con contención: el mutex está ocupado.
pthread_mutex_lockse bloquea: el hilo se duerme (llama afutex(FUTEX_WAIT)) y cede la CPU. Cuando el mutex se libera, el núcleo despierta al hilo (llama afutex(FUTEX_WAKE)).
En Linux, el mutex se implementa con un futex (fast userspace mutex). El futex es una primitiva del núcleo que permite bloquear y despertar hilos eficientemente. La idea es:
- Intentar adquirir en espacio de usuario: usar una operación atómica (CAS) para establecer el mutex a «ocupado». Si tiene éxito, retornar sin entrar al núcleo. fast path
- Si falla: el mutex está ocupado. Entrar al núcleo y bloquear el hilo (futex_wait). slow path
- Al liberar: establecer el mutex a «libre» con una operación atómica. Si hay hilos esperando, llamar a futex_wake para despertar uno. unlock
El fast path (sin contención) es muy rápido: una sola instrucción atómica. El slow path (con contención) es más lento: requiere una syscall al núcleo.
En el caso común (sin contención), el mutex se adquiere y libera con operaciones atómicas en espacio de usuario, sin entrar al núcleo. Esto lo hace tan rápido como un spinlock en el caso sin contención, pero mucho mejor en el caso con contención (porque bloquea en lugar de girar).
7. Propiedades del mutex
| Propiedad | ¿La cumple? | Comentario |
|---|---|---|
| Exclusión mutua | Sí | Solo un hilo a la vez. |
| Progreso | Sí | Si el mutex está libre, un hilo que lo solicita lo adquiere. |
| Espera acotada | Sí (con implementaciones correctas) | Los hilos se despiertan en orden FIFO (en la mayoría de las implementaciones). |
| Sin deadlock | Sí (si se usa correctamente) | Siempre que cada lock tenga su unlock. |
| Eficiencia | Sí | Fast path sin entrar al núcleo. |
8. Un ejemplo desarrollado
Un ejemplo más complejo: una cuenta bancaria con depósitos y retiros concurrentes:
#define _POSIX_C_SOURCE 200809L
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int saldo;
pthread_mutex_t mutex;
} cuenta_t;
void cuenta_init(cuenta_t *c, int saldo_inicial) {
c->saldo = saldo_inicial;
pthread_mutex_init(&c->mutex, NULL);
}
void depositar(cuenta_t *c, int monto) {
pthread_mutex_lock(&c->mutex);
c->saldo += monto;
pthread_mutex_unlock(&c->mutex);
}
int retirar(cuenta_t *c, int monto) {
int ok = 0;
pthread_mutex_lock(&c->mutex);
if (c->saldo >= monto) {
c->saldo -= monto;
ok = 1;
}
pthread_mutex_unlock(&c->mutex);
return ok;
}
int saldo(cuenta_t *c) {
int s;
pthread_mutex_lock(&c->mutex);
s = c->saldo;
pthread_mutex_unlock(&c->mutex);
return s;
}
El mutex protege el saldo: solo un hilo a la vez puede leer o modificar el saldo. Esto garantiza que el saldo sea consistente, incluso con depósitos y retiros concurrentes.
Nota que retirar tiene una verificación (if (c->saldo >= monto)) dentro de la sección crítica. Esto es importante: la verificación y la modificación deben estar en la misma sección crítica, de lo contrario otro hilo podría modificar el saldo entre la verificación y la modificación.
9. Observar en Linux
Para observar el comportamiento de un mutex en Linux:
# Compilar con ThreadSanitizer para detectar errores
$ gcc -std=c17 -Wall -Wextra -pthread -fsanitize=thread -g -o mutex_test mutex_test.c
# Ejecutar con perf para medir cambios de contexto
$ perf stat -e context-switches ./mutex_test
# Observar con strace las syscalls de futex
$ strace -e futex ./mutex_test
futex(0x..., FUTEX_WAIT, ...) = 0
futex(0x..., FUTEX_WAKE, 1) = 1
strace muestra las syscalls de futex que se ejecutan cuando hay contención. En el caso sin contención, no se ve ninguna syscall (el fast path no entra al núcleo).
10. Simulador interactivo
Este simulador muestra cómo el mutex protege el contador. Varios hilos intentan incrementar el contador simultáneamente:
Simulador · mutex y contador
| Modo | Esperado | Obtenido | Perdidos |
|---|
11. Resumen
- Un mutex es un candado que bloquea al hilo si está ocupado.
- La API de pthreads incluye init, destroy, lock, unlock, trylock, timedlock.
- El mutex cumple los tres requisitos: exclusión mutua, progreso y espera acotada.
- Sin contención, el mutex se adquiere con una operación atómica (fast path).
- Con contención, el hilo se bloquea y cede la CPU (slow path, futex).
- El mutex es más eficiente que el spinlock bajo contención.
- La regla fundamental: todo lock debe tener su unlock.
- El mutex es la herramienta más usada para proteger secciones críticas.
12. Preguntas de repaso
Intenta responder cada una antes de abrir la respuesta.
1. ¿Qué es un mutex?
Un candado que garantiza exclusión mutua y bloquea al hilo si está ocupado.
2. ¿Qué diferencia al mutex del spinlock?
El mutex bloquea al hilo y cede la CPU; el spinlock gira consumiendo CPU.
3. ¿Qué es el fast path del mutex?
Adquirir el mutex con una operación atómica sin entrar al núcleo (cuando no hay contención).
4. ¿Qué es el slow path del mutex?
Bloquear el hilo en el núcleo con futex_wait (cuando hay contención).
5. ¿Qué es un futex?
Fast userspace mutex: una primitiva del núcleo para bloquear y despertar hilos eficientemente.
6. ¿Cumple el mutex los tres requisitos?
Sí: exclusión mutua, progreso y espera acotada.
7. ¿Qué ocurre si un hilo adquiere un mutex y no lo libera?
Los demás hilos que intenten adquirirlo se bloquean para siempre (deadlock).
8. ¿Qué es trylock?
Intenta adquirir el mutex sin bloquear. Devuelve EBUSY si está ocupado.
9. ¿Por qué la verificación y la modificación deben estar en la misma sección crítica?
Para que otro hilo no modifique el dato entre la verificación y la modificación.
10. ¿Qué syscall usa el mutex internamente?
futex (FUTEX_WAIT y FUTEX_WAKE).
Para practicar
- Compila y ejecuta mutex_counter.c varias veces. ¿El resultado es siempre correcto?
- Implementa la cuenta bancaria y verifica que el saldo es consistente.
- Usa strace para observar las syscalls de futex con y sin contención.
- Compara el rendimiento del mutex con el spinlock bajo contención.
- Implementa trylock para evitar el bloqueo.
- Investiga cómo se implementa el mutex en glibc (futex).