Perché preferire l'attesa su evento a Sleep(1) su Windows
· Aggiornato il: · Go Komura · Sviluppo Windows, Sincronizzazione, Eventi, Timer, Progettazione
Nel post precedente, A Practical Guide to Soft Real-Time on Windows, abbiamo discusso come evitare cicli periodici che si appoggiano a Sleep.
Questa volta ci concentriamo su un singolo punto di quella discussione: perché dovresti preferire un’attesa su evento a un’attesa su timer breve.
Su Windows, una progettazione che “controlla a intervalli fissi” usando Sleep(1) o attese con timeout brevi è inevitabilmente influenzata dalla granularità del clock di sistema e dal ritardo di scheduling che ne segue.
Con impostazioni tipiche, una risoluzione del timer della piattaforma dell’ordine di 15,6 ms è la linea di base usuale, quindi anche se intendi “ricontrollare tra 1 ms”, l’attesa che ottieni effettivamente è spesso piuttosto grossolana.
D’altra parte, se ciò che vuoi veramente aspettare è un “evento” piuttosto che “il tempo” — lavoro che arriva, I/O che si completa, una richiesta di arresto, un cambiamento di stato — non c’è bisogno di andare a controllare a intervalli fissi. Il lato in cui si verifica l’evento segnala, e il lato in attesa aspetta sull’evento. Questo approccio è più naturale per latenza, CPU e consumo energetico.
Le domande a cui questo articolo cerca di rispondere sono queste quattro:
- Perché
Sleep(1)e le attese su timer brevi sono meno precise di quanto ci si possa aspettare? - Perché le attese su evento sono meno soggette a quella limitazione?
- In quali situazioni si dovrebbe scegliere un evento invece di un timer?
- Quando si dovrebbe comunque usare un timer?
1. La conclusione prima di tutto
- Se stai aspettando l’arrivo di un lavoro o il completamento di un I/O, attendi su un evento, non su un timer.
- Le attese temporizzate su Windows sono inevitabilmente influenzate dalla granularità del clock di sistema.
Sleep(1)non significa “svegliati esattamente 1 ms dopo”.- E anche dopo che il timeout scade, il thread diventa semplicemente pronto per primo — l’esecuzione immediata non è garantita.
- Ecco perché una progettazione che “in realtà sta aspettando un evento ma va a controllare con un timer” perde sia su latenza che su consumo energetico.
- È più pulito riservare i timer ai casi in cui il tempo stesso è genuinamente la condizione.
In termini pratici, si riduce più o meno a questo:
- “Invia metriche ogni 5 secondi” -> compito per un timer
- “Esegui appena il lavoro arriva in coda” -> compito per un evento / semaforo / variabile condizione /
WaitOnAddress - “Continua una volta finito l’I/O” -> compito per un completion / evento
- “Arresta quando arriva una richiesta di arresto” -> compito per un evento di arresto / cancellazione
2. Qual è il problema
2.1 Le attese temporizzate sono vincolate dalla granularità del clock di sistema
La precisione del timeout delle funzioni di attesa di Windows dipende dalla risoluzione del clock di sistema.
Lo stesso vale per Sleep: i millisecondi specificati non sono garantiti come “durata esatta”.
Il punto chiave qui è che specificare 1 ms non significa che ti sveglierai 1 ms dopo.
2.2 Anche quando scade la scadenza, l’esecuzione non è necessariamente immediata
Ciò che rende le cose ancora più complicate è che il thread non inizia a girare nel momento in cui il timeout scade.
Come nota la documentazione di Sleep, una volta terminato l’intervallo di attesa il thread diventa pronto, ma non c’è garanzia che ottenga la CPU e gira subito.
È influenzato da altri thread, priorità, stati di idle della CPU, DPC / ISR, contesa di lock e così via.
In altre parole, un’attesa su timer breve ha almeno due livelli di incertezza:
- La determinazione del timeout stessa è trascinata dalla granularità del timer
- Anche dopo il timeout, quando inizia l’esecuzione dipende dallo scheduler
2.3 Sleep(1) non significa un periodo di 1 ms
Quando vedi Sleep(1), è facile leggerlo come “un ciclo che gira ogni 1 ms”.
Ma in realtà non bisogna leggerlo così.
while (!g_stop)
{
Step();
Sleep(1);
}
Ciò che questo ciclo fa in realtà è questo:
- Il tempo di esecuzione di
Step()viene sommato a ogni iterazione - Il tempo di attesa di
Sleep(1)stesso è trascinato dalla granularità - Anche dopo il risveglio, non è garantito che il thread giri immediatamente
3. Perché vincono le attese su evento
3.1 L’attesa si completa su un “segnale”, non sullo “scadere del tempo”
Le attese su evento sono vantaggiose perché cambiano il significato dell’attesa.
Un’attesa su timer funziona così:
- Anche se non è successo nulla
- Ti svegli quando è trascorso un tempo fisso
- Dopo il risveglio, controlli se è successo qualcosa
Un’attesa su evento funziona così:
- Il lato in cui è successo qualcosa segnala
- Quando viene segnalato, l’attesa è soddisfatta
- Nel momento in cui ti svegli, c’è già una ragione
flowchart LR
start["thread in attesa"] --> q{"cosa stai aspettando davvero?"}
q -- "un istante di tempo" --> timer["timer / waitable timer"]
q -- "lavoro che arriva" --> event["evento / semaforo / variabile condizione"]
q -- "un valore che cambia" --> addr["WaitOnAddress"]
q -- "completamento I/O" --> io["completion / evento"]
q -- "una richiesta di arresto" --> stop["evento di arresto / cancellazione"]
3.2 Scegli lo strumento in base a ciò che stai aspettando
Come prima approssimazione, questa tabella copre la maggior parte delle decisioni.
| Cosa vuoi aspettare | Esempio sbagliato | Prima scelta |
|---|---|---|
| Lavoro che arriva in coda | TryPop con Sleep(1) |
evento / semaforo |
| I/O che si completa | Polling dello stato con un timer | overlapped I/O event / IOCP |
| Arrivo di una richiesta di arresto | Controllare un flag di stop ogni 100 ms | evento di arresto / cancellazione |
| Cambiamento di un valore nello stesso processo | while (flag == 0) Sleep(1) |
WaitOnAddress |
| Arrivo di un istante di tempo | Forzarlo su un evento | timer / waitable timer |
3.3 Neppure gli eventi sono magici
Le attese su evento sono vantaggiose nel senso che non hanno bisogno di svegliarsi sulla granularità del timer, ma ciò non significa che il thread giri con assolutamente zero ritardo nell’istante in cui viene segnalato.
Anche un’attesa su evento è comunque influenzata da:
- latenza dello scheduler
- priorità del thread
- stati di risparmio energetico della CPU
- contesa di lock
- page fault
- DPC / ISR
Ma almeno elimini il tipo di attesa non necessaria in cui il thread “dorme fino al prossimo tick del timer”.
4. Tipici anti-pattern
4.1 Polling di una coda con Sleep(1)
Questo è quello che si vede più spesso.
for (;;)
{
if (g_stop)
{
break;
}
WorkItem item;
if (TryPop(item))
{
Process(item);
continue;
}
Sleep(1);
}
Questo stile sembra semplice a prima vista, ma ha tre problemi:
- Il thread si sveglia periodicamente anche quando la coda è vuota
- La latenza è trascinata dalla granularità del timer
- Perde anche sul fronte energetico
4.2 Osservare lo stato con Thread.Sleep(1) / Task.Delay(1)
Lo stesso odore si presenta anche in C# / .NET.
while (!stoppingToken.IsCancellationRequested)
{
if (_queue.TryDequeue(out WorkItem? item))
{
await ProcessAsync(item, stoppingToken);
continue;
}
await Task.Delay(1, stoppingToken);
}
Può sembrare gentile e asincrono in superficie, ma l’essenza della progettazione è ancora polling.
5. Come risolverlo
5.1 Il producer segnala all’arrivo
Se stai aspettando arrivi in coda, cambia la progettazione in modo che sia il producer a segnalare invece di fare polling.
- Il producer inserisce un elemento nella coda
- Subito dopo l’inserimento chiama
SetEvent - Il consumer attende con
WaitForSingleObjectoWaitForMultipleObjects - Quando si sveglia, svuota la coda
5.2 Attendere insieme lavoro e arresto con WaitForMultipleObjects
Per un worker semplice, questa forma è facile da seguire.
HANDLE waits[2] = { _stopEvent, _workEvent };
for (;;)
{
DWORD rc = WaitForMultipleObjects(2, waits, FALSE, INFINITE);
if (rc == WAIT_OBJECT_0)
{
return;
}
if (rc != WAIT_OBJECT_0 + 1)
{
throw std::runtime_error("WaitForMultipleObjects failed.");
}
DrainQueue();
}
I punti chiave di questo esempio sono tre:
Sleep(1)è sparito- Il producer chiama
SetEventquando arriva un elemento - Il worker attende contemporaneamente su
stopework
5.3 Nello stesso processo, WaitOnAddress è un’alternativa valida
Se tutto ciò che vuoi è “aspettare finché un valore cambia” nello stesso processo, WaitOnAddress è un’opzione forte.
Come guida approssimativa per la scelta:
- Tra processi o target di attesa generici -> evento / semaforo / oggetto in attesa
- Cambiamenti leggeri di valore nello stesso processo ->
WaitOnAddress
6. Quando usare comunque un timer
6.1 Quando il tempo stesso è la condizione
Naturalmente, ci sono usi legittimi per i timer.
- Inviare metriche ogni 5 secondi
- Ritentare dopo 200 ms
- Svuotare una cache ogni minuto
- Attendere fino a una scadenza e trattarla come timeout
In questi casi, ciò che vuoi aspettare è davvero il tempo.
6.2 Usare un waitable timer
Se stai aspettando “il tempo stesso” su Windows, usare un waitable timer rende l’intento più chiaro che non accumulare chiamate Sleep.
6.3 Non fare di timeBeginPeriod un’abitudine
Quando la precisione delle attese temporizzate brevi inizia a dare fastidio, è tentante inserire timeBeginPeriod(1).
Ma questa non dovrebbe essere la scelta predefinita.
Ci sono tre motivi:
- Ha un costo in termini di energia / prestazioni
- Sulle versioni recenti di Windows, il comportamento è un po’ più complicato
- Averne bisogno spesso significa che non hai risolto la causa principale
7. Checklist di revisione
- Stai costruendo un ciclo di ricontrollo con
Sleep(1)/Thread.Sleep(1)/Task.Delay(1)? - Stai facendo polling con un timer quando in realtà stai aspettando l’arrivo in coda, il completamento di un I/O o una richiesta di arresto?
- La progettazione permette al lato producer / completion di segnalare?
- È possibile attendere insieme
stopeworkin un’unica attesa? - Per cambiamenti di valore nello stesso processo, si può scrivere con
WaitOnAddress? - Dove viene usato un timer, ciò che si vuole davvero aspettare è “il tempo”?
8. Riassunto
Su Windows, una progettazione che usa attese temporizzate brevi per “controllare a intervalli fissi” è inevitabilmente influenzata dalla granularità del timer e dallo scheduler.
Di conseguenza, Sleep(1) e timeout brevi non sono un’attesa così precisa come sembrano.
D’altra parte, se ciò che vuoi veramente aspettare è un “evento” — lavoro che arriva, I/O che si completa, una richiesta di arresto, un cambiamento di stato — un’attesa su evento è la scelta più naturale.
Tutto si riduce a questa riga:
Attendi su un timer per il tempo; attendi su un evento per gli eventi.
Avere confine chiaro porta diversi vantaggi:
- La latenza diventa più facile da ragionare
- I risvegli periodici non necessari diminuiscono
- Il consumo energetico migliora
- L’intento del codice diventa più facile da leggere
9. Riferimenti
- Sleep function (Win32)
- Wait Functions
- WaitForSingleObject function
- Event Objects (Synchronization)
- Using Event Objects
- WaitOnAddress function
- WakeByAddressSingle function
- timeBeginPeriod function
- CreateWaitableTimerExW function
- SetWaitableTimer function
- Thread.Sleep Method (.NET)
- Results for the Idle Energy Efficiency Assessment
Articoli correlati
Articoli recenti con gli stessi tag per approfondire argomenti vicini.
Quando non conviene portare un'app Windows sul Web: tabella decisionale e la soluzione realistica della "divisione"
Le richieste di trasformare in Web le applicazioni Windows aziendali sono in aumento, ma per le app che gestiscono integrazione con dispo...
Una lista di controllo minima sulla sicurezza per lo sviluppo di app Windows
Una guida in stile elenco di controllo alle nozioni di base sulla sicurezza per le app aziendali WPF / WinForms / WinUI / C++ / C#: privi...
Scelta tra i tre timer di .NET - PeriodicTimer / Timer / DispatcherTimer
Quale timer .NET dovresti usare? PeriodicTimer per loop asincroni, timer per callback ThreadPool, DispatcherTimer per WPF UI, oltre a una...
Perché utilizzare .NET Generic Host e BackgroundService nelle app desktop
Come utilizzare Generic Host e BackgroundService per organizzare l'avvio, l'elaborazione periodica, l'arresto, la registrazione, la confi...
Una guida pratica a FileSystemWatcher: gestire gli eventi persi e duplicati
Organizziamo come utilizzare FileSystemWatcher e le sue insidie: eventi persi, notifiche duplicate, trappole di rilevamento del completam...
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.
Thread UI e timer
Thread UI WPF / WinForms, flussi asincroni, Dispatcher e progettazione dei timer.
Servizi collegati all’argomento
L’articolo è direttamente collegato ai servizi seguenti.
Consulenza tecnica e revisione del progetto
Questo argomento copre la progettazione delle attese, la scelta delle primitive di sincronizzazione e i compromessi tra latenza e consumo energetico nei sistemi soft real-time, quindi si abbina bene a consulenza tecnica e design review.
Sviluppo di applicazioni Windows
Sostituire il polling basato su timer con una progettazione guidata dagli eventi in applicazioni e servizi Windows influenza direttamente la qualità dell'implementazione, rendendola un tema centrale dello sviluppo di applicazioni Windows.
Domande frequenti
Domande che ricorrono nelle consulenze sull’argomento dell’articolo.
- Perché Sleep(1) non dorme esattamente per 1 millisecondo su Windows?
- Le attese temporizzate su Windows sono vincolate dalla granularità del clock di sistema, e con impostazioni tipiche la risoluzione del timer della piattaforma è dell'ordine di 15,6 ms. Quindi anche specificando 1 ms, l'attesa effettiva è spesso molto più grossolana. Inoltre, quando il timeout scade il thread diventa semplicemente pronto per l'esecuzione; non è garantito che ottenga subito la CPU, perché lo scheduling è influenzato da altri thread, priorità, stati di idle della CPU e DPC/ISR.
- Quando dovrei usare un'attesa su evento invece di un timer su Windows?
- Usa un'attesa su evento ogni volta che ciò che stai aspettando è un evento piuttosto che un istante di tempo: lavoro che arriva in coda, I/O che si completa, richiesta di arresto che arriva o valore che cambia. Il lato in cui si verifica l'evento segnala, e il lato in attesa si sveglia con una ragione già presente, il che è migliore per latenza, CPU e consumo energetico. Riserva i timer ai casi in cui il tempo stesso è genuinamente la condizione, come inviare metriche ogni 5 secondi o ritentare dopo 200 ms.
- Cosa c'è di sbagliato nel fare polling di una coda con Sleep(1) o Task.Delay(1)?
- Questo pattern ha tre problemi: il thread si sveglia periodicamente anche quando la coda è vuota, la latenza è trascinata dalla granularità del timer, e spreca energia. La soluzione è far segnalare dal producer immediatamente dopo aver inserito in coda, mentre il consumer attende con WaitForSingleObject o WaitForMultipleObjects e svuota la coda al risveglio. In C#, un ciclo async con Task.Delay(1) sembra gentile in superficie, ma in sostanza è comunque polling.
- Dovrei usare timeBeginPeriod(1) per rendere le attese temporizzate brevi più precise?
- Non dovrebbe essere la scelta predefinita, per tre motivi: ha un costo in termini di energia e prestazioni, il suo comportamento sulle versioni recenti di Windows è più complicato, e averne bisogno spesso significa che non hai risolto la causa principale. Se ciò che stai aspettando è in realtà un evento, passa a un'attesa su evento. Se hai genuinamente bisogno di attendere un istante di tempo, un timer in attesa esprime l'intento in modo più chiaro che non accumulare chiamate Sleep.
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.