Despertares espurios — Por qué las variables de condición despiertan «sin haber sido notificadas» y cómo esperar correctamente en Windows

· · Windows, Multithreading, Variables de condición, Sincronización, C++, C#, Win32 API, Investigación de fallos

«Ponemos datos en la cola y despertamos al hilo trabajador que espera. Llevaba seis meses ejecutándose, y un día intentó leer una cola vacía y falló.» «Estamos enviando la notificación, pero de vez en cuando un hilo no despierta nunca.» — El encuentro entre hilos parece que está funcionando, y es un criadero de errores que solo aparecen raramente. Las investigaciones de este tipo a menudo aterrizan en código que envuelve el wait de una variable de condición en un if. Y detrás de eso se sienta el despertar espurio — el fenómeno de regresar de wait sin haber recibido una notificación.

«Despierta aunque nadie lo notificó» suena a un defecto de la implementación, pero es un comportamiento que Win32, C++ y POSIX escriben todos en su documentación o sus estándares, y el Monitor de .NET está diseñado bajo el supuesto de que «una vez despertado, vuelve a comprobar la condición». ¿Por qué se permite ese comportamiento? ¿En qué capa ocurre en Windows? ¿Y cómo se escribe la espera para no topárselo nunca? Dirigido a desarrolladores que escriben aplicaciones empresariales y software de control de equipos en Windows, este artículo desentraña qué es realmente un despertar espurio a partir de fuentes primarias, y reduce la espera correcta a Win32 (C), C++ y C#.

1. Conclusión principal

  • El wait de una variable de condición puede regresar incluso cuando no ha llegado ninguna notificación. La documentación oficial de Win32 afirma que las variables de condición están sujetas a despertares espurios (despertares no ligados a un despertar explícito) y a despertares robados (otro hilo consume la condición antes de que lo haga el hilo despertado).1
  • Así que siempre debe escribir la espera como «un bucle while más una nueva comprobación de la condición». El código que comprueba una vez con if y luego hace wait parece que está funcionando, y alberga un error que solo se reproduce raramente.12
  • Esto no es una rareza específica de Windows; POSIX y el estándar de C++ dicen lo mismo. Una implementación que «no despierte de forma espuria nunca en absoluto» ralentizaría todas las operaciones de variable de condición, así que el despertar se permite bajo el supuesto de que quien espera volverá a comprobar.34
  • En C++, la forma de predicado wait(lock, pred) hace que la biblioteca realice el bucle por usted. Esa forma ejecuta de hecho while (!pred()) wait(lock);. Es el valor predeterminado para código nuevo.5
  • Monitor.Wait de C# necesita la misma disciplina. La condición puede consumirse en el intervalo entre ser despertado y readquirir el bloqueo, así que vuelve a comprobar la condición en un while y regresa a Wait.6
  • Actualice y compruebe la condición bajo el mismo bloqueo. Si mira la condición fuera del bloqueo y luego entra en wait, una notificación puede pasar por el hueco — un despertar perdido.1
  • No recree la notificación transitoria de una variable de condición de «despertar a quien está esperando ahora mismo» con un pulso sobre un evento. PulseEvent en particular puede perder la notificación en el instante en que un APC de modo kernel levanta brevemente la espera, y el propio Microsoft dice, con todas las letras, «no es fiable, no lo use, use una variable de condición en su lugar».7

Lo que sigue recorre, en orden, los mecanismos que sostienen esta conclusión.

2. Qué es un despertar espurio — Despertar no significa que la condición se cumpla

Una variable de condición es un primitivo de sincronización para «poner a dormir un hilo hasta que se cumpla alguna condición, y hacer que lo despierten cuando se cumple». En Win32 eso es la estructura CONDITION_VARIABLE junto con SleepConditionVariableCS / SleepConditionVariableSRW (espera) y WakeConditionVariable / WakeAllConditionVariable (notificar). La API de espera libera de forma atómica el bloqueo que sostiene (una sección crítica o un bloqueo SRW) y se duerme, y al despertar readquiere el bloqueo antes de regresar.1

La pregunta es qué significa realmente el hecho de «haber regresado de wait». De forma ingenua se quiere pensar «llegó una notificación = se cumple la condición», pero en realidad hay tres casos en los que wait regresa.

Caso Notificación Condición al regresar
Despertar genuino A menudo satisfecha, pero no garantizada
Despertar espurio Ninguna dirigida a usted Sigue insatisfecha
Despertar robado Otro hilo la consumió primero; insatisfecha
Tres casos en los que wait regresaLa espera de una variable de condición puede regresar no solo por una notificación genuina sino también por un despertar espurio sin notificación y por un despertar robado en el que llegó una notificación pero la condición se consumió primero, así que todos los casos necesitan que se vuelva a comprobar la condiciónRegresó de waitNotificación genuinaDespertar espurio (sin notificación)Despertar robado (condición ya consumida)Volver a comprobar la condición y luego continuar

Figura 1: Hay tres caminos de vuelta desde wait, y el llamador no puede decir cuál tomó, así que siempre debe volver a comprobar la condición.

Un despertar espurio es este segundo caso — el fenómeno de que la API de espera regrese sin estar ligada a una notificación explícita que pretendía despertarlo. No se limita a situaciones en las que no se ha llamado a WakeConditionVariable en ningún sitio del sistema. Por ejemplo, bajo una carga alta en la que las notificaciones llegan en una ráfaga corta, la implementación puede despertar hilos en espera de más en un lote, y desde el lado que no tiene una notificación correspondiente eso también es un despertar espurio. La página de variables de condición de Microsoft Learn lo dice con claridad: “Condition variables are subject to spurious wakeups (those not associated with an explicit wake) and stolen wakeups (another thread manages to run before the woken thread). Therefore, you should recheck a predicate (typically in a while loop) after a wait operation returns.”1

El punto importante es que el llamador no puede decir a través de cuál de los tres casos regresó. Si no puede decirlo, solo hay una estrategia disponible: cada vez que regrese, compruebe la propia condición que estaba esperando, y vuelva a dormir si no se cumple. Ese es el contenido real de la regla de hierro «envuelva wait en un while». Dicho al revés, mientras mantenga esa regla, el código es correcto no importa cuál de los tres casos lo despertó.

3. Por qué la especificación lo permite — La notificación precisa es cara

«Despertar sin haber sido notificado no es más que una implementación descuidada, ¿no?» es una pregunta justa. De hecho es teóricamente posible construir una implementación que nunca despierte de forma espuria. Aun así, POSIX, Windows y el estándar de C++ se decantaron todos por el lado de «puede ocurrir». La razón se afirma con franqueza en el Rationale de pthread_cond_wait en POSIX (The Open Group Base Specifications).3

La primera razón es el rendimiento. Intentar implementar de forma estricta una notificación que «despierte de forma fiable exactamente a un hilo», sobre todo en multiprocesadores, añade un coste extra de sincronización a cada operación de variable de condición. Entre la notificación y el despertar se sienta un planificador, y según el momento de las interrupciones y la apropiación no puede evitar «que se ejecute un hilo distinto antes del que pretendía despertar». Pagarle a todo el mundo el coste de sellar eso por completo es peor, para mantener las variables de condición rápidas, que aceptar que «de vez en cuando puede despertar de más».

La segunda razón es la observación de que este compromiso no rompe las aplicaciones — de hecho las hace más robustas. Como se permiten los despertares espurios, el código correcto siempre escribe un bucle que comprueba el predicado (la condición que se espera). El Rationale de POSIX dice que forzar este bucle hace que el código se documente a sí mismo y sea más robusto.3 Una vez que el bucle está ahí, el significado de una notificación se degrada de «una garantía de que se cumple la condición» a «una pista de que la condición puede haber cambiado», y el lado que espera se vuelve tolerante a cambios de diseño modestos en quien notifica (despertar a demasiados, despertar en un lote, etc.).

Los despertares robados son un asunto aún más estructural. Siempre hay un hueco de tiempo entre que quien notifica llama a WakeConditionVariable y que el hilo despertado readquiere el bloqueo y regresa de wait. Si un tercer hilo puede tomar el bloqueo en ese intervalo, puede consumir la condición (el contenido de la cola, etc.) primero. Ese es un hueco que ninguna cantidad de pulir la implementación puede borrar, porque viene de la propia forma de la herramienta de variable de condición.

Línea de tiempo de un despertar robadoEl productor pone un elemento en la cola y despierta al consumidor A que espera, pero antes de que A readquiera el bloqueo el consumidor B adquiere el bloqueo y toma el único elemento, así que la cola está vacía para cuando A despiertaConsumidor BProductorConsumidor A (esperando)Consumidor BProductorConsumidor A (esperando)Despertado, esperando a readquirir el bloqueoLa cola está vacía (robado)Añadir un elemento a la colaWakeConditionVariableAdquirir el bloqueo y tomar un elementoReadquirir el bloqueo y regresar de waitVolver a comprobar en el bucle while y esperar de nuevo

Figura 2: Un «despertar robado», en el que un tercer hilo consume la condición en el hueco de tiempo entre la notificación y el despertar, puede ocurrir bajo cualquier implementación.

En otras palabras, incluso si el sistema operativo erradicara por completo los despertares espurios, mientras existan despertares robados sigue sin poder escribir «desperté = se cumple la condición». El bucle de nueva comprobación de quien espera se exige de todos modos, y dado eso, es más barato permitir despertares espurios y mantener la implementación rápida — ese es el juicio de diseño que las variables de condición han llevado durante décadas.

4. En qué capas aparece en Windows

Esta propiedad muestra su cara en cualquier capa de primitivo de sincronización de Windows que use. Para hacerse a la idea de que no puede escapar de ella no importa contra la API de qué capa escriba, veremos las capas representativas.

Las variables de condición de Win32 (CONDITION_VARIABLE) están, como ya se ha indicado, documentadas en SleepConditionVariableCS / SleepConditionVariableSRW como sujetas tanto a despertares espurios como a despertares robados, y se le exige volver a comprobar el predicado en un bucle while.2 El ejemplo oficial de uso (una cola productor–consumidor) también escribe la espera dentro de un bucle while.8

El WaitOnAddress aún más de bajo nivel es una API de espera más primitiva que una variable de condición: «esperar hasta que el valor de una dirección dada cambie» (Windows 8 y posteriores). Incluso esta API cercana a la capa inferior tiene documentación que afirma «está garantizado que regresa cuando se señala la dirección, pero también se permite que regrese por otros motivos», y enumera como ejemplos de despertar pronto una condición de poca memoria, abandonar un despertar anterior para la misma dirección y ejecutar una compilación comprobada. Por eso el propio ejemplo de uso de la documentación tiene la forma de «un bucle while que compara el valor de nuevo».9

std::condition_variable de C++ es lo mismo. La documentación de MSVC dice del wait sin predicado que «bloquea hasta que lo señala una llamada a notify_one / notify_all. También puede despertar de forma espuria», y explica que la forma de predicado wait(lock, pred) ejecuta de hecho el siguiente código.5

while (!Pred())
    wait(Lck);

En otras palabras, el wait en forma de predicado recomendado en C++ no es otra cosa que la biblioteca quitándole de las manos el «envuélvalo en un while», como describe este artículo. cppreference afirma igualmente que el wait sin predicado puede desbloquearse de forma espuria.4

Monitor.Wait / Pulse de .NET tiene su propia estructura de colas de una cola de espera y una cola lista, pero la disciplina no cambia. Un hilo despertado por Pulse / PulseAll se mueve a la cola lista y regresa de Wait en el orden en que puede readquirir el bloqueo. Que otro hilo pueda consumir la condición en el intervalo antes de que se readquiera el bloqueo es lo mismo que en Win32, y la documentación también está escrita bajo el supuesto de que «el hilo despertado vuelve a evaluar la condición que lo hizo entrar en la espera, y llama a Wait de nuevo si es necesario».610

Todas las capas exigen que se vuelva a comprobar el predicadoLa documentación oficial exige que se vuelva a comprobar la condición después de despertar en todas las capas — C++ std::condition_variable, .NET Monitor, Win32 CONDITION_VARIABLE y el WaitOnAddress de bajo nivelC++ std::condition_variableAl despertar, volver a comprobar la condición (while).NET Monitor.WaitWin32 CONDITION_VARIABLEWaitOnAddress

Figura 3: Cambie el lenguaje o el marco y el requisito oficial sigue siendo el mismo en cada capa de primitivo de espera: vuelva a comprobar después de despertar.

5. La forma correcta de esperar — Escríbala con while y un predicado

A partir de aquí, implementación. Solo hay tres principios.

  1. Sostenga lo que espera como estado (un predicado), no como una «notificación». La condición es estado compartido protegido por un bloqueo — «¿la cola no está vacía?», «¿está fijada la marca?» — no «¿me despertaron?».
  2. Coloque siempre wait dentro de un bucle while sobre la condición. Cada vez que despierte, compruebe la condición, y vuelva a dormir si no se cumple.
  3. Actualice y compruebe la condición bajo el mismo bloqueo. Quien notifica actualiza el estado y luego notifica.
Flujo de un bucle de espera correctoAdquiera el bloqueo y compruebe la condición; si no se cumple, libere el bloqueo y duerma; al despertar readquiera el bloqueo y vuelva a la comprobación de la condición. Continúe con el bloqueo sostenido solo cuando se cumple la condiciónNoAdquirir el bloqueo¿Se cumple la condición?wait (liberar el bloqueo y dormir)Despertar (readquirir el bloqueo)Continuar mientras aún sostiene el bloqueo

Figura 4: Una espera correcta es un bucle, y no hay hueco entre comprobar la condición y procesarla (ambas ocurren mientras se sostiene el bloqueo).

Esta forma tiene un beneficio fácil de pasar por alto. El momento en que sale del bucle while, queda establecido, mientras aún sostiene el bloqueo, que «se cumple la condición». El bucle que defiende contra los despertares espurios es, tal cual, una garantía de que no hay un hueco de condición de carrera entre comprobar la condición y procesarla.

La forma básica en Win32 (C)

CRITICAL_SECTION cs;
CONDITION_VARIABLE cv;
int queueCount = 0;   // shared state protected by cs

// Initialise once at startup (for static initialisation, cv = CONDITION_VARIABLE_INIT)
InitializeCriticalSection(&cs);
InitializeConditionVariable(&cv);

// Waiter (consumer)
EnterCriticalSection(&cs);
while (queueCount == 0) {                       // always while, never if
    SleepConditionVariableCS(&cv, &cs, INFINITE);
}
// Here the lock is held and queueCount > 0 is guaranteed
--queueCount;
LeaveCriticalSection(&cs);

// Notifier (producer)
EnterCriticalSection(&cs);
++queueCount;                                    // update the state under the lock
LeaveCriticalSection(&cs);
WakeConditionVariable(&cv);                      // notifying after releasing the lock is fine

Puede llamar a la notificación (WakeConditionVariable) desde dentro del bloqueo o desde fuera de él, pero la documentación dice que despertar después de liberar el bloqueo suele ser mejor, para reducir los cambios de contexto.1 Por otro lado, la propia actualización del estado (++queueCount) debe ocurrir siempre bajo el bloqueo. No confunda las dos.

La forma básica en C++ — Haga de la espera de predicado el valor predeterminado

std::mutex m;
std::condition_variable cv;
std::queue<Item> q;

// Waiter
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [&] { return !q.empty(); });   // internally while(!pred) wait
Item item = std::move(q.front());
q.pop();
lk.unlock();

// Notifier
{
    std::lock_guard<std::mutex> lk(m);
    q.push(std::move(item));
}
cv.notify_one();

Como el wait en forma de predicado realiza el bucle por usted, un while escrito a mano es innecesario. Cuando corrija código existente que aún tiene un bucle escrito a mano, while (q.empty()) cv.wait(lk); es una forma correcta, así que no hay necesidad de apresurarse a reescribirlo. La única forma incorrecta es if (q.empty()) cv.wait(lk);.

La forma básica en C#

private readonly object _gate = new();
private readonly Queue<Item> _queue = new();

// Waiter
lock (_gate)
{
    while (_queue.Count == 0)          // always while, never if
    {
        Monitor.Wait(_gate);
    }
    var item = _queue.Dequeue();
}

// Notifier
lock (_gate)
{
    _queue.Enqueue(item);
    Monitor.Pulse(_gate);              // Monitor.Pulse can only be called inside the lock
}

Monitor.Wait / Pulse / PulseAll solo se pueden llamar desde dentro de un bloqueo (bloque lock), lo que difiere de Win32. Llamarlos fuera del bloqueo lanza SynchronizationLockException.10

Esperar con un tiempo de espera — Calcule el tiempo restante a partir de un plazo

Cuando espera con un tiempo de espera, pasar «el mismo valor de tiempo de espera» en cada iteración del bucle estira la espera cada vez que ocurre un despertar espurio. La forma correcta es fijar primero el plazo y recalcular el tiempo restante.

ULONGLONG deadline = GetTickCount64() + timeoutMs;
EnterCriticalSection(&cs);
while (queueCount == 0) {
    ULONGLONG now = GetTickCount64();
    if (now >= deadline) {
        break;                          // timeout (condition still unsatisfied)
    }
    if (!SleepConditionVariableCS(&cv, &cs, (DWORD)(deadline - now)) &&
        GetLastError() != ERROR_TIMEOUT) {
        break;                          // on failure other than timeout, stop waiting and leave
    }
    // Confirm ERROR_TIMEOUT finally via the while condition and the deadline check
}
BOOL ready = (queueCount > 0);
if (ready) { --queueCount; }
LeaveCriticalSection(&cs);
Flujo correcto de una espera con tiempo de esperaFije primero el plazo, y cada vez que despierte compruebe la condición y el plazo; si aún queda tiempo, recalcule el tiempo restante y vuelva a la esperaNoNoFijar el plazo¿Se cumple la condición?Pasar al procesamiento¿Ha pasado el plazo?Manejar el tiempo de esperaCalcular el tiempo restante y esperar

Figura 5: Una espera con tiempo de espera no vuelve a pasar «la misma duración de espera»; recalcula el tiempo restante a partir de un plazo.

En C++, puede dejar este cálculo, plazo incluido, a la sobrecarga de wait_until (tiempo absoluto) más predicado. Incluso cuando regresa por tiempo de espera le da el valor final del predicado, así que también puede decidir «¿agotamos el tiempo, o llegamos?» sobre el predicado.5

6. Un catálogo de patrones que evitar

Comprobar solo una vez con if. Esta es la estrella del artículo. En el momento en que ocurre un despertar espurio o un despertar robado, el procesamiento continúa con la condición insatisfecha. Tomar de una cola vacía, tocar datos no inicializados, una doble liberación — el síntoma se convierte en «un fallo o una corrupción de datos que solo aparece de vez en cuando».

Comprobar o actualizar la condición fuera del bloqueo. Si quien espera mira la condición fuera del bloqueo, decide «aún no» y, en el hueco antes de entrar en wait, quien notifica actualiza el estado y envía una notificación, la notificación se dispara contra una variable de condición sin nadie esperando y se desvanece. Quien espera entra entonces en wait y sigue esperando una notificación que no volverá a llegar. Eso es un despertar perdido, la imagen especular de un despertar espurio. La razón de que la API de espera de una variable de condición esté diseñada para «liberar de forma atómica el bloqueo y dormirse» es precisamente cerrar este hueco.1 No ocurre mientras mantenga la disciplina de bloqueos.

Línea de tiempo de un despertar perdidoSi quien espera comprueba la condición fuera del bloqueo y quien notifica actualiza el estado y notifica en el hueco antes de entrar en wait, la notificación se envía a una variable de condición sin nadie esperando y se desvanece, y quien espera sigue esperando una notificación que no llegará nuncaQuien notificaQuien esperaQuien notificaQuien esperaNadie espera en este momentoLa notificación ya se ha ido y no despierta nuncaComprobar la condición fuera del bloqueo (insatisfecha)Actualizar el estado y notificarEntrar en wait

Figura 6: Si comprueba la condición fuera del bloqueo, la notificación se cuela por el hueco entre la comprobación y wait — un «despertar perdido».

Recrear la «notificación transitoria» de una variable de condición con un pulso sobre un evento. Los propios eventos (CreateEvent + SetEvent) no son un antipatrón. Una señal de despertar en una configuración en la que un solo consumidor procesa la cola hasta que está vacía, o una instrucción de detención que una vez alzada no se baja nunca (un evento de restablecimiento manual), son usos correctos de un evento; y cuando quiere unirlo con otros destinos de espera mediante WaitForMultipleObjects, o cruzar un límite de proceso, una variable de condición — un objeto de modo usuario que no se puede compartir entre procesos — es la que no se puede usar.1 Lo peligroso es intentar recrear, con operaciones de evento, la notificación transitoria de una variable de condición que «despierta solo a los hilos que esperan en ese instante y no deja estado detrás». Esa idea casi siempre lleva al siguiente punto, PulseEvent.

Usar PulseEvent. Es una API que, sobre un evento de restablecimiento manual, «despierta a todos los que esperan en ese momento y de inmediato devuelve el evento al estado no señalado», pero el propio Microsoft afirma en la documentación que «esta función no es fiable y no debe usarse. Existe principalmente por compatibilidad hacia atrás. Use una variable de condición en su lugar.» La razón es que un hilo que espera puede ser retirado temporalmente del estado de espera por un APC de modo kernel y volver a la espera después de que el APC termine. Si se llama a PulseEvent en ese breve intervalo, ese hilo no se incluye entre «los que esperaban en el momento en que se llamó» y no se despierta.7 Los APC de kernel son algo que el sistema operativo usa internamente; la aplicación no puede controlarlos.11 Este problema es también un aviso de análisis estático (C28648).12 Si un despertar espurio es el problema de «despertar de más», este es el problema de «dormirse de más cuando debería haber despertado», y un bucle while no puede salvarlo — porque la propia notificación se ha perdido.

Enviar solo la notificación primero, sin sostener el bloqueo, antes de actualizar el estado. Llamar a WakeConditionVariable mientras el estado aún está rancio, y solo entonces tomar el bloqueo y actualizar el estado — en ese orden, el hilo despertado sigue viendo la condición insatisfecha cuando comprueba, y vuelve a dormir. Si no llega ninguna notificación más, se queda ahí. Tenga en cuenta que si escribe «notificar → actualizar → liberar» mientras aún sostiene el mismo bloqueo, no hay un daño real, porque quien espera no puede comprobar la condición hasta que readquiere el bloqueo. Aun así, para que los lectores no tengan que verificar esta condición de seguridad cada vez, es más seguro estandarizar el orden «actualizar el estado bajo el bloqueo, y notificar después de eso».

7. Cómo investigar cuando se encuentra con ello

Los errores que involucran despertares espurios se caracterizan por «aparecer solo raramente». Trabajando hacia atrás desde el síntoma, se parten en las dos familias siguientes.

Familia 1: El procesamiento continúa con la condición insatisfecha. Una excepción o un fallo por tomar de una cola vacía, resultados que faltan, etc. Sospeche una espera sin predicado. Puede peinar esto de forma mecánica en la revisión de código — busque sitios donde cv.wait( tiene solo un argumento, y sitios donde SleepConditionVariableCS / Monitor.Wait está envuelto en if en lugar de while. Esta comprobación no exige esperar una reproducción, y es el movimiento de mayor apalancamiento que tiene.

Familia 2: Un hilo que debería despertar no lo hace (un cuelgue). Sospeche un despertar perdido (comprobar la condición fuera del bloqueo, o notificar fuera del bloqueo antes de actualizar el estado) y PulseEvent. Tome un volcado del proceso colgado y mire la pila de cada hilo, y puede identificar qué hilo está atascado en qué API de espera. Desde ahí, persiga en el código «quién se suponía que enviaba esa notificación, y en qué orden».

Flujo de triaje a partir del síntomaSi el procesamiento continúa con la condición insatisfecha, peine las esperas sin predicado buscando en el código; si un hilo no despierta, identifique el sitio de espera a partir de un volcado y sospeche un despertar perdido o PulseEventUn error que solo aparece raramenteEl procesamiento continúa con la condición insatisfechaUn hilo que debería despertar no lo haceBuscar en el código esperas sin predicadoIdentificar los hilos que esperan a partir de un volcadoCambiar if por while, o usar espera de predicadoSospechar un despertar perdido o PulseEvent

Figura 7: Si el síntoma es «continuar demasiado lejos» o «no despertar nunca» parte tanto lo que sospecha como cómo investiga.

Si quiere reproducirlo, el movimiento estándar es ensanchar la ventana de carrera. Aumente la fluctuación de momentos usando más hilos que núcleos físicos, insertando un Sleep deliberado entre la espera y la notificación, y ejecutando compilaciones tanto de depuración como de publicación. Cuando confirme que «dejó de reproducirse después de que corrigiéramos la espera sin predicado», compare bajo el mismo estrés.

8. Resumen — Una lista de comprobación

  • Los caminos de vuelta desde wait son tres — notificación genuina, despertar espurio y despertar robado — y el llamador no puede distinguirlos. Así que siempre escriba la espera como un bucle while sobre la condición.
  • Un despertar espurio es un comportamiento que Win32, C++ y POSIX permitieron de forma deliberada como un compromiso frente al rendimiento, y no desaparecerá con una corrección del sistema operativo ni con un cambio de biblioteca. No se asume que Monitor.Wait de .NET despierte sin motivo, pero como existen despertares robados y tiempos de espera, sigue exigiendo la misma disciplina de while.
  • En C++, tome por defecto la forma de predicado wait(lock, pred). La biblioteca realiza el bucle.
  • Actualice y compruebe la condición bajo el mismo bloqueo. Envíe la notificación «después de actualizar el estado». La notificación de Win32/C++ puede ocurrir después de liberar el bloqueo; el Pulse de C# es solo dentro del bloqueo.
  • Para una espera con tiempo de espera, fije un plazo y recalcule el tiempo restante. En C++, wait_until más un predicado.
  • No recree la notificación transitoria de una variable de condición con un pulso sobre un evento. PulseEvent en particular es algo que la documentación oficial afirma, con todas las letras, «no lo use, use una variable de condición en su lugar». Los propios eventos siguen siendo la herramienta correcta para una instrucción de detención, unirse con WaitForMultipleObjects y la sincronización entre procesos.
  • En la revisión, busque de forma mecánica «espera sin predicado» y «if + wait». Puede matar un error que se reproduce raramente sin esperar una reproducción.

Un despertar espurio, en contra de lo extraño del nombre, se condensa en una palabra clave de una línea para la corrección — cambie if por while. Y detrás de esa línea se sienta la idea de diseño de la herramienta de variable de condición: «la notificación precisa es cara, así que comprobar es responsabilidad de quien espera». Entiéndalo como un mecanismo y debería poder aplicar la misma disciplina sin vacilar cuando cambie el lenguaje o el marco.

Artículos relacionados

Áreas de consultoría relacionadas

En KomuraSoft LLC nos encargamos de revisiones de diseño de multihilo, de investigación de causa raíz (análisis de volcados) de fallos y cuelgues que «solo se reproducen de vez en cuando», y de migrar código de sincronización legado (dependiente de eventos y de PulseEvent, y similares) a una base de variables de condición. Empezar por triar el síntoma está bien — no dude en ponerse en contacto.

Referencias

  1. Microsoft Learn, Condition Variables. Sobre que una variable de condición es un objeto de modo usuario que libera de forma atómica un bloqueo y entra en una espera; sobre que hay despertares espurios (despertares no ligados a un despertar explícito) y despertares robados (otro hilo se ejecuta antes que el hilo despertado), de modo que después de regresar de una espera debería volver a comprobar el predicado en un bucle while; y sobre que la notificación es posible desde dentro o desde fuera del bloqueo, pero despertar después de liberar el bloqueo es mejor para reducir los cambios de contexto.  2 3 4 5 6 7 8

  2. Microsoft Learn, SleepConditionVariableCS function (synchapi.h). Sobre liberar de forma atómica una sección crítica especificada y esperar en una variable de condición; sobre que el hilo despertado readquiere la sección crítica antes de regresar; sobre que se devuelve ERROR_TIMEOUT ante un tiempo de espera; y sobre que hay despertares espurios y despertares robados, de modo que después de regresar de una espera debería volver a comprobar el predicado (normalmente en un bucle while).  2

  3. The Open Group Base Specifications, pthread_cond_timedwait, pthread_cond_wait. Sobre que pueden ocurrir despertares espurios de pthread_cond_wait / pthread_cond_timedwait; sobre que regresar de wait no significa nada sobre el valor del predicado, así que el predicado debería reevaluarse; y sobre que el Rationale afirma que una implementación que «despierta exactamente a uno» puede ralentizar las operaciones de variable de condición sobre todo en multiprocesadores, y que permitir despertares espurios fuerza un bucle de comprobación del predicado y hace las aplicaciones más robustas.  2 3

  4. cppreference.com, std::condition_variable::wait. Sobre que el wait sin predicado puede desbloquearse por un despertar espurio; y sobre que la sobrecarga de predicado es equivalente a while (!pred()) wait(lock); y se define como un bucle que readquiere el bloqueo y comprueba el predicado en cada notificación o despertar espurio.  2

  5. Microsoft Learn, condition_variable Class. Sobre que el wait sin predicado se afirma que se desbloquea con notify_one / notify_all y también que puede despertar de forma espuria; sobre que la forma de predicado wait(lock, pred) ejecuta de hecho while (!Pred()) wait(Lck);; y sobre que wait_for / wait_until tienen la misma propiedad y una sobrecarga de predicado.  2 3

  6. Microsoft Learn, Monitor.Wait Method. Sobre que Wait libera el bloqueo y entra en la cola de espera; sobre que no regresa después de ser despertado por Pulse / PulseAll hasta que se readquiere el bloqueo; y sobre que el uso previsto es que el hilo despertado vuelva a evaluar la condición que lo hizo entrar en la espera y llame a Wait de nuevo si es necesario.  2

  7. Microsoft Learn, PulseEvent function (winbase.h). Sobre que un hilo que espera puede ser retirado temporalmente del estado de espera por un APC de modo kernel y regresar después de que el APC termine, de modo que si se llama a PulseEvent en ese intervalo el hilo no se libera; y sobre que PulseEvent por tanto no es fiable y no debe usarse en aplicaciones nuevas, usándose una variable de condición en su lugar.  2

  8. Microsoft Learn, Using Condition Variables. Sobre el ejemplo oficial que implementa una cola productor–consumidor con una sección crítica y dos variables de condición (BufferNotEmpty y BufferNotFull). La espera se realiza dentro de un bucle que comprueba el predicado. 

  9. Microsoft Learn, WaitOnAddress function (synchapi.h). Sobre que la función que espera a que cambie el valor de una dirección está garantizada a regresar cuando se señala pero también se permite que regrese por otros motivos; sobre que los ejemplos de despertar pronto incluyen una condición de poca memoria, abandonar un despertar anterior para la misma dirección y ejecutar una compilación comprobada; y sobre que por tanto hay que comparar el valor de nuevo después de regresar, siendo el propio ejemplo oficial un bucle while. 

  10. Microsoft Learn, Monitor.PulseAll Method. Sobre que PulseAll mueve hilos de la cola de espera a la cola lista, y el siguiente hilo de la cola lista adquiere el bloqueo cuando se libera el bloqueo; y sobre que Pulse / PulseAll / Wait solo se pueden llamar desde dentro de un bloque de sincronización.  2

  11. Microsoft Learn, Waits and APCs. Sobre que los APC de kernel se ejecutan de forma preventiva, y el sistema interrumpe y reanuda internamente una espera sin regresar de la API de espera, de modo que una señal transitoria como KePulseEvent puede perderse en ese intervalo. 

  12. Microsoft Learn, C28648: PulseEvent is an unreliable function. Sobre el aviso de análisis estático ante el uso de PulseEvent; sobre que un hilo que estaba fuera de la espera por un APC no se libera y puede colgarse para siempre; y sobre la guía para sustituirlo por SetEvent u otro objeto de sincronización. 

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

El artículo está directamente relacionado con los siguientes servicios.

Preguntas frecuentes

Preguntas habituales en las consultas sobre el tema del artículo.

¿Es un despertar espurio un error del sistema operativo o de la biblioteca?
No — es un comportamiento escrito en la especificación. SleepConditionVariableCS de Win32, std::condition_variable de C++ y pthread_cond_wait de POSIX tienen todos documentación oficial o un estándar que afirma de forma explícita que puede ocurrir un despertar no ligado a una notificación. Una implementación que lo prohibiera es teóricamente posible, pero ralentizaría todas las operaciones de variable de condición (sobre todo la notificación en multiprocesadores), así que el compromiso es permitirlo bajo el entendimiento de que «la corrección se conserva si quien espera vuelve a comprobar la condición». El remedio, por tanto, no es esperar una corrección del sistema operativo, sino escribir siempre la espera dentro de un bucle while (o usar una espera en forma de predicado).
¿Envolver la espera en un bucle while perjudica el rendimiento?
En la práctica el coste es despreciable. Todo lo que añade el bucle while es una comprobación extra de la condición cada vez que despierta, y eso es una comparación barata mientras ya sostiene el bloqueo. Los propios despertares espurios son raros, así que la iteración extra del bucle solo ocurre en casos excepcionales. El coste de dejar la comprobación como un if, en cambio, es un «error que solo se reproduce raramente» en el que el procesamiento continúa con la condición insatisfecha — no hay comparación. Lo que de verdad domina el coste de espera de una variable de condición es la contención del bloqueo y con qué frecuencia notifica, no si el while está ahí.
Si uso la espera en forma de predicado de C++, ¿puedo olvidarme de los despertares espurios?
Para el bucle de espera, sí: cv.wait(lock, pred) es de hecho while (!pred()) wait(lock); así que tanto los despertares espurios como los despertares robados se absorben de forma automática. El código C++ nuevo debería tomar por defecto la sobrecarga de predicado. Aun así tiene que proteger las actualizaciones del estado compartido que lee el predicado con el mismo mutex, y quien notifica sigue teniendo que actualizar ese estado antes de llamar a notify. La espera de predicado le quita el bucle de las manos; no le quita de las manos la disciplina de bloqueos.
¿Ocurre el mismo problema con Monitor.Wait de C#?
Sí. Un hilo que espera en Monitor.Wait lo despierta Pulse/PulseAll y luego readquiere el bloqueo antes de regresar de Wait, pero en ese intervalo otro hilo puede haber adquirido el bloqueo primero y haber consumido la condición (un despertar robado). La documentación de Microsoft está escrita bajo el supuesto de que el hilo despertado vuelve a evaluar la condición que lo hizo esperar, y llama a Wait de nuevo si es necesario. Así que la forma básica en C# es también while (!condition) Monitor.Wait(gate);. Una restricción que difiere de Win32 es que solo puede llamar a Wait/Pulse desde dentro de una instrucción lock.
¿También ocurren despertares espurios cuando espera un evento con WaitForSingleObject?
En una espera ordinaria (no alertable), WAIT_OBJECT_0 se devuelve solo cuando el objeto pasa de verdad a señalado; no hay un «despertar sin motivo» del tipo que tienen las variables de condición. Dicho esto, «el evento pasó a señalado» y «se cumple la condición de su aplicación» son cosas distintas. Si varios consumidores los despierta el mismo evento, el hilo que toma el bloqueo primero consume la condición, así que sigue haciendo falta volver a comprobar la condición después de despertar. Los diseños que intentan recrear la notificación transitoria de una variable de condición de «despertar solo a quien está esperando en ese instante» con un evento también suelen toparse con el problema de fiabilidad de PulseEvent, así que para esperar una condición dentro de un proceso, una variable de condición es la herramienta más segura.

Perfil del autor

Página de presentación del autor del artículo.

Go Komura

Representante de KomuraSoft LLC

Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.

Volver al blog