La sincronización de hilos en C11 se basa en la gestión de la visibilidad de la memoria y la atomicidad de las operaciones. Para evitar condiciones de carrera (race conditions) al acceder a recursos compartidos, utilizamos mtx_t (mutex), que garantiza que solo un hilo acceda a una sección crítica a la vez. Sin embargo, cuando un hilo necesita esperar a que cambie un estado específico (por ejemplo, que un buffer deje de estar lleno), el uso de un mutex por sí solo es insuficiente y llevaría a un desperdicio de ciclos de CPU mediante busy-waiting. Aquí es donde entran las cnd_t (variables de condición), que permiten que un hilo se suspenda de forma eficiente hasta que otro hilo le notifique un cambio mediante una señal.
Para que este mecanismo funcione, el diseño de la máquina abstracta de C requiere que la variable de condición esté siempre asociada a un mutex. La magia ocurre en cnd_wait: esta función libera el mutex de forma atómica y pone al hilo en estado de espera. Si no fuera atómica, un hilo podría ser interrumpido justo después de liberar el mutex pero antes de dormir, perdiéndose la señal de otro hilo (el problema del lost wakeup). Al despertar, ya sea por cnd_signal o cnd_broadcast, el hilo re-adquiere el mutex automáticamente antes de que cnd_wait retorne.
Este modelo es esencial en arquitecturas de baja latencia y sistemas embebidos donde el control sobre el tiempo de espera es crítico; en esos casos, se utiliza mtx_timedlock con un struct timespec para evitar bloqueos indefinidos. No obstante, si implementas mal la lógica de comprobación, podrías sufrir un “despertar espurio” (spurious wakeup), donde el hilo despierta sin que nadie haya enviado una señal, o un error de lógica si no verificas la condición tras despertar.
#include <threads.h>
#include <stdio.h>
#include <stdbool.h>
#include <stdlib.h>
#define BUFFER_SIZE 5
#define ITEMS_TO_PRODUCE 10
typedef struct {
int buffer[BUFFER_SIZE];
int count;
bool finished;
mtx_t mtx;
cnd_t cond_not_full;
cnd_t cond_not_empty;
} shared_queue_t;
int producer(void *arg) {
shared_queue_t *q = (shared_queue_t *)arg;
for (int i = 0; i < ITEMS_TO_PRODUCE; i++) {
mtx_lock(&q->mtx);
// El uso de 'while' es obligatorio para evitar spurious wakeups
while (q->count == BUFFER_SIZE) {
cnd_wait(&q->cond_not_full, &q->mtx);
}
q->buffer[q->count] = i;
q->count++;
printf("[Productor] Añadido: %d | Buffer: %d\n", i, q->count);
cnd_signal(&q->cond_not_empty);
mtx_unlock(&q->mtx);
}
mtx_lock(&q->mtx);
q->finished = true;
cnd_signal(&q->cond_not_empty);
mtx_unlock(&q->mtx);
return THR_SUCCESS;
}
int consumer(void *arg) {
shared_queue_t *q = (shared_queue_t *)arg;
while (true) {
mtx_lock(&q->mtx);
while (q->count == 0 && !q->finished) {
cnd_wait(&q->cond_not_empty, &q->mtx);
}
if (q->count == 0 && q->finished) {
mtx_unlock(&q->mtx);
break;
}
int item = q->buffer[0]; // En un buffer real sería una cola circular
// Simplificación para el ejemplo: usamos el buffer como cola básica
for(int i = 0; i < q->count - 1; i++) q->buffer[i] = q->buffer[i+1];
q->count--;
printf("[Consumidor] Extraído: %d | Buffer: %d\n", item, q->count);
cnd_signal(&q->cond_not_full);
mtx_unlock(&q->mtx);
}
return THR_SUCCESS;
}
int main(void) {
shared_queue_t q = {
.count = 0,
.finished = false,
.mtx = MTX_PLAIN
};
if (mtx_init(&q.mtx, mtx_plain) != thrd_success ||
cnd_init(&q.cond_not_full) != thrd_success ||
cnd_init(&q.cond_not_empty) != thrd_success) {
return EXIT_FAILURE;
}
thrd_t t1, t2;
if (thrd_create(&t1, producer, &q) != thrd_create(&t2, consumer, &q)) {
return EXIT_FAILURE;
}
thrd_join(t1, NULL);
thrd_join(t2, NULL);
mtx_destroy(&q.mtx);
cnd_destroy(&q.cond_not_full);
cnd_destroy(&q.cond_not_empty);
return EXIT_SUCCESS;
}
En el código anterior, la estructura shared_queue_t encapsula todos los estados que requieren protección. Al inicializar el mutex con mtx_plain, estamos indicando que no es un mutex recursivo; si un hilo intentara bloquearlo dos veces sin liberarlo, se produciría un deadlock.
En la función producer, fíjate en el uso de while (q->count == BUFFER_SIZE). Esta es la defensa contra los spurious wakeups: aunque la señal de cnd_signal sea válida, el hilo debe volver a verificar que realmente hay espacio disponible tras recuperar el control del mutex mediante el retorno de cnd_wait. Si usáramos un if, el hilo podría intentar escribir en un índice inválido si despertara erróneamente.
La función cnd_wait(&q->cond_not_empty, &q->mtx) es el punto crítico de sincronización. Realiza tres operaciones en una secuencia que el sistema operativo garantiza como atómica respecto a la señalización: libera el mutex, suspende el hilo y, al despertar, re-adquiere el mutex. El consumidor, por su parte, utiliza cnd_signal para despertar a un solo hilo esperando, mientras que si tuviéramos múltiples consumidores, cnd_broadcast sería más seguro para despertar a todos y evitar que se queden bloqueados esperando una señal que ya pasó.
El error frecuente
Un error clásico es intentar usar if en lugar de while al esperar una condición de una variable de condición:
// Código erróneo y peligroso
mtx_lock(&q->mtx);
if (q->count == 0) {
cnd_wait(&q->cond_not_empty, &q->mtx);
}
// ERROR: Si hay un despertar espurio, q->count sigue siendo 0,
// pero el hilo procede a intentar extraer datos de un buffer vacío.
int item = q->buffer[--q->count];
mtx_unlock(&q->mtx);
Este patrón es extremadamente difícil de depurar porque la falla es intermitente y depende de la implementación del scheduler del kernel. Herramientas como ThreadSanitizer (TSan) de GCC/Clang son fundamentales para detectar este tipo de condiciones de carrera de alto nivel que el compilador, por sí solo, no puede detectar.
N° 96