Sveglie spurie — Perché le condition variable si svegliano «senza essere notificate» e come attendere correttamente su Windows
· Go Komura · Windows, Multithreading, Condition variable, Sincronizzazione, C++, C#, Win32 API, Risoluzione dei problemi
«Mettiamo i dati in coda e svegliamo il thread worker in attesa. Aveva girato per sei mesi, poi un giorno ha tentato di leggere una coda vuota ed è andato in crash.» «Stiamo inviando la notifica, ma di tanto in tanto un thread non si sveglia mai.» — Il rendezvous multithread sembra funzionare, ed è un terreno di coltura per bug che compaiono solo raramente. Le indagini di questo tipo spesso atterrano su codice che avvolge il wait di una condition variable in un if. E dietro quello siede la sveglia spuria — il fenomeno di tornare da wait senza aver ricevuto una notifica.
«Si sveglia anche se nessuno l’ha notificata» suona come un difetto dell’implementazione, ma è un comportamento che Win32, C++ e POSIX scrivono tutti nella loro documentazione o nei loro standard, e il Monitor di .NET è progettato nell’assunto che «una volta svegliato, ricontrollate la condizione». Perché quel comportamento è consentito? In quale strato succede su Windows? E come scrivete l’attesa così da non finirci mai dentro? Rivolto agli sviluppatori che scrivono applicazioni aziendali e software di controllo delle attrezzature su Windows, questo articolo disfa ciò che una sveglia spuria è davvero a partire dalle fonti primarie, e riduce l’attesa corretta a Win32 (C), C++ e C#.
1. Prima di tutto, la conclusione
- Il
waitdi una condition variable può ritornare anche quando non è arrivata nessuna notifica. La documentazione ufficiale Win32 afferma che le condition variable sono soggette a sveglie spurie (sveglie non legate a un wake esplicito) e a sveglie rubate (un altro thread che consuma la condizione prima del thread svegliato).1 - Quindi dovete sempre scrivere l’attesa come «un ciclo while più un ricontrollo della condizione». Il codice che controlla una volta con
ife poi fawaitsembra funzionare, e ospita un bug che si riproduce solo raramente.12 - Questa non è una stranezza specifica di Windows; POSIX e lo standard C++ dicono la stessa cosa. Un’implementazione che «non si sveglia mai in modo spurio» rallenterebbe ogni operazione di condition variable, quindi la sveglia è consentita nell’assunto che chi attende ricontrollerà.34
- In C++, la forma con predicato
wait(lock, pred)fa eseguire il ciclo alla libreria. Quella forma gira di fattowhile (!pred()) wait(lock);. È il default per il codice nuovo.5 Monitor.Waitdi C# ha bisogno della stessa disciplina. La condizione può essere consumata nell’intervallo tra essere svegliati e riacquisire il lock, quindi ricontrollate la condizione in unwhilee tornate aWait.6- Aggiornate e controllate la condizione sotto lo stesso lock. Se guardate la condizione fuori dal lock e poi entrate in
wait, una notifica può passare attraverso lo spazio — una sveglia persa.1 - Non ricreate la notifica transitoria di una condition variable «sveglia chi sta aspettando proprio ora» con un pulse su un evento.
PulseEventin particolare può perdere la notifica nell’istante in cui un APC in modalità kernel solleva brevemente l’attesa, e Microsoft stessa dice, in tante parole, «è inaffidabile, non usatelo, usate invece una condition variable».7
Ciò che segue percorre i meccanismi che sostengono questa conclusione, in ordine.
2. Cos’è una sveglia spuria — Svegliarsi non significa che la condizione vale
Una condition variable è un primitivo di sincronizzazione per «mettere un thread a dormire finché qualche condizione vale, e farlo svegliare quando vale». Su Win32 è la struttura CONDITION_VARIABLE insieme a SleepConditionVariableCS / SleepConditionVariableSRW (attesa) e WakeConditionVariable / WakeAllConditionVariable (notifica). L’API di attesa rilascia atomicamente il lock che tenete (una critical section o un lock SRW) e va a dormire, e alla sveglia riacquisisce il lock prima di ritornare.1
La domanda è cosa significa di fatto il fatto di «essere tornati da wait». Ingenuamente vorreste pensare «è arrivata una notifica = la condizione vale», ma in realtà ci sono tre casi in cui wait ritorna.
| Caso | Notifica | Condizione al ritorno |
|---|---|---|
| Sveglia genuina | Sì | Spesso soddisfatta, ma non garantita |
| Sveglia spuria | Nessuna indirizzata a voi | Ancora non soddisfatta |
| Sveglia rubata | Sì | Un altro thread l’ha consumata per primo; non soddisfatta |
flowchart TB
accTitle: Tre casi in cui wait ritorna
accDescr: L'attesa di una condition variable può ritornare non solo da una notifica genuina ma anche da una sveglia spuria senza notifica e da una sveglia rubata in cui è arrivata una notifica ma la condizione è stata consumata per prima, quindi ogni caso ha bisogno che la condizione sia ricontrollata
w["Tornato da wait"] --> a["Notifica genuina"]
w --> b["Sveglia spuria (nessuna notifica)"]
w --> c["Sveglia rubata (condizione già consumata)"]
a --> r["Ricontrolla la condizione, poi procedi"]
b --> r
c --> r
Figura 1: Ci sono tre percorsi di ritorno da wait, e il chiamante non può dire quale ha preso, quindi dovete sempre ricontrollare la condizione.
Una sveglia spuria è questo secondo caso — il fenomeno dell’API di attesa che ritorna senza essere legata a una notifica esplicita che era destinata a svegliarvi. Non è limitata a situazioni in cui WakeConditionVariable non è mai stato chiamato da nessuna parte nel sistema. Per esempio, sotto un carico alto in cui le notifiche arrivano in un burst breve, l’implementazione può svegliare thread in attesa extra in un lotto, e dal lato che non ha una notifica corrispondente anche quella è una sveglia spuria. La pagina sulle condition variable di Microsoft Learn lo dice chiaramente: «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
Il punto importante è che il chiamante non può dire attraverso quale dei tre casi è tornato. Se non potete dirlo, c’è una sola strategia disponibile: ogni volta che ritornate, controllate la condizione per cui stavate aspettando stessa, e tornate a dormire se non vale. È il contenuto reale della regola di ferro «avvolgete wait in un while». Detto al contrario, finché tenete quella regola, il codice è corretto qualunque dei tre casi vi abbia svegliato.
3. Perché la specifica lo consente — La notifica precisa è costosa
«Svegliarsi senza essere notificati è solo un’implementazione sciatta, no?» è una domanda lecita. Di fatto è teoricamente possibile costruire un’implementazione che non si sveglia mai in modo spurio. Anche così, POSIX, Windows e lo standard C++ sono tutti scesi dal lato «può succedere». La ragione è dichiarata francamente nel Rationale di pthread_cond_wait in POSIX (The Open Group Base Specifications).3
La prima ragione è le prestazioni. Tentare di implementare una notifica che «sveglia in modo affidabile esattamente un thread» in modo stretto, specialmente su multiprocessore, aggiunge un costo extra di sincronizzazione a ogni operazione di condition variable. Tra notifica e sveglia siede uno scheduler, e a seconda dei tempi di interrupt e preemption non potete evitare «un thread diverso che gira prima di quello che intendevate svegliare». Far pagare a tutti il costo di sigillare quello completamente è peggio, per tenere le condition variable veloci, che accettare che «occasionalmente potete svegliare in extra».
La seconda ragione è l’osservazione che questo compromesso non rompe le applicazioni — in realtà le rende più robuste. Poiché le sveglie spurie sono consentite, il codice corretto scrive sempre un ciclo che controlla il predicato (la condizione in attesa). Il Rationale di POSIX dice che forzare questo ciclo rende il codice auto-documentante e più robusto.3 Una volta che il ciclo c’è, il significato di una notifica è declassato da «una garanzia che la condizione vale» a «un indizio che la condizione può essere cambiata», e il lato in attesa diventa tollerante a cambiamenti di progetto modesti sul lato che notifica (svegliare troppi, svegliare in un lotto, e così via).
Le sveglie rubate sono una questione ancora più strutturale. C’è sempre un vuoto di tempo tra il momento in cui chi notifica chiama WakeConditionVariable e il momento in cui il thread svegliato riacquisisce il lock e torna da wait. Se un terzo thread può prendere il lock in quell’intervallo, può consumare la condizione (i contenuti della coda, e così via) per primo. È un vuoto che nessuna quantità di lucidatura dell’implementazione può cancellare, perché viene dalla forma stessa dello strumento condition variable.
sequenceDiagram
accTitle: Linea temporale di una sveglia rubata
accDescr: Il produttore mette un elemento in coda e sveglia il consumatore A in attesa, ma prima che A riacquisisca il lock il consumatore B acquisisce il lock e prende l'unico elemento, quindi la coda è vuota nel momento in cui A si sveglia
participant A as Consumatore A (in attesa)
participant P as Produttore
participant B as Consumatore B
P->>P: Aggiungi un elemento alla coda
P->>A: WakeConditionVariable
Note over A: Svegliato, in attesa di riacquisire il lock
B->>B: Acquisisci il lock e prendi un elemento
A->>A: Riacquisisci il lock e torna da wait
Note over A: La coda è vuota (rubata)
A->>A: Ricontrolla nel ciclo while e attendi di nuovo
Figura 2: Una «sveglia rubata», in cui un terzo thread consuma la condizione nel vuoto di tempo tra notifica e sveglia, può succedere sotto qualsiasi implementazione.
In altre parole, anche se il sistema operativo sradicasse completamente le sveglie spurie, finché esistono le sveglie rubate non potete comunque scrivere «mi sono svegliato = la condizione vale». Il ciclo di ricontrollo di chi attende è comunque richiesto, e dato quello, è più economico consentire le sveglie spurie e tenere l’implementazione veloce — è il giudizio di progetto che le condition variable portano da decenni.
4. In quali strati compare su Windows
Questa proprietà mostra il volto qualunque sia lo strato di primitivo di sincronizzazione Windows che usate. Per avere il senso del fatto che non potete sfuggirle qualunque sia lo strato di API su cui scrivete, guarderemo gli strati rappresentativi.
Le condition variable Win32 (CONDITION_VARIABLE) sono, come già notato, documentate su SleepConditionVariableCS / SleepConditionVariableSRW come soggette sia a sveglie spurie sia a sveglie rubate, e siete tenuti a ricontrollare il predicato in un ciclo while.2 Anche l’esempio ufficiale di uso (una coda produttore–consumatore) scrive l’attesa dentro un ciclo while.8
Il WaitOnAddress ancora più di basso livello è un’API di attesa più primitiva di una condition variable: «aspetta finché il valore a un dato indirizzo cambia» (Windows 8 e successivi). Anche questa API quasi di fondo ha documentazione che afferma «è garantito che ritorni quando l’indirizzo è signaled, ma è anche consentito che ritorni per altri motivi», e elenca come esempi di sveglia anticipata una condizione di poca memoria, l’abbandono di un wake precedente per lo stesso indirizzo, e l’esecuzione di una checked build. È per questo che l’esempio di uso della documentazione stessa è nella forma di «un ciclo while che confronta di nuovo il valore».9
std::condition_variable di C++ è lo stesso. La documentazione di MSVC dice del wait senza predicato che «si blocca fino a essere signaled da una chiamata a notify_one / notify_all. Può anche svegliarsi in modo spurio», e spiega che la forma con predicato wait(lock, pred) gira di fatto il codice seguente.5
while (!Pred())
wait(Lck);
In altre parole, il wait in forma di predicato raccomandato in C++ non è altro che la libreria che vi toglie dalle mani «avvolgetelo in un while», come descrive questo articolo. Anche cppreference afferma che il wait senza predicato può essere sbloccato in modo spurio.4
Monitor.Wait / Pulse di .NET ha una propria struttura di code di una coda di attesa e una coda ready, ma la disciplina non cambia. Un thread svegliato da Pulse / PulseAll si muove nella coda ready e torna da Wait nell’ordine in cui può riacquisire il lock. Il fatto che un altro thread possa consumare la condizione nell’intervallo prima che il lock sia riacquisito è lo stesso che su Win32, e anche la documentazione è scritta nell’assunto che «il thread svegliato rivaluta la condizione che lo ha fatto entrare nell’attesa, e chiama di nuovo Wait se necessario».610
flowchart TB
accTitle: Ogni strato richiede che il predicato sia ricontrollato
accDescr: La documentazione ufficiale richiede che la condizione sia ricontrollata dopo la sveglia a ogni strato — C++ std::condition_variable, .NET Monitor, Win32 CONDITION_VARIABLE, e il WaitOnAddress di basso livello
cpp["C++ std::condition_variable"] --> rule["Alla sveglia, ricontrolla la condizione (while)"]
net[".NET Monitor.Wait"] --> rule
win["Win32 CONDITION_VARIABLE"] --> rule
woa["WaitOnAddress"] --> rule
Figura 3: Cambiate il linguaggio o il framework e il requisito ufficiale è ancora lo stesso a ogni strato di primitivo di attesa: ricontrollate dopo che vi svegliate.
5. Il modo corretto di attendere — Scrivetelo con while e un predicato
Da qui in poi, implementazione. Ci sono solo tre principi.
- Tenete ciò per cui aspettate come stato (un predicato), non come una «notifica». La condizione è stato condiviso protetto da un lock — «la coda è non vuota?», «il flag è impostato?» — non «sono stato svegliato?».
- Mettete sempre
waitdentro un ciclo while sulla condizione. Ogni volta che vi svegliate, controllate la condizione, e tornate a dormire se non vale. - Aggiornate e controllate la condizione sotto lo stesso lock. Chi notifica aggiorna lo stato e poi notifica.
flowchart TB
accTitle: Flusso di un ciclo di attesa corretto
accDescr: Acquisite il lock e controllate la condizione; se non vale, rilasciate il lock e dormite; alla sveglia riacquisite il lock e tornate al controllo della condizione. Procedete tenendo il lock solo quando la condizione vale
l["Acquisisci il lock"] --> c{"La condizione è soddisfatta?"}
c -->|"No"| s["wait (rilascia il lock e dormi)"]
s --> wk["Sveglia (riacquisisci il lock)"]
wk --> c
c -->|"Sì"| go["Procedi tenendo ancora il lock"]
Figura 4: Un’attesa corretta è un ciclo, e non c’è vuoto tra controllare la condizione e elaborarla (entrambe succedono mentre il lock è tenuto).
Questa forma ha un beneficio facile da perdere. Nel momento in cui uscite dal ciclo while, è stabilito, mentre tenete ancora il lock, che «la condizione vale». Il ciclo che difende dalle sveglie spurie è, così com’è, una garanzia che non c’è un vuoto di race condition tra controllare la condizione e elaborarla.
La forma di base in 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
Potete chiamare la notifica (WakeConditionVariable) da dentro il lock o da fuori, ma la documentazione dice che svegliare dopo aver rilasciato il lock è di solito meglio, per ridurre i context switch.1 D’altra parte, l’aggiornamento dello stato stesso (++queueCount) deve sempre succedere sotto il lock. Non confondete i due.
La forma di base in C++ — Fate dell’attesa con predicato il default
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();
Poiché il wait in forma di predicato esegue il ciclo per voi, un while scritto a mano è superfluo. Quando correggete codice esistente che ha ancora un ciclo scritto a mano, while (q.empty()) cv.wait(lk); è una forma corretta, quindi non c’è bisogno di affrettarsi a riscriverlo. L’unica forma non corretta è if (q.empty()) cv.wait(lk);.
La forma di base in 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 si possono chiamare solo da dentro un lock (blocco lock), il che differisce da Win32. Chiamarli fuori dal lock lancia SynchronizationLockException.10
Attendere con un timeout — Calcolate il tempo rimanente da una scadenza
Quando attendete con un timeout, passare «lo stesso valore di timeout» a ogni iterazione del ciclo allunga l’attesa ogni volta che succede una sveglia spuria. La forma corretta è fissare prima la scadenza e ricalcolare il tempo rimanente.
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: Flusso corretto di un'attesa con un timeout
accDescr: Fissate prima la scadenza, e ogni volta che vi svegliate controllate la condizione e la scadenza; se c'è ancora tempo, ricalcolate il tempo rimanente e tornate all'attesa
d["Fissa la scadenza"] --> c{"La condizione è soddisfatta?"}
c -->|"Sì"| go["Procedi all'elaborazione"]
c -->|"No"| t{"La scadenza è passata?"}
t -->|"Sì"| to["Gestisci il timeout"]
t -->|"No"| w["Calcola il tempo rimanente e attendi"]
w --> c
Figura 5: Un’attesa con un timeout non passa di nuovo «la stessa durata di attesa»; ricalcola il tempo rimanente da una scadenza.
In C++, potete lasciare questo calcolo, scadenza compresa, all’overload wait_until (tempo assoluto) più predicato. Anche quando ritorna per timeout vi dà il valore finale del predicato, quindi potete anche decidere «siamo andati in timeout, o ce l’abbiamo fatta?» sul predicato.5
6. Un catalogo di pattern da evitare
Controllare solo una volta con if. È la protagonista dell’articolo. Nel momento in cui succede una sveglia spuria o una sveglia rubata, l’elaborazione procede con la condizione non soddisfatta. Prendere da una coda vuota, toccare dati non inizializzati, un double free — il sintomo diventa «un crash o una corruzione dei dati che compare solo occasionalmente».
Controllare o aggiornare la condizione fuori dal lock. Se chi attende guarda la condizione fuori dal lock, decide «non ancora», e nel vuoto prima di entrare in wait chi notifica aggiorna lo stato e invia una notifica, la notifica viene sparata a una condition variable senza nessuno in attesa e svanisce. Chi attende entra allora in wait e continua ad aspettare una notifica che non arriverà più. È una sveglia persa, l’immagine speculare di una sveglia spuria. La ragione per cui l’API di attesa di una condition variable è progettata per «rilasciare atomicamente il lock e andare a dormire» è proprio chiudere questo vuoto.1 Non succede finché tenete la disciplina dei lock.
sequenceDiagram
accTitle: Linea temporale di una sveglia persa
accDescr: Se chi attende controlla la condizione fuori dal lock e chi notifica aggiorna lo stato e notifica nel vuoto prima che venga inserito wait, la notifica viene inviata a una condition variable senza nessuno in attesa e svanisce, e chi attende continua ad aspettare una notifica che non arriverà mai
participant W as Chi attende
participant N as Chi notifica
W->>W: Controlla la condizione fuori dal lock (non soddisfatta)
N->>N: Aggiorna lo stato e notifica
Note over N: Nessuno in attesa in questo momento
W->>W: Entra in wait
Note over W: La notifica è già andata e non si sveglia mai
Figura 6: Se controllate la condizione fuori dal lock, la notifica scivola attraverso il vuoto tra il controllo e wait — una «sveglia persa».
Ricreare la «notifica transitoria» di una condition variable con un pulse su un evento. Gli eventi stessi (CreateEvent + SetEvent) non sono un anti-pattern. Un segnale di sveglia in un allestimento in cui un singolo consumatore elabora la coda finché è vuota, o un’istruzione di stop che una volta alzata non viene mai abbassata (un evento a reset manuale), sono usi corretti di un evento; e quando volete unirlo ad altri bersagli di attesa via WaitForMultipleObjects, o attraversare un confine di processo, una condition variable — un oggetto in user-mode che non può essere condiviso tra processi — è quella che non si può usare.1 Ciò che è pericoloso è tentare di ricreare, con operazioni su eventi, la notifica transitoria di una condition variable che «sveglia solo i thread in attesa in quell’istante e non lascia stato dietro». Quell’idea porta quasi sempre alla voce successiva, PulseEvent.
Usare PulseEvent. È un’API che, su un evento a reset manuale, «sveglia tutti quelli attualmente in attesa e restituisce immediatamente l’evento allo stato non signaled», ma Microsoft stessa afferma nella documentazione che «questa funzione è inaffidabile e non va usata. Esiste principalmente per compatibilità all’indietro. Usate invece una condition variable.» La ragione è che un thread in attesa può essere rimosso temporaneamente dallo stato di attesa da un APC in modalità kernel e tornare all’attesa dopo che l’APC completa. Se PulseEvent viene chiamato in quel breve intervallo, quel thread non è incluso tra «quelli che stavano aspettando nel momento in cui è stato chiamato» e non viene svegliato.7 Gli APC kernel sono qualcosa che il sistema operativo usa internamente; l’app non può controllarli.11 Questo problema è anche un avviso di analisi statica (C28648).12 Se una sveglia spuria è il problema di «svegliare in extra», questo è il problema di «dormire troppo quando avreste dovuto svegliarvi», e un ciclo while non può salvarvi — perché la notifica stessa è stata persa.
Inviare solo la notifica per prima, senza tenere il lock, prima di aggiornare lo stato. Chiamare WakeConditionVariable mentre lo stato è ancora stantio, e solo allora prendere il lock e aggiornare lo stato — in quell’ordine, il thread svegliato vede ancora la condizione non soddisfatta quando controlla, e torna a dormire. Se non arriva un’ulteriore notifica, resta lì. Si noti che se scrivete «notifica → aggiorna → rilascia» tenendo ancora lo stesso lock, non c’è un danno reale, perché chi attende non può controllare la condizione finché non riacquisisce il lock. Anche così, perché i lettori non debbano verificare questa condizione di sicurezza ogni volta, è più sicuro standardizzare sull’ordine «aggiorna lo stato sotto il lock, e notifica dopo quello».
7. Come indagare quando lo incontrate
I bug che coinvolgono sveglie spurie sono caratterizzati dal «comparire solo raramente». Lavorando a ritroso dal sintomo, si spezzano nelle due famiglie seguenti.
Famiglia 1: L’elaborazione procede con la condizione non soddisfatta. Un’eccezione o un crash dal prendere da una coda vuota, risultati mancanti, e così via. Sospettate un’attesa senza predicato. Potete pettinare questo in modo meccanico in code review — cercate i posti in cui cv.wait( ha un solo argomento, e i posti in cui SleepConditionVariableCS / Monitor.Wait è avvolto in if piuttosto che in while. Questo controllo non richiede di aspettare una riproduzione, ed è la mossa a più alta leva che avete.
Famiglia 2: Un thread che dovrebbe svegliarsi non lo fa (un hang). Sospettate una sveglia persa (controllare la condizione fuori dal lock, o notificare fuori dal lock prima di aggiornare lo stato) e PulseEvent. Prendete un dump dal processo bloccato e guardate lo stack di ogni thread, e potete identificare quale thread è fermo in quale API di attesa. Da lì, inseguite nel codice «chi doveva inviare quella notifica, e in quale ordine».
flowchart TB
accTitle: Flusso di triage dal sintomo
accDescr: Se l'elaborazione procede con la condizione non soddisfatta, pettinate le attese senza predicato cercando nel codice; se un thread non si sveglia, identificate il sito di attesa da un dump e sospettate una sveglia persa o PulseEvent
s["Un bug che compare solo raramente"] --> a["L'elaborazione procede con la condizione non soddisfatta"]
s --> b["Un thread che dovrebbe svegliarsi non lo fa"]
a --> a1["Cerca nel codice le attese senza predicato"]
b --> b1["Identifica i thread in attesa da un dump"]
a1 -.-> a2["Cambia if in while, o usa wait con predicato"]
b1 -.-> b2["Sospetta una sveglia persa o PulseEvent"]
Figura 7: Se il sintomo è «procedere troppo in là» o «non svegliarsi mai» spezza sia ciò che sospettate sia come indagate.
Se volete riprodurlo, la mossa standard è allargare la finestra di race. Aumentate il jitter di timing usando più thread dei core fisici, inserendo un Sleep deliberato tra wait e notify, ed eseguendo sia build debug sia release. Quando confermate che «ha smesso di riprodursi dopo che abbiamo corretto l’attesa senza predicato», confrontate sotto lo stesso stress.
8. Riepilogo — Una checklist
- I percorsi di ritorno da
waitsono tre — notifica genuina, sveglia spuria e sveglia rubata — e il chiamante non può distinguerli. Quindi scrivete sempre l’attesa come un ciclo while sulla condizione. - Una sveglia spuria è un comportamento che Win32, C++ e POSIX hanno deliberatamente consentito come compromesso rispetto alle prestazioni, e non andrà via con una correzione del sistema operativo o uno scambio di libreria.
Monitor.Waitdi .NET non è assunto a svegliarsi senza motivo, ma poiché esistono sveglie rubate e timeout, la stessa disciplina del while è comunque richiesta. - In C++, usate per default la forma con predicato
wait(lock, pred). La libreria esegue il ciclo. - Aggiornate e controllate la condizione sotto lo stesso lock. Inviate la notifica «dopo aver aggiornato lo stato». La notifica Win32/C++ può succedere dopo aver rilasciato il lock; il
Pulsedi C# è solo dentro il lock. - Per un’attesa con un timeout, fissate una scadenza e ricalcolate il tempo rimanente. In C++,
wait_untilpiù un predicato. - Non ricreate la notifica transitoria di una condition variable con un pulse su un evento.
PulseEventin particolare è qualcosa che la documentazione ufficiale afferma, in tante parole, «non usate, usate invece una condition variable». Gli eventi stessi restano lo strumento giusto per un’istruzione di stop, l’unione conWaitForMultipleObjects, e la sincronizzazione tra processi. - In review, cercate in modo meccanico «attesa senza predicato» e «
if+ wait». Potete uccidere un bug che si riproduce raramente senza aspettare una riproduzione.
Una sveglia spuria, contrariamente alla stranezza del nome, si condensa in una parola chiave di una riga per la correzione — cambiate if in while. E dietro quella riga siede l’idea di progetto dello strumento condition variable: «la notifica precisa è costosa, quindi il controllo è responsabilità di chi attende». Capitela come un meccanismo e dovreste essere in grado di applicare la stessa disciplina senza esitazione quando cambiano il linguaggio o il framework.
Articoli correlati
- Best practice di multithreading in pratica: edizione C++ — Eliminare gli incidenti per struttura con RAII e jthread
- Best practice di multithreading in pratica: edizione C — Scrivere in sicurezza alla maniera delle API Win32
- Best practice di multithreading in pratica: edizione .NET — Cosa decidere prima di aggiungere altri thread
- Perché preferire l’attesa su evento a Sleep(1) su Windows
- Trappole della memoria condivisa e buone pratiche operative
- Le profondità dell’I/O di Windows (parte 2) — I/O sincrono e asincrono: cosa significa davvero OVERLAPPED
Aree di consulenza correlate
KomuraSoft LLC si occupa di revisioni di progetto multithread, di indagine sulle cause (analisi di dump) di crash e hang che «si riproducono solo occasionalmente», e di migrare codice di sincronizzazione legacy (dipendente da eventi e PulseEvent, e simili) su una base di condition variable. Partire dal triage del sintomo va benissimo — sentitevi liberi di contattarci.
- Consulenza tecnica e revisione della progettazione
- Indagine sui difetti e analisi delle cause
- Sviluppo di app Windows
- Contattaci
Riferimenti
-
Microsoft Learn, Condition Variables. Sul fatto che una condition variable è un oggetto in user-mode che rilascia atomicamente un lock ed entra in un’attesa; sul fatto che ci sono sveglie spurie (sveglie non legate a un wake esplicito) e sveglie rubate (un altro thread che gira prima del thread svegliato), così che dopo il ritorno da un’attesa dovreste ricontrollare il predicato in un ciclo while; e sul fatto che la notifica è possibile da dentro o da fuori il lock, ma svegliare dopo aver rilasciato il lock è meglio per ridurre i context switch. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8
-
Microsoft Learn, SleepConditionVariableCS function (synchapi.h). Sul fatto di rilasciare atomicamente una critical section specificata e attendere su una condition variable; sul fatto che il thread svegliato riacquisisce la critical section prima di ritornare; sul fatto che ERROR_TIMEOUT viene restituito al timeout; e sul fatto che ci sono sveglie spurie e sveglie rubate, così che dopo il ritorno da un’attesa dovreste ricontrollare il predicato (tipicamente in un ciclo while). ↩ ↩2
-
The Open Group Base Specifications, pthread_cond_timedwait, pthread_cond_wait. Sul fatto che le sveglie spurie da pthread_cond_wait / pthread_cond_timedwait possono verificarsi; sul fatto che tornare da wait non significa niente sul valore del predicato, quindi il predicato andrebbe rivalutato; e sul fatto che il Rationale afferma che un’implementazione che «sveglia esattamente uno» può rallentare le operazioni di condition variable specialmente su multiprocessore, e che consentire le sveglie spurie forza un ciclo di controllo del predicato e rende le applicazioni più robuste. ↩ ↩2 ↩3
-
cppreference.com, std::condition_variable::wait. Sul fatto che il wait senza predicato può essere sbloccato da una sveglia spuria; e sul fatto che l’overload con predicato è equivalente a while (!pred()) wait(lock); e definito come un ciclo che riacquisisce il lock e controlla il predicato a ogni notifica o sveglia spuria. ↩ ↩2
-
Microsoft Learn, condition_variable Class. Sul fatto che il wait senza predicato è dichiarato a sbloccarsi su notify_one / notify_all e anche a potersi svegliare in modo spurio; sul fatto che la forma con predicato wait(lock, pred) gira di fatto while (!Pred()) wait(Lck);; e sul fatto che wait_for / wait_until hanno la stessa proprietà e un overload con predicato. ↩ ↩2 ↩3
-
Microsoft Learn, Monitor.Wait Method. Sul fatto che Wait rilascia il lock ed entra nella coda di attesa; sul fatto di non ritornare dopo essere stati svegliati da Pulse / PulseAll finché il lock non è riacquisito; e sull’uso previsto che il thread svegliato rivaluti la condizione che lo ha fatto entrare nell’attesa e chiami di nuovo Wait se necessario. ↩ ↩2
-
Microsoft Learn, PulseEvent function (winbase.h). Sul fatto che un thread in attesa può essere rimosso temporaneamente dallo stato di attesa da un APC in modalità kernel e tornare dopo che l’APC completa, così che se PulseEvent viene chiamato in quell’intervallo il thread non viene rilasciato; e sul fatto che PulseEvent è quindi inaffidabile e non va usato nelle applicazioni nuove, usando invece una condition variable. ↩ ↩2
-
Microsoft Learn, Using Condition Variables. Sull’esempio ufficiale che implementa una coda produttore–consumatore con una critical section e due condition variable (BufferNotEmpty e BufferNotFull). L’attesa viene eseguita dentro un ciclo che controlla il predicato. ↩
-
Microsoft Learn, WaitOnAddress function (synchapi.h). Sul fatto che la funzione che aspetta che il valore di un indirizzo cambi è garantita a ritornare quando signaled ma è anche consentita a ritornare per altri motivi; sugli esempi di sveglia anticipata che includono una condizione di poca memoria, l’abbandono di un wake precedente per lo stesso indirizzo, e l’esecuzione di una checked build; e sul fatto che quindi occorre confrontare di nuovo il valore dopo il ritorno, l’esempio ufficiale stesso essendo un ciclo while. ↩
-
Microsoft Learn, Monitor.PulseAll Method. Sul fatto che PulseAll muove i thread dalla coda di attesa alla coda ready, e il thread successivo sulla coda ready acquisisce il lock quando il lock viene rilasciato; e sul fatto che Pulse / PulseAll / Wait si possono chiamare solo da dentro un blocco di sincronizzazione. ↩ ↩2
-
Microsoft Learn, Waits and APCs. Sul fatto che gli APC kernel si eseguono in modo preemptive, e il sistema internamente interrompe e riprende un’attesa senza tornare dall’API di attesa, così che un segnale transitorio come KePulseEvent può essere perso in quell’intervallo. ↩
-
Microsoft Learn, C28648: PulseEvent is an unreliable function. Sull’avviso di analisi statica sull’uso di PulseEvent; sul fatto che un thread che era fuori dall’attesa a causa di un APC non viene rilasciato e può restare in hang per sempre; e sulla guida per sostituirlo con SetEvent o un altro oggetto di sincronizzazione. ↩
Articoli correlati
Articoli recenti con gli stessi tag per approfondire argomenti vicini.
DllMain e il loader lock — Il vero motivo per cui vi dicono di «non fare niente nell'inizializzazione della DLL»
Perché non dovete chiamare LoadLibrary o sincronizzarvi con altri thread da DllMain. A partire dalle fonti primarie, l'articolo spiega co...
L'API thread pool Win32 — Concorrenza senza creare thread, tramite CreateThreadpoolWork
State spargendo chiamate CreateThread per tutto il codice nativo? Questo articolo spiega l'API thread pool Win32 ridisegnata in Vista — i...
Named pipe in pratica — L'IPC standard di Windows, dalla progettazione alla sicurezza
Guida pratica alle named pipe, il meccanismo standard di comunicazione tra processi su Windows. L'articolo organizza, a partire dalle fon...
Che cos'è davvero «Non risponde» — Come Windows decide che un'app è bloccata, e come progettare app che non lo sono
Il «Non risponde» di Windows è un meccanismo in cui il sistema operativo giudica che una finestra non ha prelevato un messaggio per 5 sec...
App che si rompono alla ripresa dalla sospensione — Come funzionano gli eventi di alimentazione di Windows e come costruire app aziendali che li sopravvivono
Aprite il portatile e le connessioni dell'app aziendale sono morte — la causa è un progetto che non ha mai tenuto conto della sospensione...
Argomenti correlati
Queste pagine collocano l’argomento in un contesto più ampio di servizi e decisioni.
Argomenti tecnici Windows
Portale su sviluppo Windows, analisi dei problemi e valorizzazione delle risorse esistenti.
Servizi collegati all’argomento
L’articolo è direttamente collegato ai servizi seguenti.
Sviluppo di applicazioni Windows
Applicazioni aziendali, integrazione di dispositivi e strumenti di comunicazione, dai requisiti allo sviluppo.
Domande frequenti
Domande che ricorrono nelle consulenze sull’argomento dell’articolo.
- Una sveglia spuria è un bug del sistema operativo o della libreria?
- No — è un comportamento scritto nella specifica. SleepConditionVariableCS di Win32, std::condition_variable di C++, e pthread_cond_wait di POSIX hanno tutti documentazione ufficiale o uno standard che afferma esplicitamente che può verificarsi una sveglia non legata a una notifica. Un'implementazione che lo vietasse è teoricamente possibile, ma rallenterebbe ogni operazione di condition variable (soprattutto la notifica su multiprocessore), quindi il compromesso è consentirlo nell'intesa che «la correttezza è preservata se chi attende ricontrolla la condizione». Il rimedio quindi non è aspettare una correzione del sistema operativo, ma scrivere sempre l'attesa dentro un ciclo while (o usare un'attesa in forma di predicato).
- Avvolgere wait in un ciclo while fa male alle prestazioni?
- In pratica il costo è trascurabile. Tutto ciò che il ciclo while aggiunge è un controllo extra della condizione ogni volta che vi svegliate, ed è un confronto economico mentre tenete già il lock. Le sveglie spurie stesse sono rare, quindi l'iterazione extra del ciclo avviene solo in casi eccezionali. Il costo di lasciare il controllo come un if, d'altra parte, è un «bug che si riproduce solo raramente» in cui l'elaborazione procede con la condizione non soddisfatta — non c'è confronto. Ciò che di fatto domina il costo dell'attesa di una condition variable è la contesa sul lock e quanto spesso notificate, non se il while c'è.
- Se uso l'attesa in forma di predicato di C++, posso dimenticarmi delle sveglie spurie?
- Per il ciclo di attesa, sì: cv.wait(lock, pred) è di fatto while (!pred()) wait(lock); quindi sia le sveglie spurie sia le sveglie rubate vengono assorbite automaticamente. Il codice C++ nuovo dovrebbe usare per default l'overload con predicato. Dovete comunque proteggere gli aggiornamenti dello stato condiviso che il predicato legge con lo stesso mutex, e chi notifica deve ancora aggiornare quello stato prima di chiamare notify. L'attesa con predicato vi toglie il ciclo dalle mani; non vi toglie la disciplina dei lock dalle mani.
- Lo stesso problema succede con Monitor.Wait di C#?
- Sì. Un thread in attesa in Monitor.Wait viene svegliato da Pulse/PulseAll e poi riacquisisce il lock prima di tornare da Wait, ma in quell'intervallo un altro thread può aver acquisito il lock per primo e aver consumato la condizione (una sveglia rubata). La documentazione di Microsoft è scritta nell'assunto che il thread svegliato rivaluti la condizione che lo ha fatto attendere, e chiami di nuovo Wait se necessario. Quindi la forma di base in C# è anche while (!condition) Monitor.Wait(gate);. Un vincolo che differisce da Win32 è che potete chiamare Wait/Pulse solo da dentro un'istruzione lock.
- Le sveglie spurie succedono anche quando attendete su un evento con WaitForSingleObject?
- In un'attesa ordinaria (non alertable), WAIT_OBJECT_0 viene restituito solo quando l'oggetto diventa effettivamente signaled; non c'è una «sveglia senza motivo» del tipo che hanno le condition variable. Detto questo, «l'evento è diventato signaled» e «la condizione della vostra applicazione vale» sono cose diverse. Se diversi consumatori vengono svegliati dallo stesso evento, il thread che prende il lock per primo consuma la condizione, quindi dovete comunque ricontrollare la condizione dopo esservi svegliati. I progetti che tentano di ricreare la notifica transitoria di una condition variable «sveglia solo chi sta aspettando in quell'istante» con un evento tendono anche a incontrare il problema di affidabilità di PulseEvent, quindi per attendere una condizione dentro un processo, una condition variable è lo strumento più sicuro.
Profilo dell’autore
Pagina di presentazione dell’autore dell’articolo.
Go Komura
Rappresentante di KomuraSoft LLC
Specializzato nello sviluppo di software Windows, nella consulenza tecnica e nell’analisi dei malfunzionamenti, soprattutto nei progetti con sistemi esistenti e guasti difficili da riprodurre.