Réveils parasites — pourquoi les variables de condition se réveillent « sans avoir été notifiées » et comment attendre correctement sous Windows
· Go Komura · 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
waitd’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
ifpuiswaita 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 effectivementwhile (!pred()) wait(lock);. C’est le défaut pour le code neuf.5 - Le
Monitor.Waitde 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 unwhileet 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.
PulseEventen 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 |
flowchart TB
accTitle: Trois cas dans lesquels wait revient
accDescr: Une 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ée
w["Revenu de wait"] --> a["Notification véritable"]
w --> b["Réveil parasite (aucune notification)"]
w --> c["Réveil volé (condition déjà consommée)"]
a --> r["Revérifier la condition, puis poursuivre"]
b --> r
c --> r
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.
sequenceDiagram
accTitle: Chronologie d'un réveil volé
accDescr: 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éveille
participant A as Consommateur A (en attente)
participant P as Producteur
participant B as Consommateur B
P->>P: Ajouter un élément à la file
P->>A: WakeConditionVariable
Note over A: Réveillé, en attente de réacquérir le verrou
B->>B: Acquérir le verrou et prendre un élément
A->>A: Réacquérir le verrou et revenir de wait
Note over A: La file est vide (volé)
A->>A: Revé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
flowchart TB
accTitle: Chaque couche exige que le prédicat soit revérifié
accDescr: 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 niveau
cpp["C++ std::condition_variable"] --> rule["Au réveil, revérifier la condition (while)"]
net[".NET Monitor.Wait"] --> rule
win["Win32 CONDITION_VARIABLE"] --> rule
woa["WaitOnAddress"] --> rule
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.
- 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é ? ».
- 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. - Mettez à jour et vérifiez la condition sous le même verrou. Le notificateur met à jour l’état puis notifie.
flowchart TB
accTitle: Flux d'une boucle d'attente correcte
accDescr: Acqué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 tient
l["Acquérir le verrou"] --> c{"La condition est-elle satisfaite ?"}
c -->|"Non"| s["wait (libérer le verrou et dormir)"]
s --> wk["Réveil (réacquérir le verrou)"]
wk --> c
c -->|"Oui"| go["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);
flowchart TB
accTitle: Flux correct d'une attente avec un délai d'expiration
accDescr: Fixez 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'attente
d["Fixer l'échéance"] --> c{"La condition est-elle satisfaite ?"}
c -->|"Oui"| go["Poursuivre vers le traitement"]
c -->|"Non"| t{"L'échéance est-elle passée ?"}
t -->|"Oui"| to["Traiter le délai d'expiration"]
t -->|"Non"| w["Calculer le temps restant et attendre"]
w --> c
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.
sequenceDiagram
accTitle: Chronologie d'un réveil perdu
accDescr: Si 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 jamais
participant W as Thread en attente
participant N as Notificateur
W->>W: Vérifier la condition hors du verrou (non satisfaite)
N->>N: Mettre à jour l'état et notifier
Note over N: Aucun thread en attente à cet instant
W->>W: Entrer dans wait
Note over W: La notification est déjà partie et il ne se réveille jamais
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 ».
flowchart TB
accTitle: Flux de triage depuis le symptôme
accDescr: Si 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 PulseEvent
s["Un bug qui n'apparaît que rarement"] --> a["Le traitement poursuit avec la condition non satisfaite"]
s --> b["Un thread qui devrait se réveiller ne le fait pas"]
a --> a1["Chercher dans le code les attentes sans prédicat"]
b --> b1["Identifier les threads en attente depuis un dump"]
a1 -.-> a2["Changer if en while, ou utiliser l'attente à prédicat"]
b1 -.-> b2["Suspecter 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
waitsont 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.Waitde .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
Pulsede 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_untilplus un prédicat. - Ne recréez pas la notification transitoire d’une variable de condition avec une impulsion sur un événement.
PulseEventen 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 avecWaitForMultipleObjects, 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
- Bonnes pratiques de multithreading en pratique — édition C++ : éliminer les accidents structurellement avec RAII et jthread
- Bonnes pratiques du multithreading en pratique — édition langage C — écrire en toute sécurité à la manière de l’API Win32
- Bonnes pratiques de multithreading en pratique — édition .NET : ce qu’il faut décider avant d’ajouter des threads
- Pourquoi préférer l’attente sur événement à Sleep(1) sous Windows
- Pièges de la mémoire partagée et bonnes pratiques concrètes
- Les profondeurs de l’I/O Windows (partie 2) — E/S synchrones et asynchrones : ce que signifie vraiment OVERLAPPED
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.
- Conseil technique et revue de conception
- Investigation de bugs et analyse des causes
- Développement d’applications Windows
- Contact
Références
-
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
-
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
-
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
-
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
-
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
-
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
-
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
-
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. ↩
-
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. ↩
-
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
-
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. ↩
-
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 associés
Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.
DllMain et le verrou du chargeur — la vraie raison pour laquelle on vous dit de « ne rien faire dans l'initialisation d'une DLL »
Pourquoi il ne faut pas appeler LoadLibrary ni synchroniser avec d'autres threads depuis DllMain. En s'appuyant sur les sources primaires...
L'API du pool de threads Win32 — de la concurrence sans créer de threads, via CreateThreadpoolWork
Vous semez des appels CreateThread partout dans votre code natif ? Cet article explique l'API du pool de threads Win32 refondue sous Vist...
Tubes nommés en pratique — l'IPC standard de Windows, de la conception à la sécurité
Guide pratique des tubes nommés, mécanisme standard de communication inter-processus sous Windows. Cet article organise, à partir des sou...
Ce qu'est vraiment « Ne répond pas » — comment Windows décide qu'une application est bloquée, et comment concevoir des applications qui ne le sont pas
Le « Ne répond pas » de Windows est un mécanisme dans lequel l'OS juge qu'une fenêtre n'a pas récupéré de message pendant 5 secondes et l...
Bonnes pratiques de multithreading en pratique — édition C++ : éliminer les accidents structurellement avec RAII et jthread
En C++, le multithreading est un monde où une course de données devient un comportement indéfini. Cet article couvre le piège du destruct...
Sujets associés
Ces pages replacent le sujet dans un contexte plus large de services et de décisions.
Thèmes techniques Windows
Portail des sujets sur le développement Windows, l'analyse des incidents et la valorisation des actifs existants.
Services liés à ce sujet
Cet article est directement lié aux services suivants.
Développement d'applications Windows
Applications métier, intégration d'équipements et outils de communication, des besoins au développement.
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.