1. Qué es un despertar espurio
Un despertar espurio (spurious wakeup) es cuando un hilo bloqueado en pthread_cond_wait se despierta sin que nadie haya llamado a signal o broadcast. El hilo retorna de wait como si alguien lo hubiera señalado, pero la condición puede no cumplirse.
Los despertares espurios son permitidos por POSIX: la especificación dice que pthread_cond_wait puede retornar en cualquier momento, incluso sin señal. Esto es intencional: permite implementaciones más eficientes del sistema operativo.
Los despertares espurios son raros en la práctica, pero ocurren. Un programa que asume que wait solo retorna cuando alguien señaló tendrá bugs intermitentes.
Aunque son raros, los despertares espurios ocurren en sistemas reales. Un programa que no los maneja tendrá bugs que aparecen solo bajo carga y son difíciles de reproducir.
2. Por qué ocurren
Los despertares espurios ocurren por razones de implementación:
- Optimización del núcleo: el núcleo puede despertar a un hilo para evitar condiciones de carrera en la implementación del futex.
- Señales del sistema: una señal (signal) del sistema operativo puede interrumpir a un hilo bloqueado y causar que
waitretorne. - Implementación de futex: la implementación de futex en Linux puede tener despertares espurios debido a la concurrencia en el núcleo.
La especificación de POSIX permite los despertares espurios para dar flexibilidad a la implementación. Esto simplifica el código del núcleo pero complica el código del usuario.
3. if vs. while
La diferencia entre usar if y while para verificar la condición es la clave para manejar los despertares espurios:
| Patrón | Código | Comportamiento |
|---|---|---|
| if (incorrecto) | if (!cond) wait(); | Si el hilo se despierta espuriamente, sale del if y continúa sin verificar la condición. |
| while (correcto) | while (!cond) wait(); | Si el hilo se despierta espuriamente, vuelve a verificar la condición. Si no se cumple, vuelve a esperar. |
El while garantiza que la condición se verifica cada vez que wait retorna, incluso si el despertar fue espurio. El if no lo garantiza.
4. El problema con if
Usar if en lugar de while causa bugs sutiles:
pthread_mutex_lock(&mutex);
if (!dato_listo) {
pthread_cond_wait(&cond, &mutex); /* puede despertar espuriamente */
}
/* Si se despertó espuriamente, dato_listo es false. */
printf("%d\n", dato); /* BUG: puede imprimir basura */
pthread_mutex_unlock(&mutex);
Si el hilo se despierta espuriamente, dato_listo es false y dato no está listo. El hilo imprime un valor incorrecto.
El problema es que el if solo verifica la condición una vez. Si el despertar es espurio, la condición no se cumple, pero el hilo continúa de todos modos.
5. La solución con while
Usar while resuelve el problema:
pthread_mutex_lock(&mutex);
while (!dato_listo) {
pthread_cond_wait(&cond, &mutex); /* puede despertar espuriamente */
/* Al despertar, vuelve a verificar la condición. */
}
/* condición cumplida */
printf("%d\n", dato); /* seguro: dato está listo */
pthread_mutex_unlock(&mutex);
Si el hilo se despierta espuriamente, el while vuelve a verificar !dato_listo. Si es true (no listo), vuelve a esperar. Si es false (listo), sale del bucle y continúa.
El while garantiza que la condición se cumple cuando el hilo sale del bucle, independientemente de si el despertar fue espurio o no.
Toda espera sobre una condición debe ir dentro de un while, nunca de un if. Esta es la regla más importante de las variables de condición.
6. signal vs. broadcast
La diferencia entre signal y broadcast es fundamental:
| Aspecto | signal | broadcast |
|---|---|---|
| Cuántos despierta | Uno. | Todos. |
| Cuándo usar | Cuando la condición hace que un solo hilo pueda continuar. | Cuando la condición hace que varios hilos puedan continuar. |
| Costo | Bajo (un solo hilo). | Alto (todos los hilos). |
| Riesgo | Puede despertar al hilo equivocado. | No hay riesgo de olvidar un hilo. |
El problema con signal es que puede despertar al hilo equivocado. Si la condición hace que solo un hilo pueda continuar, pero signal despierta a otro, el hilo despertado vuelve a esperar (porque la condición no se cumple para él) y el hilo correcto sigue bloqueado.
Este problema se llama despertar perdido (lost wakeup): el hilo correcto nunca se despierta porque signal despertó a otro.
7. Cuándo usar signal
signal se usa cuando la condición hace que un solo hilo pueda continuar:
- Productor-consumidor con un consumidor: el productor señala que hay datos; un solo consumidor puede procesarlos.
- Cola de tareas: un hilo añade una tarea; un hilo trabajador la procesa.
- Evento único: un hilo señala que un evento ocurrió; un solo hilo espera el evento.
La regla es: si la condición hace que solo un hilo pueda continuar, usa signal. Si puede hacer que varios continúen, usa broadcast.
8. Cuándo usar broadcast
broadcast se usa cuando la condición hace que varios hilos puedan continuar:
- Barrera: todos los hilos esperan a que todos lleguen; cuando el último llega, todos continúan.
- Inicialización: un hilo inicializa un recurso; todos los hilos que esperaban pueden usarlo.
- Cambio de estado global: un hilo cambia un estado que afecta a todos los hilos.
broadcast es más costoso que signal porque despierta a todos los hilos. Pero es necesario cuando la condición afecta a varios hilos.
En la mayoría de los casos, signal es suficiente. broadcast se usa solo cuando la condición hace que varios hilos puedan continuar. Usar broadcast cuando signal bastaría es un desperdicio de CPU.
9. Semántica de Hoare vs. Mesa
Hay dos semánticas para las variables de condición:
| Semántica | Qué ocurre al hacer signal | Ventaja | Desventaja |
|---|---|---|---|
| Hoare | El hilo señalador se bloquea y el hilo señalado se ejecuta inmediatamente (con el mutex). | La condición se cumple cuando el hilo señalado se ejecuta. | Más cambios de contexto. |
| Mesa | El hilo señalador continúa ejecutándose. El hilo señalado se despierta pero debe esperar el mutex. | Menos cambios de contexto. | La condición puede no cumplirse cuando el hilo señalado se ejecuta (por eso se necesita while). |
POSIX usa la semántica de Mesa: signal solo despierta al hilo, pero no le transfiere el mutex inmediatamente. El hilo despertado debe esperar a que el mutex se libere.
La semántica de Mesa es la razón por la que se necesita el while: cuando el hilo despertado finalmente adquiere el mutex, la condición puede haber cambiado (otro hilo la consumió). El while vuelve a verificar.
Con la semántica de Hoare, el if sería suficiente (porque la condición se cumple cuando el hilo se ejecuta). Pero Hoare es más costosa y no se usa en POSIX.
10. Un ejemplo desarrollado
Un ejemplo que muestra el problema del despertar espurio y cómo el while lo resuelve:
#define _POSIX_C_SOURCE 200809L
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int dato = 0;
int dato_listo = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
void *productor(void *arg) {
for (int i = 0; i < 3; i++) {
sleep(1);
pthread_mutex_lock(&mutex);
dato = i;
dato_listo = 1;
printf("Productor: produjo %d\n", dato);
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
}
return NULL;
}
void *consumidor(void *arg) {
for (int i = 0; i < 3; i++) {
pthread_mutex_lock(&mutex);
/* Usar while, no if. */
while (!dato_listo) {
pthread_cond_wait(&cond, &mutex);
}
printf("Consumidor: consumió %d\n", dato);
dato_listo = 0;
pthread_mutex_unlock(&mutex);
}
return NULL;
}
int main(void) {
pthread_t prod, cons;
pthread_create(&prod, NULL, productor, NULL);
pthread_create(&cons, NULL, consumidor, NULL);
pthread_join(prod, NULL);
pthread_join(cons, NULL);
return 0;
}
Con while, el consumidor verifica dato_listo cada vez que se despierta. Si el despertar es espurio, dato_listo es 0 y vuelve a esperar. Si es una señal real, dato_listo es 1 y sale del bucle.
11. Simulador interactivo
Este simulador muestra el problema del despertar espurio y cómo el while lo resuelve:
Simulador · despertares espurios
12. Resumen
- Los despertares espurios son despertares sin signal o broadcast.
- Son permitidos por POSIX para dar flexibilidad a la implementación.
- El if no maneja los despertares espurios: el hilo puede continuar sin verificar la condición.
- El while maneja los despertares espurios: vuelve a verificar la condición.
- signal despierta a un hilo; broadcast despierta a todos.
- signal se usa cuando un solo hilo puede continuar; broadcast cuando varios.
- POSIX usa semántica de Mesa: signal no transfiere el mutex inmediatamente.
- La semántica de Mesa es la razón por la que se necesita el while.
13. Preguntas de repaso
Intenta responder cada una antes de abrir la respuesta.
1. ¿Qué es un despertar espurio?
Un hilo se despierta de pthread_cond_wait sin que nadie haya llamado a signal o broadcast.
2. ¿Por qué ocurren los despertares espurios?
Por optimización del núcleo, señales del sistema e implementación de futex.
3. ¿Por qué se usa while en lugar de if?
Porque el while vuelve a verificar la condición después de un despertar espurio.
4. ¿Qué ocurre si se usa if y hay un despertar espurio?
El hilo sale del if y continúa sin verificar la condición, causando un bug.
5. ¿Cuál es la diferencia entre signal y broadcast?
signal despierta a un hilo; broadcast despierta a todos.
6. ¿Cuándo se usa signal?
Cuando la condición hace que un solo hilo pueda continuar.
7. ¿Cuándo se usa broadcast?
Cuando la condición hace que varios hilos puedan continuar.
8. ¿Qué es la semántica de Mesa?
signal solo despierta al hilo, que debe esperar el mutex. No transfiere el mutex inmediatamente.
9. ¿Qué es la semántica de Hoare?
signal transfiere el mutex al hilo despertado inmediatamente.
10. ¿Por qué POSIX usa la semántica de Mesa?
Porque es más eficiente (menos cambios de contexto).
Para practicar
- Escribe un programa que use if en lugar de while y observa el bug.
- Corrige el programa usando while.
- Usa broadcast en lugar de signal y observa la diferencia.
- Investiga cómo se implementan los despertares espurios en Linux.
- Explica por qué la semántica de Mesa requiere el while.
- Investiga la diferencia de rendimiento entre signal y broadcast.