Réveils parasites — pourquoi les variables de condition se réveillent « sans avoir été notifiées » et comment attendre correctement sous Windows

· · Windows, Multithreading, Variables de condition, Synchronisation, C++, C#, Win32 API, Investigation de bugs

« Nous mettons des données dans la file et réveillons le thread worker en attente. Cela tournait depuis six mois, puis un jour il a essayé de lire une file vide et a planté. » « Nous envoyons la notification, mais de temps en temps un thread ne se réveille jamais. » — Le rendez-vous multithread a l’air de fonctionner, et est un terreau pour des bugs qui n’apparaissent que rarement. Les investigations de ce type atterrissent souvent sur du code qui enveloppe le wait d’une variable de condition dans un if. Et derrière cela se trouve le réveil parasite — le phénomène de revenir de wait sans avoir reçu de notification.

« Cela se réveille même si personne ne l’a notifié » sonne comme un défaut de l’implémentation, mais c’est un comportement que Win32, C++ et POSIX énoncent tous dans leur documentation ou leurs normes, et le Monitor de .NET est conçu en partant du principe qu’« une fois réveillé, vous revérifiez la condition ». Pourquoi ce comportement est-il autorisé ? Dans quelle couche se produit-il sous Windows ? Et comment écrire l’attente pour ne jamais le rencontrer ? Destiné aux développeurs qui écrivent des applications métier et des logiciels de contrôle d’équipement sous Windows, cet article déplie ce qu’est vraiment un réveil parasite à partir des sources primaires, et réduit l’attente correcte en Win32 (C), C++ et C#.

1. La conclusion, d’abord

  • Le wait d’une variable de condition peut revenir même lorsqu’aucune notification n’est arrivée. La documentation officielle Win32 indique que les variables de condition sont sujettes aux réveils parasites (réveils non liés à un réveil explicite) et aux réveils volés (un autre thread consommant la condition avant le thread réveillé).1
  • Donc vous devez toujours écrire l’attente comme « une boucle while plus une revérification de la condition ». Le code qui vérifie une fois avec if puis wait a l’air de fonctionner, et abrite un bug qui ne se reproduit que rarement.12
  • Ce n’est pas une bizarrerie propre à Windows ; POSIX et la norme C++ disent la même chose. Une implémentation qui « ne se réveille absolument jamais de façon parasite » ralentirait chaque opération de variable de condition, donc le réveil est autorisé en partant du principe que le thread en attente revérifiera.34
  • En C++, la forme à prédicat wait(lock, pred) fait exécuter la boucle par la bibliothèque. Cette forme exécute effectivement while (!pred()) wait(lock);. C’est le défaut pour le code neuf.5
  • Le Monitor.Wait de C# a besoin de la même discipline. La condition peut être consommée dans l’intervalle entre être réveillé et réacquérir le verrou, donc vous revérifiez la condition dans un while et revenez à Wait.6
  • Mettez à jour et vérifiez la condition sous le même verrou. Si vous regardez la condition hors du verrou puis entrez dans wait, une notification peut passer dans l’écart — un réveil perdu.1
  • Ne recréez pas la notification transitoire d’une variable de condition « réveiller qui attend maintenant » avec une impulsion sur un événement. PulseEvent en particulier peut manquer la notification à l’instant où un APC en mode noyau lève brièvement l’attente, et Microsoft elle-même dit, en toutes lettres, « c’est peu fiable, ne l’utilisez pas, utilisez une variable de condition à la place ».7

Ce qui suit parcourt les mécanismes qui soutiennent cette conclusion, dans l’ordre.

2. Ce qu’est un réveil parasite — se réveiller ne signifie pas que la condition tient

Une variable de condition est une primitive de synchronisation pour « endormir un thread jusqu’à ce qu’une condition tienne, et le faire réveiller lorsqu’elle tient ». Sous Win32 c’est la structure CONDITION_VARIABLE avec SleepConditionVariableCS / SleepConditionVariableSRW (attendre) et WakeConditionVariable / WakeAllConditionVariable (notifier). L’API d’attente libère atomiquement le verrou que vous détenez (une section critique ou un verrou SRW) et s’endort, et au réveil elle réacquiert le verrou avant de revenir.1

La question est ce que le fait d’« être revenu de wait » signifie réellement. Naïvement vous voulez penser « une notification est arrivée = la condition tient », mais en réalité il y a trois cas dans lesquels wait revient.

Cas Notification Condition au retour
Réveil véritable Oui Souvent satisfaite, mais non garantie
Réveil parasite Aucune qui vous soit adressée Encore non satisfaite
Réveil volé Oui Un autre thread l’a consommée en premier ; non satisfaite
Trois cas dans lesquels wait revientUne attente de variable de condition peut revenir non seulement d'une notification véritable mais aussi d'un réveil parasite sans notification et d'un réveil volé où une notification est arrivée mais la condition a été consommée en premier, donc chaque cas a besoin que la condition soit revérifiéeRevenu de waitNotification véritableRéveil parasite (aucune notification)Réveil volé (condition déjà consommée)Revérifier la condition, puis poursuivre

Figure 1 : il y a trois chemins de retour depuis wait, et l’appelant ne peut pas dire lequel il a pris, donc vous devez toujours revérifier la condition.

Un réveil parasite est ce second cas — le phénomène de l’API d’attente qui revient sans être liée à une notification explicite destinée à vous réveiller. Il n’est pas limité aux situations dans lesquelles WakeConditionVariable n’a jamais été appelé nulle part dans le système. Par exemple, sous une charge élevée où les notifications arrivent en une courte rafale, l’implémentation peut réveiller des threads en attente supplémentaires en lot, et du côté qui n’a pas de notification correspondante cela aussi est un réveil parasite. La page des variables de condition de Microsoft Learn le dit clairement : « 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

Le point important est que l’appelant ne peut pas dire par lequel des trois cas il est revenu. Si vous ne pouvez pas le dire, il n’y a qu’une stratégie disponible : chaque fois que vous revenez, vérifiez la condition que vous attendiez elle-même, et retournez dormir si elle ne tient pas. C’est le vrai contenu de la règle de fer « enveloppez wait dans un while ». Dit à l’envers, tant que vous gardez cette règle, le code est correct peu importe lequel des trois cas vous a réveillé.

3. Pourquoi la spécification l’autorise — une notification précise est coûteuse

« Se réveiller sans avoir été notifié, n’est-ce pas juste une implémentation bâclée ? » est une question légitime. En fait il est théoriquement possible de construire une implémentation qui ne se réveille jamais de façon parasite. Même ainsi, POSIX, Windows et la norme C++ se sont tous rangés du côté « cela peut arriver ». La raison est énoncée franchement dans la Rationale de pthread_cond_wait dans POSIX (The Open Group Base Specifications).3

La première raison est la performance. Essayer d’implémenter une notification qui « réveille de façon fiable exactement un thread » strictement, surtout sur multiprocesseurs, ajoute un coût de synchronisation supplémentaire à chaque opération de variable de condition. Un ordonnanceur siège entre la notification et le réveil, et selon le moment des interruptions et de la préemption vous ne pouvez pas éviter qu’« un thread différent s’exécute avant celui que vous vouliez réveiller ». Faire payer à tout le monde le coût de sceller cela complètement est pire, pour garder les variables de condition rapides, que d’accepter que « vous pouvez occasionnellement réveiller en trop ».

La seconde raison est l’observation que ce compromis ne casse pas les applications — il les rend en fait plus robustes. Parce que les réveils parasites sont autorisés, le code correct écrit toujours une boucle qui vérifie le prédicat (la condition attendue). La Rationale de POSIX dit que forcer cette boucle rend le code auto-documenté et plus robuste.3 Une fois que la boucle est là, le sens d’une notification est rétrogradé de « une garantie que la condition tient » à « un indice que la condition peut avoir changé », et le côté attente devient tolérant aux changements de conception modestes du notificateur (réveiller trop, réveiller en lot, etc.).

Les réveils volés sont une affaire encore plus structurelle. Il y a toujours un écart de temps entre le moment où le notificateur appelle WakeConditionVariable et le moment où le thread réveillé réacquiert le verrou et revient de wait. Si un troisième thread peut prendre le verrou dans cet intervalle, il peut consommer la condition (le contenu de la file, etc.) en premier. C’est un écart qu’aucun polissage de l’implémentation ne peut effacer, parce qu’il vient de la forme de l’outil variable de condition elle-même.

Chronologie d'un réveil voléLe producteur met un élément dans la file et réveille le consommateur A en attente, mais avant que A ne réacquière le verrou le consommateur B acquiert le verrou et prend le seul élément, donc la file est vide au moment où A se réveilleConsommateur BProducteurConsommateur A (en attente)Consommateur BProducteurConsommateur A (en attente)Réveillé, en attente de réacquérir le verrouLa file est vide (volé)Ajouter un élément à la fileWakeConditionVariableAcquérir le verrou et prendre un élémentRéacquérir le verrou et revenir de waitRevérifier dans la boucle while et attendre à nouveau

Figure 2 : un « réveil volé », dans lequel un troisième thread consomme la condition dans l’écart de temps entre notification et réveil, peut arriver sous n’importe quelle implémentation.

Autrement dit, même si l’OS éradiquait complètement les réveils parasites, tant que les réveils volés existent vous ne pouvez toujours pas écrire « je me suis réveillé = la condition tient ». La boucle de revérification du thread en attente est requise de toute façon, et étant donné cela, il est moins cher d’autoriser les réveils parasites et de garder l’implémentation rapide — c’est le jugement de conception que les variables de condition portent depuis des décennies.

4. Dans quelles couches cela apparaît sous Windows

Cette propriété montre son visage quelle que soit la couche de primitive de synchronisation Windows que vous utilisez. Pour sentir le fait que vous ne pouvez pas y échapper peu importe l’API de quelle couche vous écrivez contre, nous regarderons les couches représentatives.

Les variables de condition Win32 (CONDITION_VARIABLE) sont, comme déjà noté, documentées sur SleepConditionVariableCS / SleepConditionVariableSRW comme sujettes à la fois aux réveils parasites et aux réveils volés, et vous êtes tenu de revérifier le prédicat dans une boucle while.2 L’exemple d’usage officiel (une file producteur–consommateur) écrit aussi l’attente à l’intérieur d’une boucle while.8

Le WaitOnAddress encore plus bas niveau est une API d’attente plus primitive qu’une variable de condition : « attendre jusqu’à ce que la valeur à une adresse donnée change » (Windows 8 et ultérieur). Même cette API quasi de fond a une documentation qui indique « il est garanti de revenir lorsque l’adresse est signalée, mais il est aussi permis de revenir pour d’autres raisons », et liste comme exemples de réveil précoce une condition de mémoire basse, l’abandon d’un réveil précédent pour la même adresse, et l’exécution d’une build vérifiée. C’est pourquoi l’exemple d’usage de la documentation elle-même est sous la forme d’« une boucle while qui compare à nouveau la valeur ».9

Le std::condition_variable de C++ est le même. La documentation de MSVC dit de l’attente sans prédicat qu’elle « bloque jusqu’à être signalée par un appel à notify_one / notify_all. Elle peut aussi se réveiller de façon parasite », et explique que la forme à prédicat wait(lock, pred) exécute effectivement le code suivant.5

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

Autrement dit, l’wait sous forme de prédicat recommandé en C++ n’est rien d’autre que la bibliothèque qui vous ôte « enveloppez-le dans un while », comme le décrit cet article. cppreference indique de même que l’wait sans prédicat peut être débloqué de façon parasite.4

Le Monitor.Wait / Pulse de .NET a sa propre structure de files d’une file d’attente et d’une file prête, mais la discipline ne change pas. Un thread réveillé par Pulse / PulseAll passe à la file prête et revient de Wait dans l’ordre où il peut réacquérir le verrou. Qu’un autre thread puisse consommer la condition dans l’intervalle avant que le verrou soit réacquis est le même que sous Win32, et la documentation aussi est écrite en partant du principe que « le thread réveillé réévalue la condition qui l’a fait entrer dans l’attente, et rappelle Wait si nécessaire ».610

Chaque couche exige que le prédicat soit revérifiéLa documentation officielle exige que la condition soit revérifiée après le réveil à chaque couche — C++ std::condition_variable, .NET Monitor, Win32 CONDITION_VARIABLE, et le WaitOnAddress de bas niveauC++ std::condition_variableAu réveil, revérifier la condition (while).NET Monitor.WaitWin32 CONDITION_VARIABLEWaitOnAddress

Figure 3 : changez le langage ou le framework et l’exigence officielle reste la même à chaque couche de primitive d’attente : revérifiez après vous être réveillé.

5. La façon correcte d’attendre — écrivez-la avec while et un prédicat

À partir d’ici, l’implémentation. Il n’y a que trois principes.

  1. Tenez ce que vous attendez comme un état (un prédicat), pas comme une « notification ». La condition est un état partagé protégé par un verrou — « la file est-elle non vide ? », « le drapeau est-il levé ? » — pas « ai-je été réveillé ? ».
  2. Placez toujours wait à l’intérieur d’une boucle while sur la condition. Chaque fois que vous vous réveillez, vérifiez la condition, et retournez dormir si elle ne tient pas.
  3. Mettez à jour et vérifiez la condition sous le même verrou. Le notificateur met à jour l’état puis notifie.
Flux d'une boucle d'attente correcteAcquérez le verrou et vérifiez la condition ; si elle ne tient pas, libérez le verrou et dormez ; au réveil réacquérez le verrou et revenez à la vérification de condition. Poursuivez avec le verrou détenu seulement lorsque la condition tientNonOuiAcquérir le verrouLa condition est-elle satisfaite ?wait (libérer le verrou et dormir)Réveil (réacquérir le verrou)Poursuivre en détenant encore le verrou

Figure 4 : une attente correcte est une boucle, et il n’y a pas d’écart entre vérifier la condition et la traiter (les deux se produisent alors que le verrou est détenu).

Cette forme a un bénéfice facile à manquer. Le moment où vous quittez la boucle while, il est établi, alors que vous détenez encore le verrou, que « la condition tient ». La boucle qui défend contre les réveils parasites est, telle quelle, une garantie qu’il n’y a pas d’écart de condition de course entre vérifier la condition et la traiter.

La forme de base 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

Vous pouvez appeler la notification (WakeConditionVariable) depuis l’intérieur du verrou ou depuis l’extérieur, mais la documentation dit que réveiller après avoir libéré le verrou est habituellement mieux, pour réduire les bascules de contexte.1 D’un autre côté, la mise à jour de l’état elle-même (++queueCount) doit toujours se produire sous le verrou. Ne confondez pas les deux.

La forme de base en C++ — faites de l’attente à prédicat le défaut

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();

Parce que l’wait sous forme de prédicat exécute la boucle pour vous, un while écrit à la main est inutile. Lorsque vous corrigez du code existant qui a encore une boucle écrite à la main, while (q.empty()) cv.wait(lk); est une forme correcte, donc il n’y a pas besoin de se précipiter pour la réécrire. La seule forme incorrecte est if (q.empty()) cv.wait(lk);.

La forme de base 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 ne peuvent être appelés que depuis l’intérieur d’un verrou (bloc lock), ce qui diffère de Win32. Les appeler hors du verrou lève SynchronizationLockException.10

Attendre avec un délai d’expiration — calculez le temps restant depuis une échéance

Lorsque vous attendez avec un délai d’expiration, passer « la même valeur de délai » à chaque itération de boucle étire l’attente chaque fois qu’un réveil parasite se produit. La forme correcte est de fixer d’abord l’échéance et de recalculer le temps restant.

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);
Flux correct d'une attente avec un délai d'expirationFixez d'abord l'échéance, et chaque fois que vous vous réveillez vérifiez la condition et l'échéance ; s'il reste du temps, recalculez le temps restant et revenez à l'attenteOuiNonOuiNonFixer l'échéanceLa condition est-elle satisfaite ?Poursuivre vers le traitementL'échéance est-elle passée ?Traiter le délai d'expirationCalculer le temps restant et attendre

Figure 5 : une attente avec un délai d’expiration ne repasse pas « la même durée d’attente » ; elle recalcule le temps restant depuis une échéance.

En C++, vous pouvez laisser ce calcul, échéance comprise, à la surcharge wait_until (temps absolu) plus prédicat. Même lorsqu’elle revient sur délai d’expiration elle vous donne la valeur finale du prédicat, donc vous pouvez aussi décider « avons-nous expiré, ou avons-nous réussi ? » sur le prédicat.5

6. Un catalogue de schémas à éviter

Vérifier seulement une fois avec if. C’est la vedette de l’article. Le moment où un réveil parasite ou un réveil volé se produit, le traitement poursuit avec la condition non satisfaite. Prendre depuis une file vide, toucher des données non initialisées, un double free — le symptôme devient « un plantage ou une corruption de données qui n’apparaît qu’occasionnellement ».

Vérifier ou mettre à jour la condition hors du verrou. Si le thread en attente regarde la condition hors du verrou, décide « pas encore », et dans l’écart avant d’entrer dans wait le notificateur met à jour l’état et envoie une notification, la notification est tirée vers une variable de condition sans thread en attente et disparaît. Le thread en attente entre alors dans wait et continue d’attendre une notification qui ne viendra plus jamais. C’est un réveil perdu, l’image miroir d’un réveil parasite. La raison pour laquelle l’API d’attente d’une variable de condition est conçue pour « libérer atomiquement le verrou et s’endormir » est précisément de fermer cet écart.1 Cela n’arrive pas tant que vous gardez la discipline des verrous.

Chronologie d'un réveil perduSi le thread en attente vérifie la condition hors du verrou et que le notificateur met à jour l'état et notifie dans l'écart avant que wait soit entré, la notification est envoyée à une variable de condition sans thread en attente et disparaît, et le thread en attente continue d'attendre une notification qui ne viendra jamaisNotificateurThread en attenteNotificateurThread en attenteAucun thread en attente à cet instantLa notification est déjà partie et il ne se réveille jamaisVérifier la condition hors du verrou (non satisfaite)Mettre à jour l'état et notifierEntrer dans wait

Figure 6 : si vous vérifiez la condition hors du verrou, la notification se faufile dans l’écart entre la vérification et wait — un « réveil perdu ».

Recréer la « notification transitoire » d’une variable de condition avec une impulsion sur un événement. Les événements eux-mêmes (CreateEvent + SetEvent) ne sont pas un anti-schéma. Un signal de réveil dans une configuration où un seul consommateur traite la file jusqu’à ce qu’elle soit vide, ou une instruction d’arrêt qui une fois levée n’est jamais baissée (un événement à réinitialisation manuelle), sont des usages corrects d’un événement ; et lorsque vous voulez le joindre à d’autres cibles d’attente via WaitForMultipleObjects, ou traverser une frontière de processus, une variable de condition — un objet en mode utilisateur qui ne peut pas être partagé entre processus — est celle qui ne peut pas être utilisée.1 Ce qui est dangereux est d’essayer de recréer, avec des opérations d’événement, la notification transitoire d’une variable de condition qui « ne réveille que les threads en attente à cet instant et ne laisse aucun état derrière ». Cette idée mène presque toujours à l’élément suivant, PulseEvent.

Utiliser PulseEvent. C’est une API qui, sur un événement à réinitialisation manuelle, « réveille tous ceux actuellement en attente et remet immédiatement l’événement à l’état non signalé », mais Microsoft elle-même indique dans la documentation que « cette fonction est peu fiable et ne doit pas être utilisée. Elle existe principalement pour la compatibilité ascendante. Utilisez une variable de condition à la place. » La raison est qu’un thread en attente peut être temporairement retiré de l’état d’attente par un APC en mode noyau et revenir à l’attente après que l’APC se termine. Si PulseEvent est appelé dans ce bref intervalle, ce thread n’est pas inclus parmi « ceux qui attendaient au moment où il a été appelé » et n’est pas réveillé.7 Les APC noyau sont quelque chose que l’OS utilise en interne ; l’application ne peut pas les contrôler.11 Ce problème est aussi un avertissement d’analyse statique (C28648).12 Si un réveil parasite est le problème de « réveiller en trop », ceci est le problème de « trop dormir quand vous auriez dû vous réveiller », et une boucle while ne peut pas vous sauver — parce que la notification elle-même a été perdue.

Envoyer seulement la notification d’abord, sans détenir le verrou, avant de mettre à jour l’état. Appeler WakeConditionVariable alors que l’état est encore périmé, et seulement ensuite prendre le verrou et mettre à jour l’état — dans cet ordre, le thread réveillé voit encore la condition non satisfaite lorsqu’il vérifie, et retourne dormir. Si aucune autre notification ne vient, il reste là. Notez que si vous écrivez « notifier → mettre à jour → libérer » en détenant encore le même verrou, il n’y a pas de vrai mal, parce que le thread en attente ne peut pas vérifier la condition jusqu’à ce qu’il réacquière le verrou. Même ainsi, afin que les lecteurs n’aient pas à vérifier cette condition de sûreté à chaque fois, il est plus sûr de standardiser sur l’ordre « mettre à jour l’état sous le verrou, et notifier après cela ».

7. Comment investiguer lorsque vous le rencontrez

Les bugs impliquant des réveils parasites se caractérisent par « n’apparaître que rarement ». En remontant depuis le symptôme, ils se scindent en les deux familles suivantes.

Famille 1 : le traitement poursuit avec la condition non satisfaite. Une exception ou un plantage en prenant depuis une file vide, des résultats manquants, etc. Suspectez une attente sans prédicat. Vous pouvez peigner cela mécaniquement en revue de code — cherchez les endroits où cv.wait( n’a qu’un argument, et les endroits où SleepConditionVariableCS / Monitor.Wait est enveloppé dans if plutôt que while. Cette vérification n’exige pas d’attendre une reproduction, et est le geste au plus fort levier que vous ayez.

Famille 2 : un thread qui devrait se réveiller ne le fait pas (un blocage). Suspectez un réveil perdu (vérifier la condition hors du verrou, ou notifier hors du verrou avant de mettre à jour l’état) et PulseEvent. Prenez un dump du processus bloqué et regardez la pile de chaque thread, et vous pouvez identifier quel thread est coincé dans quelle API d’attente. De là, poursuivez dans le code « qui était censé envoyer cette notification, et dans quel ordre ».

Flux de triage depuis le symptômeSi le traitement poursuit avec la condition non satisfaite, peignez les attentes sans prédicat en cherchant dans le code ; si un thread ne se réveille pas, identifiez le site d'attente depuis un dump et suspectez un réveil perdu ou PulseEventUn bug qui n'apparaît que rarementLe traitement poursuit avec la condition non satisfaiteUn thread qui devrait se réveiller ne le fait pasChercher dans le code les attentes sans prédicatIdentifier les threads en attente depuis un dumpChanger if en while, ou utiliser l'attente à prédicatSuspecter un réveil perdu ou PulseEvent

Figure 7 : que le symptôme soit « aller trop loin » ou « ne jamais se réveiller » scinde à la fois ce que vous suspectez et comment vous investiguez.

Si vous voulez le reproduire, le geste standard est d’élargir la fenêtre de course. Augmentez le jitter de timing en utilisant plus de threads que de cœurs physiques, en insérant un Sleep délibéré entre attente et notification, et en exécutant les builds debug et release. Lorsque vous confirmez que « cela a cessé de se reproduire après que nous avons corrigé l’attente sans prédicat », comparez sous le même stress.

8. Résumé — une liste de contrôle

  • Les chemins de retour depuis wait sont trois — notification véritable, réveil parasite et réveil volé — et l’appelant ne peut pas les distinguer. Donc écrivez toujours l’attente comme une boucle while sur la condition.
  • Un réveil parasite est un comportement que Win32, C++ et POSIX ont délibérément autorisé comme compromis contre la performance, et il ne disparaîtra pas avec un correctif de l’OS ou un changement de bibliothèque. Le Monitor.Wait de .NET n’est pas supposé se réveiller sans raison, mais parce que les réveils volés et les délais d’expiration existent, la même discipline while est encore requise.
  • En C++, utilisez par défaut la forme à prédicat wait(lock, pred). La bibliothèque exécute la boucle.
  • Mettez à jour et vérifiez la condition sous le même verrou. Envoyez la notification « après avoir mis à jour l’état ». La notification Win32/C++ peut se produire après avoir libéré le verrou ; le Pulse de C# est à l’intérieur du verrou seulement.
  • Pour une attente avec un délai d’expiration, fixez une échéance et recalculez le temps restant. En C++, wait_until plus un prédicat.
  • Ne recréez pas la notification transitoire d’une variable de condition avec une impulsion sur un événement. PulseEvent en particulier est quelque chose que la documentation officielle indique, en toutes lettres, « ne pas utiliser, utiliser une variable de condition à la place ». Les événements eux-mêmes restent le bon outil pour une instruction d’arrêt, la jonction avec WaitForMultipleObjects, et la synchronisation inter-processus.
  • En revue, cherchez mécaniquement « attente sans prédicat » et « if + wait ». Vous pouvez tuer un bug qui se reproduit rarement sans attendre une reproduction.

Un réveil parasite, contrairement à l’étrangeté du nom, se condense en un mot-clé d’une ligne pour le correctif — changez if en while. Et derrière cette ligne se trouve l’idée de conception de l’outil variable de condition : « une notification précise est coûteuse, donc la vérification est la responsabilité du thread en attente ». Comprenez-le comme un mécanisme et vous devriez pouvoir appliquer la même discipline sans hésitation lorsque le langage ou le framework change.

Articles connexes

Domaines de conseil associés

KomuraSoft LLC prend en charge les revues de conception multithread, l’investigation des causes profondes (analyse de dump) de plantages et de blocages qui « ne se reproduisent qu’occasionnellement », et la migration de code de synchronisation héritée (dépendant d’événements et de PulseEvent, et analogues) vers une base de variables de condition. Commencer par trier le symptôme convient — n’hésitez pas à nous contacter.

Références

  1. Microsoft Learn, Condition Variables. Sur le fait qu’une variable de condition est un objet en mode utilisateur qui libère atomiquement un verrou et entre dans une attente ; sur le fait qu’il y a des réveils parasites (réveils non liés à un réveil explicite) et des réveils volés (un autre thread s’exécutant avant le thread réveillé), de sorte qu’après être revenu d’une attente vous devez revérifier le prédicat dans une boucle while ; et sur le fait que la notification est possible depuis l’intérieur ou l’extérieur du verrou, mais que réveiller après avoir libéré le verrou est mieux pour réduire les bascules de contexte.  2 3 4 5 6 7 8

  2. Microsoft Learn, SleepConditionVariableCS function (synchapi.h). Sur le fait de libérer atomiquement une section critique spécifiée et d’attendre sur une variable de condition ; sur le thread réveillé qui réacquiert la section critique avant de revenir ; sur ERROR_TIMEOUT renvoyé au délai d’expiration ; et sur le fait qu’il y a des réveils parasites et des réveils volés, de sorte qu’après être revenu d’une attente vous devez revérifier le prédicat (typiquement dans une boucle while).  2

  3. The Open Group Base Specifications, pthread_cond_timedwait, pthread_cond_wait. Sur le fait que des réveils parasites depuis pthread_cond_wait / pthread_cond_timedwait peuvent se produire ; sur le fait que revenir de wait ne signifie rien sur la valeur du prédicat, donc le prédicat doit être réévalué ; et sur la Rationale qui indique qu’une implémentation qui « réveille exactement un » peut ralentir les opérations de variable de condition surtout sur multiprocesseurs, et qu’autoriser les réveils parasites force une boucle de vérification de prédicat et rend les applications plus robustes.  2 3

  4. cppreference.com, std::condition_variable::wait. Sur le fait que l’attente sans prédicat peut être débloquée par un réveil parasite ; et sur le fait que la surcharge à prédicat est équivalente à while (!pred()) wait(lock); et définie comme une boucle qui réacquiert le verrou et vérifie le prédicat à chaque notification ou réveil parasite.  2

  5. Microsoft Learn, condition_variable Class. Sur le fait que l’attente sans prédicat est indiquée comme se débloquant sur notify_one / notify_all et pouvant aussi se réveiller de façon parasite ; sur le fait que la forme à prédicat wait(lock, pred) exécute effectivement while (!Pred()) wait(Lck); ; et sur le fait que wait_for / wait_until ont la même propriété et une surcharge à prédicat.  2 3

  6. Microsoft Learn, Monitor.Wait Method. Sur le fait que Wait libère le verrou et entre dans la file d’attente ; sur le fait de ne pas revenir après avoir été réveillé par Pulse / PulseAll jusqu’à ce que le verrou soit réacquis ; et sur l’usage prévu étant que le thread réveillé réévalue la condition qui l’a fait entrer dans l’attente et rappelle Wait si nécessaire.  2

  7. Microsoft Learn, PulseEvent function (winbase.h). Sur le fait qu’un thread en attente peut être temporairement retiré de l’état d’attente par un APC en mode noyau et revenir après que l’APC se termine, de sorte que si PulseEvent est appelé dans cet intervalle le thread n’est pas libéré ; et sur le fait que PulseEvent est donc peu fiable et ne doit pas être utilisé dans les nouvelles applications, une variable de condition étant utilisée à la place.  2

  8. Microsoft Learn, Using Condition Variables. Sur l’exemple officiel qui implémente une file producteur–consommateur avec une section critique et deux variables de condition (BufferNotEmpty et BufferNotFull). L’attente est effectuée à l’intérieur d’une boucle qui vérifie le prédicat. 

  9. Microsoft Learn, WaitOnAddress function (synchapi.h). Sur le fait que la fonction qui attend qu’une valeur d’adresse change est garantie de revenir lorsqu’elle est signalée mais aussi permise de revenir pour d’autres raisons ; sur les exemples de réveil précoce incluant une condition de mémoire basse, l’abandon d’un réveil précédent pour la même adresse, et l’exécution d’une build vérifiée ; et sur le fait qu’il faut donc comparer à nouveau la valeur après le retour, l’exemple officiel lui-même étant une boucle while. 

  10. Microsoft Learn, Monitor.PulseAll Method. Sur le fait que PulseAll déplace les threads de la file d’attente vers la file prête, et que le thread suivant sur la file prête acquiert le verrou lorsque le verrou est libéré ; et sur le fait que Pulse / PulseAll / Wait ne peuvent être appelés que depuis l’intérieur d’un bloc de synchronisation.  2

  11. Microsoft Learn, Waits and APCs. Sur le fait que les APC noyau s’exécutent de façon préemptive, et que le système interrompt et reprend en interne une attente sans revenir de l’API d’attente, de sorte qu’un signal transitoire tel que KePulseEvent peut être manqué dans cet intervalle. 

  12. Microsoft Learn, C28648: PulseEvent is an unreliable function. Sur l’avertissement d’analyse statique à l’usage de PulseEvent ; sur le fait qu’un thread qui était hors de l’attente à cause d’un APC n’est pas libéré et peut se bloquer pour toujours ; et sur le guidage pour le remplacer par SetEvent ou un autre objet de synchronisation. 

Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.

Ces pages replacent le sujet dans un contexte plus large de services et de décisions.

Cet article est directement lié aux services suivants.

Questions fréquentes

Questions souvent posées lors d’une consultation sur le sujet de cet article.

Un réveil parasite est-il un bug de l'OS ou de la bibliothèque ?
Non — c'est un comportement énoncé dans la spécification. SleepConditionVariableCS de Win32, std::condition_variable de C++, et pthread_cond_wait de POSIX ont tous une documentation officielle ou une norme qui indique explicitement qu'un réveil non lié à une notification peut se produire. Une implémentation qui l'interdirait est théoriquement possible, mais elle ralentirait chaque opération de variable de condition (surtout la notification sur multiprocesseurs), donc le compromis est de l'autoriser en partant du principe que « la correction est préservée si le thread en attente revérifie la condition ». Le remède n'est donc pas d'attendre un correctif de l'OS, mais d'écrire toujours l'attente à l'intérieur d'une boucle while (ou d'utiliser une attente sous forme de prédicat).
Envelopper l'attente dans une boucle while nuit-il aux performances ?
En pratique le coût est négligeable. Tout ce que la boucle while ajoute est une vérification de condition supplémentaire chaque fois que vous vous réveillez, et c'est une comparaison bon marché alors que vous détenez déjà le verrou. Les réveils parasites eux-mêmes sont rares, donc l'itération de boucle supplémentaire n'arrive que dans des cas exceptionnels. Le coût de laisser la vérification comme un if, d'un autre côté, est un « bug qui ne se reproduit que rarement » dans lequel le traitement poursuit avec la condition non satisfaite — il n'y a pas de comparaison. Ce qui domine réellement le coût d'attente d'une variable de condition est la contention de verrou et la fréquence à laquelle vous notifiez, pas la présence du while.
Si j'utilise l'attente sous forme de prédicat de C++, puis-je oublier les réveils parasites ?
Pour la boucle d'attente, oui : cv.wait(lock, pred) est effectivement while (!pred()) wait(lock); donc les réveils parasites et les réveils volés sont absorbés automatiquement. Le nouveau code C++ devrait par défaut utiliser la surcharge à prédicat. Vous devez encore protéger les mises à jour de l'état partagé que le prédicat lit avec le même mutex, et le notificateur doit encore mettre à jour cet état avant d'appeler notify. L'attente à prédicat vous ôte la boucle des mains ; elle ne vous ôte pas la discipline des verrous.
Le même problème se produit-il avec Monitor.Wait de C# ?
Oui. Un thread qui attend dans Monitor.Wait est réveillé par Pulse/PulseAll puis réacquiert le verrou avant de revenir de Wait, mais dans cet intervalle un autre thread peut avoir acquis le verrou en premier et consommé la condition (un réveil volé). La documentation de Microsoft est écrite en partant du principe que le thread réveillé réévalue la condition qui l'a fait attendre, et rappelle Wait si nécessaire. Donc la forme de base en C# est aussi while (!condition) Monitor.Wait(gate);. Une contrainte qui diffère de Win32 est que vous ne pouvez appeler Wait/Pulse que depuis l'intérieur d'une instruction lock.
Les réveils parasites se produisent-ils aussi lorsque vous attendez sur un événement avec WaitForSingleObject ?
Dans une attente ordinaire (non alertable), WAIT_OBJECT_0 n'est renvoyé que lorsque l'objet devient effectivement signalé ; il n'y a pas de « réveil sans raison » du genre que les variables de condition ont. Cela dit, « l'événement est devenu signalé » et « la condition de votre application tient » sont des choses différentes. Si plusieurs consommateurs sont réveillés par le même événement, le thread qui prend le verrou en premier consomme la condition, donc vous devez encore revérifier la condition après le réveil. Les conceptions qui essaient de recréer la notification transitoire d'une variable de condition « ne réveiller que qui attend à cet instant » avec un événement tendent aussi à buter sur le problème de fiabilité de PulseEvent, donc pour attendre une condition à l'intérieur d'un processus, une variable de condition est l'outil le plus sûr.

Profil de l’auteur

Page de présentation de l’auteur de l’article.

Go Komura

Représentant de KomuraSoft LLC

Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.

Retour au blog