← Concurrencia y Sincronización Bloque 4 · Mutex · Tema 15 de 47

Tema 15

Mutex en pthreads

El mutex (mutual exclusion) es el mecanismo de sincronización más usado en la práctica. Es un candado que un hilo adquiere antes de entrar a la sección crítica y libera al salir. En este tema verás cómo usar pthread_mutex_lock y pthread_mutex_unlock, cómo proteger la sección crítica del contador y cómo corregir el programa de forma definitiva.

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:

Mutex vs spinlock

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ónDescripción
pthread_mutex_initInicializa un mutex (o usar PTHREAD_MUTEX_INITIALIZER).
pthread_mutex_destroyDestruye un mutex.
pthread_mutex_lockAdquiere el mutex (bloquea si está ocupado).
pthread_mutex_unlockLibera el mutex.
pthread_mutex_trylockIntenta adquirir sin bloquear (devuelve EBUSY si está ocupado).
pthread_mutex_timedlockIntenta adquirir con timeout.

3. Inicialización

Un mutex se puede inicializar de dos formas:

inicialización de mutex
/* 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.

lock y unlock
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).

Olvidar el unlock es el error más común

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:

mutex_counter.c
#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:

terminal · el contador con mutex siempre da el resultado correcto
$ 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:

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:

  1. 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
  2. Si falla: el mutex está ocupado. Entrar al núcleo y bloquear el hilo (futex_wait). slow path
  3. 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.

El mutex es rápido cuando no hay contención

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 mutuaSíSolo un hilo a la vez.
ProgresoSíSi el mutex está libre, un hilo que lo solicita lo adquiere.
Espera acotadaSí (con implementaciones correctas)Los hilos se despiertan en orden FIFO (en la mayoría de las implementaciones).
Sin deadlockSí (si se usa correctamente)Siempre que cada lock tenga su unlock.
EficienciaSí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:

cuenta.c
#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:

terminal · observar mutex
# 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

ModoEsperadoObtenidoPerdidos

11. Resumen

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

  1. Compila y ejecuta mutex_counter.c varias veces. ¿El resultado es siempre correcto?
  2. Implementa la cuenta bancaria y verifica que el saldo es consistente.
  3. Usa strace para observar las syscalls de futex con y sin contención.
  4. Compara el rendimiento del mutex con el spinlock bajo contención.
  5. Implementa trylock para evitar el bloqueo.
  6. Investiga cómo se implementa el mutex en glibc (futex).