Esta es la lección central del módulo. Llevamos tres dejando asteriscos: un contador++ que pierde incrementos, un n_ultimas++ en /dev/shm/meteora-cache que hace lo mismo entre procesos, una struct Lectura de 24 bytes que se puede leer a medias. Sabemos qué es una sección crítica y qué tres requisitos debe cumplir una solución. Sabemos montar el canal entre procesos. Lo que no tenemos todavía es el protocolo que impide que dos flujos entren a la vez.

Aquí lo construimos de abajo arriba, porque es la única forma de entender por qué las primitivas son como son. Empezaremos intentando resolverlo solo con variables normales —fracasando de tres maneras distintas, cada una instructiva—; veremos por qué el problema es irresoluble sin ayuda del hardware; conoceremos las instrucciones atómicas que la CPU ofrece para esto; y sobre ellas levantaremos spinlocks, mutex, semáforos, variables de condición, bloqueos de lectura/escritura y barreras. Terminaremos mirando cómo lo implementa Linux con futex, por qué volatile no sirve para nada de esto, y cuánto cuesta realmente la contención en microsegundos medidos.

Contenido

  1. El problema de la sección crítica, formalizado
  2. Intentos ingenuos que fallan
  3. La solución de Peterson y sus límites reales
  4. Soporte del hardware: test-and-set y compare-and-swap
  5. Un contador atómico para meteo-api
  6. Espera activa y spinlocks
  7. Bloqueo con suspensión y el papel del planificador
  8. Mutex POSIX: el contador de Meteora arreglado
  9. Semáforos contadores y binarios
  10. Variables de condición y monitores
  11. Bloqueos de lectura/escritura y barreras
  12. Cómo lo implementa Linux: futex
  13. Barreras de memoria y por qué volatile no sirve
  14. Granularidad del bloqueo y coste de la contención

El problema de la sección crítica, formalizado

Recordemos el planteamiento con precisión. Tenemos n flujos que repiten el ciclo entrada(); seccion_critica(); salida(); resto();, y hay que diseñar entrada() y salida() de forma que se cumplan los tres requisitos de Conceptos de Concurrencia:

  1. Exclusión mutua: nunca dos flujos dentro de la sección crítica a la vez.
  2. Progreso: si está libre y alguien quiere entrar, la decisión no se pospone indefinidamente, y los que están en resto() no participan en ella.
  3. Espera limitada: hay un límite al número de veces que otros entran antes de que te toque.

Y dos supuestos que no se pueden violar: nada se puede suponer sobre la velocidad relativa de los flujos (uno puede ir mil veces más rápido, o quedarse parado un segundo entero por una expulsión del planificador) ni sobre el número de núcleos. Vamos a intentar resolverlo con lo que tenemos —variables compartidas normales—, fracasando tres veces; cada fracaso enseña algo que necesitaremos después.

Intentos ingenuos que fallan

Intento 1: la bandera única

La idea más natural: una variable ocupado que pongo a 1 mientras estoy dentro.

int ocupado = 0;                                    /* compartida */
void entrada(void) { while (ocupado == 1) ;         /* espero a que se libere */
                     ocupado = 1; }                 /* la marco como mía */
void salida(void)  { ocupado = 0; }

Falla la exclusión mutua, el requisito más importante. La traza: en t1 el hilo A lee ocupado → 0 y sale del while; en t2 el planificador lo expulsa antes de que escriba, y B lee ocupado → 0 y también sale; en t3 B escribe ocupado = 1 y entra; en t4 A escribe ocupado = 1 y entra también.

Los dos dentro. El motivo es el que ya conocemos: comprobar y actuar son dos operaciones separadas, con una ventana entre ellas. Es el check-then-act de 03-01 aplicado al propio candado, y la ironía es notable: el mecanismo destinado a proteger una sección crítica contiene él mismo una sección crítica sin proteger. Esa es la razón de fondo por la que el problema es irresoluble con lecturas y escrituras normales: necesitamos comprobar y modificar en un solo paso indivisible, y ninguna variable de C nos lo da.

Intento 2: turno estricto

Alternemos rigurosamente, con una variable que dice de quién es la vez:

int turno = 0;
void entrada(int yo) { while (turno != yo) ; }
void salida(int yo)  { turno = 1 - yo; }        /* le cedo el turno al otro */

Ahora sí hay exclusión mutua: turno tiene un solo valor, así que solo uno pasa el while, y como la escritura de un int alineado es atómica no hay ninguna ventana. Pero falla el progreso, y de forma grave. Si el hilo A sale de su sección crítica poniendo turno = 1, y el hilo B decide no volver a entrar porque está ocupado en resto(), entonces B nunca pondrá turno = 0 y A espera para siempre con la sección crítica libre. Esto viola directamente la cláusula que dice que un flujo que está en resto() no debe participar en la decisión. Y en la práctica es un desastre de rendimiento: si A entra mil veces por segundo y B una vez por minuto, A queda limitado a una entrada por minuto. El turno estricto impone a todos el ritmo del más lento.

Intento 3: dos banderas

Separemos "quiero entrar" de "estoy dentro", con una bandera por hilo:

int quiere[2] = {0, 0};
void entrada(int yo) { quiere[yo] = 1;              /* anuncio que quiero entrar */
                       while (quiere[1 - yo]) ; }   /* espero a que el otro no quiera */
void salida(int yo)  { quiere[yo] = 0; }

Ahora sí hay exclusión mutua (si los dos estuvieran dentro, ambos habrían tenido que ver la bandera del otro a 0 después de ponerla a 1, lo cual es imposible) y sí hay progreso frente a un hilo inactivo. Pero falla de una forma nueva y peor: si A pone quiere[0] = 1 y, antes de llegar a su while, B pone quiere[1] = 1, entonces los dos entran en su bucle de espera y ninguno sale nunca. Cada uno espera a que el otro renuncie, y ninguno renuncia porque está bloqueado esperando. Es un interbloqueo, el tema de Interbloqueos, fabricado aquí en cinco líneas.

Una variante tentadora es "si veo que el otro también quiere, retiro mi bandera un momento y reintento". Eso evita el interbloqueo pero introduce un livelock: los dos pueden retirar y reponer sus banderas sincronizadamente para siempre, cada uno cediendo educadamente al otro sin que ninguno avance. Es la versión informática de dos personas que se cruzan en un pasillo y se apartan al mismo lado una y otra vez. Resumen de los tres intentos:

Intento Exclusión mutua Progreso Espera limitada Fallo
Bandera única No Carrera check-then-act
Turno estricto No Un hilo inactivo bloquea al otro
Dos banderas No Interbloqueo

La solución de Peterson y sus límites reales

Gary Peterson publicó en 1981 la solución más elegante al problema para dos flujos, combinando las dos ideas anteriores: las banderas dicen quién quiere entrar, y el turno desempata.

/* peterson.c — solución correcta para DOS hilos */
int quiere[2] = {0, 0};
int turno = 0;

void entrada(int yo) {
    int otro = 1 - yo;
    quiere[yo] = 1;               /* (1) anuncio que quiero entrar */
    turno = otro;                 /* (2) CEDO el turno al otro */
    while (quiere[otro] && turno == otro) ;   /* (3) espero solo si él quiere Y es su turno */
}
void salida(int yo) { quiere[yo] = 0; }       /* retiro mi solicitud */

La línea (2) es la genial, y es contraintuitiva: cedo el turno al otro justo cuando yo quiero entrar. Por qué funciona, requisito por requisito:

Exclusión mutua. Para que los dos estuvieran dentro, ambos habrían salido del while. A sale si quiere[B] == 0 o si turno == A; B sale si quiere[A] == 0 o si turno == B. Si ambos están dentro, los dos pusieron su bandera a 1, luego las dos primeras condiciones son falsas y quedaría turno == A y turno == B a la vez: imposible, porque turno es una sola variable. Contradicción. Progreso. Si B no quiere entrar, su bandera está a 0 y A pasa directamente; si los dos quieren, turno tiene un solo valor y uno de los dos pasa. Espera limitada. Cuando A sale y quiere volver a entrar, pone turno = B, así que si B esperaba, ahora pasa: A puede adelantar a B como mucho una vez, el mejor límite posible.

Si intercambiaras las líneas (1) y (2) la solución dejaría de ser correcta. Y si los dos ejecutan (2) casi a la vez, el segundo en escribir gana el desempate —su escritura de turno es la que queda— y el primero pasa: un desempate que se resuelve solo, sin ninguna operación atómica compuesta.

Peterson es un resultado teórico precioso que en la práctica no se usa nunca, por tres razones que explican todo lo que viene después. Solo funciona para dos hilos: existe una generalización a n —el algoritmo del filtro, o el de la panadería de Lamport— pero requiere n pasos de espera y arrays de tamaño n, así que no escala. Es espera activa pura: ese while (...) ; quema un núcleo entero, lo que es catastrófico con más hilos que núcleos, porque el que espera consume su cuanto sin avanzar mientras el que tiene el candado no puede ejecutarse.

Y la tercera, la razón definitiva: la reordenación de memoria. Peterson es correcto sobre un modelo de memoria secuencialmente consistente, donde todos los núcleos ven las escrituras en el mismo orden. Ningún procesador moderno cumple eso. En x86-64 una escritura pasa primero por el store buffer del núcleo antes de hacerse visible al resto, y el procesador puede adelantar una lectura posterior a una escritura anterior a otra dirección. En el código de arriba, el procesador puede ejecutar la lectura de quiere[otro] antes de que la escritura de quiere[yo] haya salido del store buffer y sea visible para el otro hilo. Si ambos hacen lo mismo simétricamente, los dos leen la bandera del contrario a 0 y ambos entran. La exclusión mutua se rompe, no por un fallo del algoritmo, sino porque el hardware no ejecuta lo que el código dice, sino algo equivalente para un solo hilo.

Para que Peterson funcione en hardware real hay que insertar una barrera explícita, __atomic_thread_fence(__ATOMIC_SEQ_CST);, entre la escritura de turno y el bucle de espera. Y esa línea es la puerta de entrada a todo lo que sigue: la sincronización correcta necesita apoyo del hardware, no basta con escribir código listo. Volveremos sobre las barreras de memoria en el apartado 13.

Soporte del hardware: test-and-set y compare-and-swap

El problema de fondo de todos los intentos era el mismo: comprobar y modificar son dos operaciones y hay una ventana entre ellas. La solución es que el procesador ofrezca una instrucción que haga las dos cosas de forma indivisible, y todas las arquitecturas modernas la tienen en dos variantes.

/* Semántica de las dos, ejecutadas de forma INDIVISIBLE por el hardware */
int test_and_set(int *destino) {          /* escribe 1 y devuelve lo que había */
    int viejo = *destino; *destino = 1; return viejo;
}
int compare_and_swap(int *destino, int esperado, int nuevo) {
    if (*destino == esperado) { *destino = nuevo; return 1; }   /* solo si coincide */
    return 0;
}

CAS es estrictamente más potente que test-and-set y es la primitiva sobre la que se construye prácticamente todo. En x86-64 se implementa con lock cmpxchg; el prefijo lock es lo que hace la magia: durante esa instrucción, el núcleo obtiene la línea de caché en estado exclusivo y ningún otro puede modificarla.

En C11 no hace falta escribir ensamblador: GCC y Clang ofrecen las funciones __atomic_* y el estándar define <stdatomic.h>:

atomic_int candado = 0;
void adquirir(void) {
    int esperado;
    do { esperado = 0;             /* CAS lo modifica si falla: hay que reponerlo */
    } while (!atomic_compare_exchange_weak(&candado, &esperado, 1));
}
void liberar(void) { atomic_store(&candado, 0); }

Esto ya sí cumple la exclusión mutua, sin trucos ni barreras manuales, y para cualquier número de hilos: el bucle reintenta mientras otro tenga el candado, y en cuanto lo suelta un CAS tiene éxito y solo uno gana, porque la comparación y la escritura son indivisibles.

Dos detalles del CAS de C11 que confunden la primera vez. atomic_compare_exchange_weak modifica esperado cuando falla, dejándole el valor real que encontró, y por eso hay que reponerlo a 0 dentro del bucle; la versión _strong no puede fallar espuriamente pero es algo más lenta en algunas arquitecturas, así que en un bucle de reintento usa siempre _weak. Y existe atomic_flag_test_and_set, la primitiva de test-and-set, garantizada como libre de bloqueos en todas las plataformas.

Comparando las dos: ambas sirven para candados y ambas cuestan lo mismo (~20 ns sin contención, ~500 ns con 8 núcleos peleándose), pero solo CAS sirve para contadores, pilas y colas sin candados, porque puede condicionar la escritura al valor previo. A cambio, CAS arrastra el conocido problema ABA en estructuras con punteros. Esos 20 nanosegundos frente al ~1 ns de una escritura normal son el precio de la atomicidad: la instrucción negocia la propiedad exclusiva de la línea de caché con el resto de núcleos a través del protocolo de coherencia. 20 veces más caro que una escritura normal, y ese número es la razón de todo el apartado 14 sobre granularidad.

Un contador atómico para meteo-api

Con esto ya podemos arreglar el primer asterisco del módulo: peticiones_totales++ en /dev/shm/meteora-cache.

/* contador_atomico.c — el contador de peticiones, ahora correcto.
   Esta estructura vive en /dev/shm/meteora-cache, mapeada con MAP_SHARED
   por los 4 trabajadores. Los tipos atómicos funcionan igual entre
   procesos, siempre que sean libres de bloqueos (lock-free). */
struct cache_meteora { atomic_ulong peticiones_totales, peticiones_error; } cache;

void *trabajador(void *arg) {
    (void)arg;
    for (int i = 0; i < VUELTAS; i++)   /* fetch_add: leer+sumar+escribir, INDIVISIBLE */
        atomic_fetch_add_explicit(&cache.peticiones_totales, 1, memory_order_relaxed);
    return NULL;
}
/* main(): comprobar atomic_is_lock_free(), lanzar 4 hilos de 1.000.000 de
   vueltas, join, e imprimir atomic_load(&cache.peticiones_totales). */
$ ./contador_atomico
¿Es libre de bloqueos? sí
Esperado: 4000000  Obtenido: 4000000     ← siempre, en todas las ejecuciones

Tres cosas que aprender de este ejemplo.

atomic_fetch_add es la operación que necesitábamos: leer-sumar-escribir de forma indivisible, en una sola instrucción (lock xadd en x86-64). Ya no hay ventana entre la lectura y la escritura, y por tanto no hay incrementos perdidos. Nunca.

memory_order_relaxed es una optimización consciente y aquí es correcta. Por defecto, las operaciones atómicas de C11 usan memory_order_seq_cst, que además de ser atómicas imponen un orden global entre todas las operaciones de todos los hilos, lo que obliga a barreras costosas. Para un contador de estadísticas no necesitamos ningún orden: solo que la suma sea correcta, no que su valor coordine nada más. relaxed da atomicidad sin ordenación y es notablemente más rápido:

Modo Tiempo (4 hilos, 4M incrementos) Relación
Sin atomicidad (incorrecto) 0,021 s referencia
memory_order_relaxed 0,192 s 9,1×
memory_order_seq_cst (por defecto) 0,241 s 11,5×
Con pthread_mutex_t 0,687 s 32,7×

Cuidado: relaxed solo es correcto cuando el valor no se usa para deducir nada sobre otras variables; si el contador fuera una bandera del tipo "los datos ya están listos", sería un error grave, y ante la duda el modo por defecto es más lento pero nunca incorrecto. Fíjate además en que un contador atómico es mucho más barato que un mutex: 0,192 s frente a 0,687 s, 3,6 veces más rápido. Regla general: si la sección crítica es una sola operación sobre una sola variable, usa un atómico, no un candado. Y para Meteora, con procesos y no hilos, los tipos atómicos funcionan igual entre procesos cuando la variable está en memoria compartida, siempre que atomic_is_lock_free() sea verdadero —si no lo fuera, la implementación usaría un candado interno de la biblioteca, privado de cada proceso y por tanto inútil—. Para tipos de 8 bytes o menos en x86-64, siempre lo es.

Espera activa y spinlocks

El candado que construimos con CAS tiene una característica que hay que examinar: espera activa (busy waiting o spinning). El hilo que no consigue el candado se queda dando vueltas en un bucle, consumiendo CPU sin avanzar, y a ese tipo de candado se le llama spinlock.

/* spinlock.c — con la optimización de espera de x86 */
typedef struct { atomic_flag ocupado; } spinlock_t;

void spin_lock(spinlock_t *s) {   /* PAUSE: optimización de espera de x86 */
    while (atomic_flag_test_and_set_explicit(&s->ocupado, memory_order_acquire))
        __builtin_ia32_pause();
}
void spin_unlock(spinlock_t *s) {
    atomic_flag_clear_explicit(&s->ocupado, memory_order_release);
}

Esa instrucción pause le dice al procesador "estoy en un bucle de espera": reduce el consumo de energía, evita la penalización por especulación fallida al salir del bucle y, con hyperthreading, cede recursos de ejecución al hilo hermano. Un spinlock sin pause puede ser 2 o 3 veces más lento que uno con ella. Es una línea que la gente olvida constantemente.

¿Cuándo tiene sentido quemar CPU esperando? La decisión se reduce a comparar dos números: dormirse y despertarse cuesta ~2-5 µs (dos cambios de contexto más la gestión de la cola de espera), y girar cuesta lo que dure la sección crítica. Si la sección crítica dura menos que el coste de dormirse, girar sale más barato; si dura más, gana dormir.

Situación ¿Spinlock? Por qué
Sección crítica de ~50 ns (incrementar un contador) Girar 50 ns cuesta menos que dormirse 3 µs
Sección crítica de ~10 µs (recorrer una lista corta) Dudoso Medir; suele ganar el mutex adaptativo
Sección crítica con E/S o malloc Nunca Puede durar milisegundos
Contexto de interrupción del núcleo Obligatorio Un manejador no puede dormir (módulo 2)
Más hilos que núcleos, o un solo núcleo Nunca El que gira impide ejecutarse al que tiene el candado

La última fila describe el desastre clásico: con un solo núcleo, el hilo A con el candado y el hilo B girando, B consume su cuanto entero —milisegundos— sin avanzar, porque A no puede ejecutarse para soltarlo. El spinlock ha convertido una espera de 50 nanosegundos en una de varios milisegundos: un factor de 100.000. Existe además la inversión de prioridad: un hilo de baja prioridad tiene el candado, uno de alta gira esperándolo, y el planificador nunca ejecuta al de baja porque el de alta está listo, así que el sistema se cuelga. Fue la causa del famoso fallo del Mars Pathfinder en 1997, que reiniciaba periódicamente en Marte, y la solución es la herencia de prioridad —el poseedor hereda temporalmente la prioridad del que espera—, que en POSIX se activa con pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_INHERIT).

En Linux, spin_lock() es la primitiva estándar del núcleo, precisamente porque en contexto de interrupción no hay alternativa: no se puede dormir. En espacio de usuario existe pthread_spinlock_t, pero su uso correcto es raro.

Bloqueo con suspensión y el papel del planificador

La alternativa a girar es dormir: si el candado está ocupado, el flujo pide al núcleo que lo suspenda y lo despierte cuando esté libre. El mecanismo completo, conectando con el módulo 2: el hilo intenta adquirir el candado con una operación atómica y falla; llama al núcleo, que lo pone en estado S (dormido interrumpible) y lo encola en la cola de espera asociada al candado; el planificador lo saca de la cola de listos y elige a otro, con cero CPU consumida; cuando el poseedor libera, el núcleo saca un hilo de esa cola y lo pone en R; y el planificador lo ejecutará cuando le toque según su vruntime.

Comparadas, girar consume el 100 % de un núcleo mientras espera y dormir el 0 %; girar es desastroso con más hilos que núcleos y dormir es imposible en contexto de interrupción; girar despierta al instante y dormir depende del planificador. Pero la observación decisiva es otra: cuando no hay contención, las dos cuestan lo mismo (~20 ns), porque ambas se reducen a una operación atómica que tiene éxito a la primera. Esa es la observación que da lugar al diseño de futex y la que explica por qué un mutex POSIX es casi gratis en el caso común.

Mutex POSIX: el contador de Meteora arreglado

Un mutex (de mutual exclusion) es la primitiva estándar de exclusión mutua en espacio de usuario. Sus dos propiedades definitorias son que es binario (libre u ocupado, sin estados intermedios) y que tiene propietario: solo el hilo que lo bloqueó puede desbloquearlo.

/* mutex_meteora.c — protegiendo la caché completa, no solo un contador */
struct cache_meteora {
    pthread_mutex_t candado;         /* ← el candado vive CON los datos */
    unsigned long peticiones_totales;
    unsigned int  n_ultimas;
    struct Lectura ultimas[1024];
} cache;

void *trabajador(void *arg) {
    (void)arg;
    struct Lectura l = { .estacion_id = 41, .temperatura = 21.5f };
    for (int i = 0; i < VUELTAS; i++) {
        pthread_mutex_lock(&cache.candado);
        /* ---- SECCIÓN CRÍTICA: lo más corta posible ---- */
        cache.peticiones_totales++;
        cache.ultimas[cache.n_ultimas % 1024] = l;   /* 24 bytes, no atómico */
        cache.n_ultimas++;
        /* ---- FIN DE LA SECCIÓN CRÍTICA ---- */
        pthread_mutex_unlock(&cache.candado);
    }
    return NULL;
}
/* En main(): pthread_mutex_init(&cache.candado, NULL), lanzar 4 hilos de
   500.000 vueltas, join, y pthread_mutex_destroy al final. */

La salida es Esperado: 2000000 Obtenido: 2000000 Tiempo: 0.412 s. Ahora está resuelto el problema completo: no solo el contador, sino la escritura de la struct Lectura de 24 bytes, que ningún tipo atómico puede hacer indivisible por sí solo. Un mutex protege una región de código arbitrariamente compleja, y esa es su ventaja frente a los atómicos.

Dos decisiones de diseño del ejemplo que conviene copiar. El candado vive dentro de la estructura que protege, la convención que hace el código mantenible: quien vea struct cache_meteora sabe inmediatamente qué candado protege qué datos, mientras que un mutex global suelto en otro fichero es una fuente inagotable de olvidos. Y la sección crítica es lo más corta posible: todo lo que no necesite protección (calcular l, preparar la respuesta HTTP, escribir en el log) va fuera, porque cuanto más tiempo se tenga el candado, más esperan los demás y peor escala.

Para procesos distintos, como los trabajadores de meteo-api que comparten /dev/shm/meteora-cache, hay que declararlo compartido entre procesos explícitamente, o no funcionará:

pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED);   /* ← imprescindible */
pthread_mutexattr_setrobust(&attr, PTHREAD_MUTEX_ROBUST);      /* ← muy recomendable */
pthread_mutex_init(&cache->candado, &attr);   /* cache está en memoria compartida */

PTHREAD_MUTEX_ROBUST resuelve un problema específico de los procesos: si un trabajador muere con el candado tomado, sin robust el candado queda bloqueado para siempre y todos los demás se cuelgan; con robust, el siguiente en intentarlo recibe EOWNERDEAD, puede reparar el estado inconsistente y llamar a pthread_mutex_consistent() para reactivarlo. Es la diferencia entre un servicio que se recupera de la muerte de un trabajador y uno que se queda colgado hasta que alguien lo reinicie a mano.

Midiendo la contención

Los números anteriores merecen mirarse juntos. Con 4 hilos incrementando el mismo contador 500.000 veces cada uno:

Estrategia Tiempo Coste por operación
Sin protección (incorrecto) 0,013 s 6,5 ns
Atómico relaxed 0,096 s 48 ns
Mutex POSIX 0,412 s 206 ns
Mutex con 8 hilos 1,890 s 472 ns
Mutex con 16 hilos 4,310 s 539 ns

Dos lecciones cuantitativas: el mutex cuesta unos 200 ns por operación con contención moderada, unas 30 veces más que la operación desprotegida; y, más importante, el coste por operación crece con el número de hilos —de 4 a 16 hilos, el coste unitario se multiplica por 2,6—. No solo no escala: empeora. Es la escalabilidad negativa que anticipábamos en 03-01 al hablar de la ley de Amdahl, y la razón de que el apartado 14 trate sobre granularidad.

Semáforos contadores y binarios

Un semáforo, inventado por Dijkstra en 1965, es un contador entero no negativo con dos operaciones atómicas: wait() (históricamente P, de proberen) decrementa el contador y, si queda negativo, bloquea al flujo; post() (históricamente V, de verhogen) lo incrementa y, si había alguien bloqueado, despierta a uno. La interpretación intuitiva es directa: el contador representa cuántas unidades del recurso quedan disponibles.

/* semaforo.c — limitar a 3 las consultas simultáneas a la base de datos */
sem_t plazas;

void *peticion(void *arg) {
    long id = (long)arg;
    sem_wait(&plazas);                /* pido una plaza; si no hay, espero */
    printf("[peticion %ld] consultando la base de datos\n", id);
    usleep(200000);                   /* la consulta tarda 200 ms */
    sem_post(&plazas);                /* devuelvo la plaza */
    return NULL;
}

int main(void) {
    sem_init(&plazas, 0, 3);          /* 3 = plazas iniciales; 0 = solo hilos */
    pthread_t h[10];
    for (long i = 0; i < 10; i++) pthread_create(&h[i], NULL, peticion, (void *)i);
    for (int i = 0; i < 10; i++) pthread_join(h[i], NULL);
    sem_destroy(&plazas);
    return 0;
}

Al ejecutarlo se ve el efecto: las peticiones 0, 1 y 2 empiezan de inmediato; la 3 no empieza hasta que una de las tres termina, unos 200 ms después. Diez peticiones tardan 800 ms (cuatro tandas de 200 ms) en lugar de los 200 ms que tardarían todas a la vez. El semáforo ha impuesto un límite de concurrencia de 3, que es exactamente lo que queríamos. Un semáforo inicializado a 1 se llama semáforo binario y parece equivalente a un mutex. No lo es, y la diferencia importa:

Mutex Semáforo binario
Propiedad : solo el que bloqueó desbloquea No: cualquiera puede hacer post
Uso natural Proteger una sección crítica Señalizar entre flujos
Herencia de prioridad y recursividad Disponibles No
Detección de errores Desbloquear uno ajeno da error Es una operación legítima
Seguro en un manejador de señal No : sem_post es async-signal-safe

La propiedad separa los dos usos, y de ahí sale la regla práctica: para proteger datos compartidos, mutex —la propiedad convierte en error detectable el desbloqueo por otro hilo, permite herencia de prioridad y expresa mejor la intención—; para señalizar que algo ha ocurrido o contar recursos, semáforo —un productor hace sem_post y un consumidor sem_wait: son flujos distintos por diseño, y ahí un mutex sería incorrecto—. La última fila de la tabla añade un detalle útil: sem_post() es de las poquísimas funciones seguras dentro de un manejador de señal (lo vimos en la lección de IPC), lo que la convierte en la forma canónica de que un manejador despierte al bucle principal.

Los semáforos POSIX vienen sin nombre (sem_init, para hilos, o para procesos si está en memoria compartida y el segundo argumento es 1) y con nombre (sem_open("/meteora-plazas", ...), que crea /dev/shm/sem.meteora-plazas y sirve para procesos sin parentesco).

Variables de condición y monitores

Los mutex resuelven "solo uno a la vez" y los semáforos "como mucho N a la vez". Falta un tercer problema, muy distinto: esperar a que se cumpla una condición sobre los datos. El caso típico: un trabajador de meteo-api quiere responder con datos frescos, pero el agregador aún no ha actualizado la caché, así que necesita esperar a que n_ultimas > 0. Sin herramientas, la única opción sería girar comprobando, y girar con un mutex tomado es un interbloqueo garantizado.

Una variable de condición es una cola de espera asociada a una condición lógica, con tres operaciones: wait(cond, mutex) suelta el mutex atómicamente, duerme, y al despertar vuelve a tomarlo; signal(cond) despierta a uno de los que esperan; broadcast(cond) despierta a todos.

Esa palabra "atómicamente" es la clave de todo el mecanismo: si soltar el mutex y dormirse fueran dos pasos, otro hilo podría colarse entre ambos, cambiar la condición y hacer signal antes de que durmiéramos, con lo que la señal se perdería y dormiríamos para siempre. Es el problema de la señal perdida (lost wakeup), y la atomicidad de wait es lo que lo impide.

/* condicion.c — el agregador avisa y meteo-api espera */
struct cache_meteora {
    pthread_mutex_t candado;
    pthread_cond_t  hay_datos;
    unsigned int    n_ultimas;
} cache;

void *consumidor(void *arg) {
    pthread_mutex_lock(&cache.candado);
    while (cache.n_ultimas == 0)                    /* ← WHILE, nunca IF */
        pthread_cond_wait(&cache.hay_datos, &cache.candado);
    cache.n_ultimas--;                              /* consumo uno */
    pthread_mutex_unlock(&cache.candado);
    return NULL;
}
void *productor(void *arg) {
    pthread_mutex_lock(&cache.candado);
    cache.n_ultimas = 3;
    pthread_cond_broadcast(&cache.hay_datos);       /* despierta a todos */
    pthread_mutex_unlock(&cache.candado);
    return NULL;
}

La regla más importante de esta lección: pthread_cond_wait va SIEMPRE dentro de un bucle while, nunca de un if. Hay tres razones independientes y basta una para justificarlo. Despertares espurios: POSIX permite explícitamente que pthread_cond_wait retorne sin que nadie haya hecho signal —no es un fallo de implementación, permitirlo la hace más simple y rápida, y en Linux ocurre de verdad cuando una señal interrumpe la espera—. Otro hilo puede haberse adelantado: con broadcast despiertan tres consumidores pero solo hay una lectura disponible; el primero en recuperar el mutex la consume y los otros dos encuentran la condición falsa otra vez. Y la condición puede haber cambiado por otra vía, porque en código real más de un sitio modifica el estado.

Con while, cualquiera de esos casos simplemente vuelve a dormir; con if, produce corrupción silenciosa. Este error es probablemente el más común de toda la programación concurrente, y el que más caro sale porque falla raras veces. Entre signal y broadcast, la elección es: signal despierta a uno y es más barato, pero mal usado puede despertar al hilo equivocado y dejar a todos dormidos; broadcast despierta a todos, es más caro (tormenta de despertares) y no puede fallar. Usa broadcast si tienes dudas; signal solo cuando todos los que esperan comprueben la misma condición y haya una sola unidad disponible.

Monitores: el mismo concepto en Python

Un monitor empaqueta los datos, el mutex y las variables de condición en una unidad donde la exclusión mutua es automática. Java lo tiene con synchronized; Python lo ofrece con threading.Condition, que incorpora su propio candado:

# monitor.py — el equivalente en Python, con el mismo patrón
import threading

class CacheMeteora:
    def __init__(self):
        self._cond = threading.Condition()   # incluye su propio Lock
        self._lecturas = []

    def consumir(self, id_api):
        with self._cond:                     # equivale a mutex_lock/unlock
            while not self._lecturas:        # ← WHILE, igual que en C
                self._cond.wait()
            l = self._lecturas.pop(0)
            print(f"[api-{id_api}] consumida {l}")
            return l

    def publicar(self, lectura):
        with self._cond:
            self._lecturas.append(lectura)
            self._cond.notify_all()          # = pthread_cond_broadcast

La correspondencia es exacta: with self._cond es el par lock/unlock, wait() suelta el candado atómicamente y lo recupera al despertar, notify()/notify_all() son signal/broadcast, y la regla del while se mantiene idéntica porque Python también admite despertares espurios. Lo que gana el monitor es que el with garantiza que el candado se libera aunque haya una excepción, eliminando de raíz el olvido de unlock.

Bloqueos de lectura/escritura y barreras

Un mutex trata igual a lectores y escritores: solo uno a la vez. Pero varios lectores simultáneos no se estorban —si nadie modifica el dato, leerlo desde diez hilos a la vez es seguro—, y un mutex desaprovecha esa oportunidad. Un bloqueo de lectura/escritura (pthread_rwlock_t) distingue lectura compartida (muchos lectores a la vez) de escritura exclusiva (un escritor solo, sin lectores):

pthread_rwlock_t cerrojo = PTHREAD_RWLOCK_INITIALIZER;

pthread_rwlock_rdlock(&cerrojo);       /* meteo-api lee: EN PARALELO */
float t = cache.ultimas[i].temperatura;
pthread_rwlock_unlock(&cerrojo);

pthread_rwlock_wrlock(&cerrojo);       /* el agregador actualiza: EN EXCLUSIVA */
recalcular_medias(&cache);
pthread_rwlock_unlock(&cerrojo);

La pregunta es cuándo compensa, porque un rwlock no es gratis: su estructura interna es más compleja que la de un mutex y adquirirlo cuesta más. Con secciones críticas cortas (~100 ns) el mutex gana o empata hasta proporciones de 99/1, y solo a partir de 99,9/0,1 conviene plantearse algo mejor —RCU o doble búfer—. Con secciones largas (~10 µs), en cambio, el rwlock gana ya desde 90/10 y gana mucho a partir de 99/1.

La regla resumida: el rwlock compensa cuando las lecturas dominan claramente y la sección crítica es lo bastante larga como para que el paralelismo entre lectores compense su mayor coste de adquisición; con secciones de nanosegundos, el sobrecoste se come la ventaja. El caso de Meteora encaja de sobra: meteo-api lee 1.200 veces por segundo y el agregador escribe una vez por hora, una proporción de 4.320.000 a 1. Eso sí, el rwlock tiene un problema que hay que conocer: la inanición de escritores. Si llegan lectores continuamente, puede que nunca haya un instante sin lectores y el escritor espere indefinidamente. Linux ofrece pthread_rwlockattr_setkind_np(&attr, PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP) para darles preferencia. Ese compromiso entre favorecer a unos u otros es justamente el problema de lectores-escritores, que desarrollaremos en Problemas Clásicos de Concurrencia.

Barreras

Una barrera sincroniza un grupo de hilos en un punto: ninguno pasa hasta que todos hayan llegado.

pthread_barrier_init(&barrera, NULL, 4);       /* 4 hilos */

void *fase_agregacion(void *arg) {
    calcular_medias_de_mi_trozo();
    pthread_barrier_wait(&barrera);            /* espero a los otros 3 */
    fusionar_resultados();     /* aquí TODOS los parciales ya están calculados */
    return NULL;
}

Es la primitiva natural para cálculos por fases, cuando la fase N+1 necesita los resultados completos de la fase N: en el agregador, con 4 hilos calculando medias parciales por rango de estaciones, garantiza que nadie fusione antes de que todos los parciales existan. Su coste es el del hilo más lento —una barrera hace que todos vayan al ritmo del peor—, así que el reparto equilibrado del trabajo importa mucho más en código con barreras que sin ellas.

Cómo lo implementa Linux: futex

Ya podemos responder a la pregunta que quedó abierta en el apartado 7: si girar es malo con contención y dormir cuesta 2-5 µs siempre, ¿cómo consigue un pthread_mutex_lock ser barato? La respuesta es futex (fast userspace mutex), la llamada al sistema que Linux introdujo en 2002 y sobre la que se construyen todos los mutex, semáforos y variables de condición de glibc. Su idea es tan simple como brillante:

El caso común —no hay contención— se resuelve enteramente en espacio de usuario con una operación atómica, sin llamar al núcleo. Solo cuando hay contención real se paga el precio de una llamada al sistema.

El mutex es, en esencia, un int en memoria compartida. La lógica simplificada:

/* Versión conceptual de lo que hace glibc. 0=libre, 1=ocupado, 2=ocupado+esperando */
void mutex_lock(int *m) {
    int esperado = 0;
    if (atomic_compare_exchange_strong(m, &esperado, 1))
        return;                              /* CAMINO RÁPIDO: ~20 ns, SIN syscall */
    do {                                     /* CAMINO LENTO: alguien lo tiene */
        if (esperado == 2 || atomic_exchange(m, 2) != 0)
            futex(m, FUTEX_WAIT, 2, NULL);   /* ← syscall: duerme */
        esperado = 0;
    } while (!atomic_compare_exchange_strong(m, &esperado, 2));
}

void mutex_unlock(int *m) {
    if (atomic_fetch_sub(m, 1) != 1) {       /* valía 2: había alguien esperando */
        atomic_store(m, 0);
        futex(m, FUTEX_WAKE, 1, NULL);       /* ← syscall: despierta a uno */
    }                                        /* si valía 1: NO hay syscall */
}

Los tres valores del entero codifican todo el estado: 0 libre, 1 ocupado sin nadie esperando, 2 ocupado con al menos uno esperando. Ese tercer valor es lo que permite que unlock sepa si necesita despertar a alguien o puede salir sin llamar al núcleo. Los números lo dicen todo: un lock sin contención cuesta ~20 ns sin ninguna llamada al sistema y un unlock sin nadie esperando ~15 ns, también sin syscall; solo cuando hay contención aparecen FUTEX_WAIT (~2-5 µs) y FUTEX_WAKE (~1-2 µs).

Y en un programa bien diseñado la inmensa mayoría de las adquisiciones no tienen contención, por lo que un mutex POSIX casi nunca llega a hablar con el núcleo. Se comprueba con strace, y el resultado es revelador:

$ strace -c -f ./mutex_meteora 2>&1 | grep -E "futex|calls"
% time     seconds  usecs/call     calls    errors syscall
 89.31    0.041205          49       841           futex

841 llamadas a futex para 2.000.000 de adquisiciones: una por cada 2.378 bloqueos, con el 99,96 % resuelto íntegramente en espacio de usuario. Si cada adquisición hubiera implicado una llamada al sistema, el programa habría tardado unos 4 segundos en lugar de 0,412. Dos detalles más: los futex funcionan entre procesos porque operan sobre la dirección física de la página —por eso los mutex en /dev/shm/meteora-cache funcionan con PTHREAD_PROCESS_SHARED—, y el núcleo indexa las colas de espera por esa dirección en un hash global. Para ver la contención en vivo, perf lock contention o el propio strace -c.

Barreras de memoria y por qué volatile no sirve

Queda una pieza que ha ido apareciendo desde el apartado 3 y que hay que cerrar: la reordenación de memoria. Ni el compilador ni el procesador ejecutan tus instrucciones en el orden en que las escribiste; ambos las reordenan libremente, con una única garantía: el resultado debe ser el mismo desde el punto de vista de un solo hilo. Esa cláusula es la trampa, porque en cuanto hay otro hilo mirando, la reordenación se vuelve observable.

datos.temperatura = 21.5f;      /* (A) preparo el dato    */  →  listo = 1;
listo = 1;                      /* (B) anuncio que va     */  →  datos.temperatura = 21.5f;
/*      lo que escribes                                       lo que puede ejecutarse */

Para un solo hilo ambas versiones son equivalentes: nadie mira listo entre medias. Pero otro hilo que haga while (!listo); usar(datos.temperatura); puede ver listo == 1 y leer una temperatura basura. Hay dos niveles de reordenación, y hay que combatir los dos:

Nivel Quién reordena Qué lo impide
Compilador GCC/Clang al optimizar volatile, asm volatile("":::"memory"), atómicos
Procesador Ejecución fuera de orden, store buffer Solo barreras de memoria (mfence, lock) o atómicos

Y aquí está la respuesta a la pregunta del título:

volatile impide la reordenación del compilador, pero no la del procesador, y no hace atómica ninguna operación.

Resuelve dos de los cinco problemas —que el compilador cachee la variable en un registro y que el compilador reordene los accesos— y deja intactos los tres que importan: que el procesador reordene, que la operación sea leer-modificar-escribir, y que otro núcleo vea un valor obsoleto. Por eso volatile long contador; contador++; sigue perdiendo incrementos exactamente igual que sin volatile: se lee de memoria, se incrementa en un registro y se escribe, con la misma ventana de siempre.

El uso correcto de volatile es muy estrecho: registros de hardware mapeados en memoria (donde cada lectura tiene efectos laterales) y la bandera volatile sig_atomic_t de un manejador de señal, correcta porque el manejador se ejecuta en el mismo hilo y no hay dos núcleos implicados. Para todo lo demás, la respuesta es _Atomic / <stdatomic.h>, cuyos tipos llevan incorporadas las barreras necesarias según el orden de memoria que pidas:

Orden de memoria Qué garantiza Coste típico en x86-64
relaxed Solo atomicidad, ninguna ordenación Mínimo
acquire (en lecturas) Nada posterior se adelanta a esta lectura Gratis en x86
release (en escrituras) Nada anterior se retrasa tras esta escritura Gratis en x86
acq_rel Ambas Gratis en x86
seq_cst (por defecto) Orden total global entre todos los hilos mfence: ~20-30 ns

El patrón release/acquire resuelve el ejemplo de arriba y merece memorizarse: el escritor prepara los datos y luego hace una escritura release de la bandera; el lector hace una lectura acquire y, si la ve puesta, tiene garantizado que ve todo lo que el escritor hizo antes. Es la base de la publicación segura de datos entre hilos.

Buena noticia final: si usas mutex, semáforos o variables de condición, todo esto está resuelto para ti, porque pthread_mutex_lock incluye una barrera acquire y pthread_mutex_unlock una release. Solo necesitas entender las barreras si escribes código sin candados, y ahí es donde la mayoría de la gente se equivoca.

Granularidad del bloqueo y coste de la contención

Última cuestión, de diseño: ¿cuánto debe proteger un candado? La elección determina el rendimiento del sistema entero:

Granularidad Qué protege Ventaja Inconveniente
Gruesa Un candado para toda la estructura Simple, difícil equivocarse Contención alta, no escala
Fina Un candado por elemento o por partición Escala bien Complejo, riesgo de interbloqueo
Por partición (sharding) N candados, elegidos por hash Buen equilibrio Requiere buena función de hash
Sin candados Solo operaciones atómicas Máximo rendimiento Muy difícil de escribir correctamente

Un ejemplo sobre la caché de Meteora. Con un candado global para ultimas[1024], los 4 trabajadores compiten siempre, aunque toquen entradas distintas. Con particionado:

#define N_PARTICIONES 16
struct cache_meteora {
    /* Cada mutex en su propia línea de caché de 64 bytes: sin false sharing */
    struct { pthread_mutex_t m; char relleno[64 - sizeof(pthread_mutex_t)]; }
        candados[N_PARTICIONES];
    struct Lectura ultimas[1024];
};

void guardar(struct cache_meteora *c, struct Lectura *l) {
    int p = l->estacion_id % N_PARTICIONES;   /* cada estación, siempre la misma */
    pthread_mutex_lock(&c->candados[p].m);
    c->ultimas[l->estacion_id % 1024] = *l;
    pthread_mutex_unlock(&c->candados[p].m);
}

Ahora dos trabajadores que atiendan estaciones de particiones distintas no compiten en absoluto: con 16 particiones y estaciones bien repartidas, la probabilidad de colisión cae a 1/16. Y el relleno hasta 64 bytes no es decorativo: sin él, varios mutex caerían en la misma línea de caché y los núcleos se la invalidarían mutuamente al bloquear candados distintos. Es el false sharing de 03-01, y puede anular por completo la ventaja del particionado.

Medición sobre meteo-01 con 8 hilos y 4 millones de operaciones:

Estrategia Tiempo Aceleración
Un candado global 3,84 s 1,00×
4 particiones 1,21 s 3,17×
16 particiones 0,53 s 7,25×
64 particiones 0,51 s 7,53×
16 particiones sin relleno (false sharing) 2,97 s 1,29×

Tres conclusiones: el particionado funciona (7,25× con 8 hilos es casi el máximo posible), hay rendimientos decrecientes (de 16 a 64 particiones apenas se gana, porque con 8 hilos ya casi no hay colisiones) y olvidar el relleno arruina el diseño (2,97 s frente a 0,53 s: un factor de 5,6 perdido por no alinear a la línea de caché).

Las reglas de ingeniería que se derivan: empieza con granularidad gruesa, porque un candado simple y correcto vale más que uno fino y roto; mide antes de refinar, ya que si el candado se adquiere sin contención el 99 % de las veces, refinarlo no ganará nada; mantén la sección crítica corta, porque sacar de ella un printf o un malloc suele dar más que cualquier rediseño del candado; nunca hagas E/S con un candado tomado, ya que un write() a disco puede tener a los demás esperando milisegundos, cuatro órdenes de magnitud más de lo previsto; alinea los candados a la línea de caché cuando tengas varios; y toma siempre los candados en el mismo orden, que es la regla que evita los interbloqueos y a la que dedicaremos Interbloqueos entera.

Errores Comunes y Consejos

Usar if en lugar de while con pthread_cond_wait. El error más común y más caro: los despertares espurios existen, y con broadcast varios hilos despiertan aunque solo haya trabajo para uno, así que con if todos siguen adelante sobre una condición falsa. Siempre while.

Creer que volatile sirve para sincronizar. Impide la reordenación del compilador, no la del procesador, y no hace atómica ninguna operación: volatile int contador; contador++; pierde incrementos exactamente igual. Usa <stdatomic.h> o un mutex.

Olvidar unlock en un camino de error. Un return prematuro dentro de la sección crítica deja el candado tomado para siempre y cuelga a todos los demás. En C, un solo punto de salida o macros de limpieza; en C++, std::lock_guard; en Python, with lock:.

Usar un spinlock donde debía ir un mutex, o poner varios mutex en la misma línea de caché. Si la sección crítica dura más de unos cientos de nanosegundos, o hay más hilos que núcleos, un spinlock quema núcleos enteros esperando: en espacio de usuario, la respuesta por defecto es siempre el mutex. Y varios candados independientes que comparten línea de caché salen 5,6 veces más lentos, como medimos arriba; rellena hasta 64 bytes o usa alignas(64).

Proteger con el candado equivocado, o no usar PTHREAD_PROCESS_SHARED en memoria compartida. Dos secciones críticas sobre el mismo dato con candados distintos no se excluyen entre sí, y un mutex por defecto puesto en /dev/shm/meteora-cache no da error: simplemente no excluye nada. La convención de guardar el candado dentro de la estructura que protege evita casi todos los casos del primer tipo.

Consejo: el orden de preferencia práctico es (1) no compartir, (2) datos inmutables, (3) una operación atómica, (4) un mutex, (5) rwlock o granularidad fina si has medido contención, (6) código sin candados solo si eres especialista. Baja un escalón solo cuando el anterior no baste, y con una medición en la mano. Y documenta qué protege cada candado: un comentario /* protege: peticiones_totales, n_ultimas, ultimas[] */ junto a la declaración es lo primero que buscará quien depure un cuelgue a las tres de la mañana.

Ejercicios

Ejercicio 1: comparar cuatro estrategias

Implementa un contador compartido incrementado por N hilos un millón de veces cada uno, con cuatro estrategias: sin protección, con atomic_fetch_add en modo relaxed, con pthread_mutex_t y con pthread_spinlock_t. Mide el tiempo con N = 1, 2, 4 y 8 hilos, verifica la corrección de cada una y construye la tabla. Explica por qué el spinlock se comporta como se comporta al pasar de 4 a 8 hilos en una máquina de 8 núcleos con otros procesos activos.

Ejercicio 2: el error del if

Escribe un programa con una cola compartida de capacidad 1, tres consumidores que esperan con una variable de condición y un productor que hace broadcast tras insertar un elemento. Implementa la espera primero con if y después con while. Ejecuta ambas versiones y explica exactamente qué ocurre en la versión con if, incluyendo qué imprime y por qué.

Ejercicio 3: rwlock frente a mutex

Implementa la caché de Meteora con dos variantes: protegida por pthread_mutex_t y por pthread_rwlock_t. Lanza 7 hilos lectores y 1 escritor, donde cada lectura recorre 100 elementos del array y cada escritura actualiza 100 elementos. Mide el número total de operaciones por segundo de cada variante y determina, variando la proporción de escrituras (1 %, 10 %, 50 %), a partir de qué punto el mutex vuelve a ser mejor.

Soluciones

Solución 1

/* comparar.c (núcleo) — gcc -O2 -pthread; argumentos: n_hilos y modo (0-3) */
void *trabajador(void *a) {
    (void)a;
    for (int i = 0; i < VUELTAS; i++) switch (modo) {
        case 0: c_plano++; break;
        case 1: atomic_fetch_add_explicit(&c_atomico, 1, memory_order_relaxed); break;
        case 2: pthread_mutex_lock(&mtx);  c_mutex++; pthread_mutex_unlock(&mtx); break;
        case 3: pthread_spin_lock(&spn);   c_spin++;  pthread_spin_unlock(&spn);  break;
    }
    return NULL;
}
/* main(): pthread_spin_init, cronometrar con CLOCK_MONOTONIC alrededor de
   crear n hilos y hacerles join, e imprimir esperado, obtenido y tiempo. */

Resultados en meteo-01 (8 núcleos):

Hilos Sin protección Atómico relaxed Mutex Spinlock
1 0,003 s ✓ 0,006 s ✓ 0,021 s ✓ 0,011 s ✓
2 0,009 s 0,041 s ✓ 0,158 s ✓ 0,092 s ✓
4 0,013 s 0,096 s ✓ 0,412 s ✓ 0,381 s ✓
8 0,021 s 0,204 s ✓ 1,890 s ✓ 4,720 s

(✓ = resultado correcto; ✗ = incrementos perdidos.) Lecturas de la tabla. La versión sin protección es siempre la más rápida y siempre incorrecta a partir de 2 hilos: la sincronización tiene un coste real y hay que pagarlo. El atómico es 4-9 veces más rápido que el mutex, porque una sola instrucción lock xadd sustituye a todo el protocolo de adquisición y liberación.

Por qué el spinlock se dispara con 8 hilos. Hasta 4 hilos el spinlock gana al mutex (0,381 s frente a 0,412 s): con una sección crítica de nanosegundos, girar cuesta menos que dormirse. Con 8 hilos en 8 núcleos se dispara a 4,72 s, 2,5 veces peor, por dos causas que se suman. Primera: no hay ningún núcleo realmente libre, porque el sistema (shell, systemd, los ksoftirqd) también quiere CPU, así que cuando el planificador expulsa al hilo que tiene el spinlock, los otros siete siguen girando milisegundos enteros sin que nadie pueda avanzar. Segunda: cada giro es un CAS que exige la propiedad exclusiva de la línea de caché, y ocho núcleos peleándose por ella generan una tormenta de tráfico de coherencia que ralentiza incluso al que sí tiene el candado.

El mutex, en cambio, duerme a los que no pueden entrar: dejan de consumir CPU y de invalidar la línea de caché. Regla: en espacio de usuario, mutex por defecto; spinlock solo con secciones críticas de nanosegundos, menos hilos que núcleos y una medición que lo respalde.

Solución 2

/* if_vs_while.c (núcleo) */
void *consumidor(void *arg) {
    long id = (long)arg;
    pthread_mutex_lock(&m);
    if (usar_if) { if    (elementos == 0) pthread_cond_wait(&c, &m); }
    else         { while (elementos == 0) pthread_cond_wait(&c, &m); }
    elementos--;                                    /* ¡puede quedar negativo! */
    printf("[consumidor %ld] consume; quedan %d\n", id, elementos);
    pthread_mutex_unlock(&m);
    return NULL;
}
/* main(): lanzar 3 consumidores, sleep(1), y entonces, con el mutex tomado,
   poner elementos = 1 (UN solo elemento) y hacer broadcast (a los TRES). */
$ ./if_vs_while if                     $ ./if_vs_while
[consumidor 0] consume; quedan 0       [consumidor 0] consume; quedan 0
[consumidor 1] consume; quedan -1  ←   (los otros dos siguen esperando:
[consumidor 2] consume; quedan -2  ←    correcto, no hay más elementos)
elementos final: -2

Qué ocurre con if. El broadcast despierta a los tres consumidores, pero solo hay un elemento. Los tres estaban dentro de pthread_cond_wait, recuperan el mutex uno tras otro y los tres continúan más allá del if, porque un if comprueba la condición una sola vez, antes de dormir. El consumidor 0 consume el único elemento y deja elementos = 0; el 1 y el 2, ya despiertos, no vuelven a comprobar nada y decrementan igualmente, dejando el contador en -2.

Aquí el daño es un número negativo. En código real es mucho peor: si elementos fuera el índice de un array, tendrías accesos con índice negativo; si fuera un puntero sacado de una cola vacía, un NULL desreferenciado; si fuera un descriptor, un read sobre basura. Y todo ello de forma intermitente, porque solo ocurre cuando varios consumidores esperan a la vez.

Con while, los consumidores 1 y 2 reevalúan elementos == 0 al despertar, comprueban que es cierto y vuelven a dormir. Esa reevaluación es lo que aporta el bucle, y es la razón de que POSIX pueda permitir despertares espurios sin romper ningún programa bien escrito.

Solución 3

/* rwlock_vs_mutex.c (núcleo del experimento) */
void *hilo(void *arg) {
    unsigned semilla = (unsigned)(long)arg;
    while (!parar) {
        int escribe = (rand_r(&semilla) % 100) < pct_escritura;
        if (usar_rw) {
            if (escribe) pthread_rwlock_wrlock(&rwl); else pthread_rwlock_rdlock(&rwl);
        } else pthread_mutex_lock(&mtx);
        double s = 0;                                  /* trabajo: 100 elementos */
        for (int i = 0; i < 100; i++)
            if (escribe) cache_datos[i].temperatura = 21.5f;
            else         s += cache_datos[i].temperatura;
        if (usar_rw) pthread_rwlock_unlock(&rwl); else pthread_mutex_unlock(&mtx);
        atomic_fetch_add_explicit(&ops, 1, memory_order_relaxed);
    }
    return NULL;
}

Resultados con 8 hilos, en miles de operaciones por segundo:

% escrituras Mutex (kops/s) rwlock (kops/s) Ganancia
0,1 % 1.240 6.890 5,56×
1 % 1.235 5.410 4,38×
10 % 1.210 2.180 1,80×
30 % 1.190 1.340 1,13×
50 % 1.180 1.020 0,86× ← peor

Interpretación. Con lecturas dominantes el rwlock gana claramente: los 7 lectores recorren el array en paralelo en lugar de en serie, y la ganancia de 5,56× con un 0,1 % de escrituras se acerca al máximo teórico de 7×. El punto de equilibrio está en torno al 35-40 % de escrituras; a partir de ahí el rwlock es peor que el mutex, por dos motivos que se suman: su estructura interna es más compleja —lleva la cuenta de lectores activos, lo que exige operaciones atómicas adicionales en cada adquisición— y con muchas escrituras los lectores apenas se solapan, así que se paga el sobrecoste sin cobrar la ventaja.

Aplicado a Meteora: meteo-api lee 1.200 veces por segundo y el agregador escribe una vez por hora, un 0,00002 % de escrituras, muy a la izquierda de la primera fila. El rwlock es la elección correcta, y con una proporción tan extrema merece la pena ir un paso más: un esquema de doble búfer en el que el agregador prepara una copia nueva y publica su puntero con un atomic_store en modo release haría que los lectores no tomasen ningún candado en absoluto, ni siquiera el de lectura.

Conclusión

El problema de la sección crítica no se puede resolver bien con variables normales, y hemos visto por qué fallando tres veces: la bandera única rompe la exclusión mutua porque comprobar y actuar son dos operaciones; el turno estricto la garantiza pero viola el progreso, dejando que un hilo inactivo bloquee a otro para siempre; y las dos banderas producen un interbloqueo en cinco líneas. La solución de Peterson los combina y es correcta sobre el papel, con espera limitada óptima, pero solo sirve para dos hilos, es espera activa pura y —lo definitivo— falla en hardware real por la reordenación de memoria salvo que le añadas una barrera explícita.

La salida es el hardware: test-and-set y sobre todo compare-and-swap, que comparan y escriben de forma indivisible en una sola instrucción (lock cmpxchg). Cuestan unos 20 ns, veinte veces más que una escritura normal, porque negocian la propiedad exclusiva de la línea de caché. Sobre CAS hemos arreglado el primer asterisco del módulo: atomic_fetch_add sobre peticiones_totales da el resultado exacto siempre, es 3,6 veces más rápido que un mutex, y funciona igual entre procesos en /dev/shm/meteora-cache mientras sea libre de bloqueos.

De ahí salen las dos familias de espera. Los spinlocks giran: correctos para secciones de nanosegundos y obligatorios en contexto de interrupción, catastróficos con más hilos que núcleos —lo hemos medido: 4,72 s frente a 1,89 s del mutex con 8 hilos—. El bloqueo con suspensión duerme el hilo, cuesta 2-5 µs y consume cero CPU. Los mutex POSIX protegen regiones de código arbitrarias y no solo una variable, por lo que son la respuesta cuando hay que escribir una struct Lectura de 24 bytes de forma indivisible; entre procesos exigen PTHREAD_PROCESS_SHARED, y PTHREAD_MUTEX_ROBUST evita que la muerte de un trabajador cuelgue el servicio entero. Los semáforos cuentan recursos y no tienen propietario, lo que los hace la herramienta de señalización entre flujos distintos y la única segura dentro de un manejador de señal. Las variables de condición resuelven "esperar a que se cumpla algo", con la atomicidad de wait protegiendo contra la señal perdida y la regla más importante de la lección: siempre while, nunca if. Los rwlock permiten lectores simultáneos —5,56× con un 0,1 % de escrituras, pero peor que un mutex a partir del 40 %— y las barreras sincronizan fases de cálculo al ritmo del hilo más lento.

Por debajo, Linux lo implementa todo con futex: el caso sin contención se resuelve entero en espacio de usuario con un CAS de 20 ns, y solo se llama al núcleo para dormir o despertar. Los números lo confirman: 841 llamadas al sistema para 2.000.000 de adquisiciones, el 99,96 % sin tocar el núcleo. Hemos cerrado también la cuenta de volatile: impide la reordenación del compilador pero no la del procesador y no hace atómico nada, así que no sirve para sincronizar; su sitio son los registros de hardware y la bandera de un manejador de señal. Y la granularidad decide el rendimiento: particionar la caché en 16 candados alineados a la línea de caché da 7,25× frente al candado global, mientras que olvidar el relleno de 64 bytes lo hunde a 1,29× por false sharing.

Ya tienes todas las piezas, y ahora viene lo interesante: combinarlas. Un mutex protege un dato, pero ¿cómo se coordina un productor que llena un búfer con un consumidor que lo vacía, sin que el productor escriba en un búfer lleno ni el consumidor lea de uno vacío? ¿Cómo se reparte el acceso entre muchos lectores y un escritor sin que nadie pase hambre? ¿Y por qué cinco filósofos con cinco tenedores se quedan todos bloqueados? Estos patrones tienen nombre propio desde hace sesenta años, son el vocabulario común de la concurrencia, y todo problema real que te encuentres será una variante de alguno de ellos. Los vemos en Problemas Clásicos de Concurrencia.

Fundamentos de Sistemas Operativos

Módulo 1: Introducción a los Sistemas Operativos

Módulo 2: Gestión de Recursos

Módulo 3: Concurrencia

Módulo 4: Estructuras de Archivos

Módulo 5: Protección y Seguridad del Sistema

Módulo 6: Virtualización y Contenedores

Módulo 7: Administración y Diagnóstico en la Práctica

© Copyright 2026. Todos los derechos reservados