Why Windows Code Should Prefer Event Waits Over Timer Polling - Avoiding ~15.6 ms Granularity
The short version
Use a timer when you are waiting on time. Use an event when you are waiting on something happening.
Sleep(1)does not mean “wake up exactly 1 ms from now.” It is shaped by the system clock granularity (about 15.6 ms) and scheduling delay- If you are waiting for work to arrive or for I/O to complete, waiting on an event beats polling a timer in latency, CPU, and power
- After a timeout fires, the thread only becomes ready - it is not guaranteed to run immediately
- Use a timer only when time itself is the condition
How to choose in practice
| What you are waiting for | Bad choice | First choice |
|---|---|---|
| Work arriving on a queue | Sleep(1) then TryPop |
event / semaphore |
| I/O completing | poll state on a timer | overlapped I/O event / IOCP |
| A stop request | check a flag every 100 ms | stop event / cancellation |
| A value change inside one process | while (flag==0) Sleep(1) |
WaitOnAddress |
| A point in time arriving | shoehorn it into an event | timer / waitable timer |
What actually goes wrong
Timed waits are bound by the system clock granularity
The accuracy of Sleep and any wait with a timeout depends on the system clock resolution. The millisecond value you pass in is not what you actually get.
A thread does not run immediately when its timeout fires
When the timeout expires, the thread only enters the ready state. Other threads, priorities, the CPU idle state, DPC/ISR activity, and lock contention all decide when it actually runs.
Sleep(1) is not a 1 ms periodic loop
while (!g_stop) {
Step();
Sleep(1);
}
In reality this loop:
- adds the cost of
Step()to every iteration - has its
Sleep(1)pulled toward the clock granularity - has no guarantee of running right after waking
Why event waits are better
The exit condition becomes “signaled,” not “time is up”
- Timer wait: wakes up on a fixed schedule even if nothing happened, then has to check whether anything actually did
- Event wait: the side where something happened signals, and the wait completes when the signal arrives
Pick the tool that matches what you are waiting for
flowchart LR
start["Waiting thread"] --> q{"What are you waiting for?"}
q -- "Time" --> timer["timer / waitable timer"]
q -- "Work arriving" --> event["event / semaphore / condition variable"]
q -- "A value change" --> addr["WaitOnAddress"]
q -- "I/O completion" --> io["completion / event"]
q -- "A stop request" --> stop["stop event / cancellation"]
Typical anti-patterns and how to fix them
Anti-pattern: polling a queue with Sleep(1)
for (;;) {
if (g_stop) break;
WorkItem item;
if (TryPop(item)) { Process(item); continue; }
Sleep(1);
}
Problems:
- wakes up periodically even when the queue is empty
- latency is dragged toward the timer granularity
- poor power efficiency
Better: WaitForMultipleObjects
HANDLE waits[2] = { _stopEvent, _workEvent };
for (;;) {
DWORD rc = WaitForMultipleObjects(2, waits, FALSE, INFINITE);
if (rc == WAIT_OBJECT_0) return; // stop
if (rc == WAIT_OBJECT_0 + 1) DrainQueue();
}
Key points:
Sleep(1)is gone- the producer calls
SetEventwhen an item arrives - the worker waits for
stopandworkin a single call
The same problem shows up in C#/.NET
while (!stoppingToken.IsCancellationRequested) {
if (_queue.TryDequeue(out var item)) { await ProcessAsync(item); continue; }
await Task.Delay(1, stoppingToken); // still polling, just dressed up
}
Inside one process, WaitOnAddress is also worth considering
If all you want is to wait until a value changes inside the same process, WaitOnAddress is lightweight and convenient.
- Cross-process or general-purpose waits -> event / semaphore / waitable object
- Lightweight intra-process value changes ->
WaitOnAddress
When a timer really is the right tool
When time itself is the condition, a timer is correct:
- send metrics every 5 seconds
- retry 200 ms later
- sweep a cache once per minute
- wait until a deadline and then time out
In these cases, a waitable timer expresses intent more clearly than stacking up Sleep calls.
Do not reach for timeBeginPeriod as a default
Bumping precision by adding timeBeginPeriod(1) is best avoided:
- it has a power and performance cost
- its behavior on recent Windows is more nuanced than it used to be
- it usually papers over the real cause instead of fixing it
A review checklist
- Is there a “check again soon” loop built around
Sleep(1)orTask.Delay(1)? - Is a timer poll being used where the real wait is for queue arrival or I/O completion?
- Can the producer side signal instead?
- Can
stopandworkbe combined into a single wait? - For an intra-process value change, can it be written with
WaitOnAddress? - Where a timer is used, is the thing you are actually waiting for really “time”?
Wrapping up
On Windows, a design that polls with short timer waits is not as accurate as it looks - timer granularity and the scheduler both get in the way.
Use a timer when you are waiting on time. Use an event when you are waiting on something happening. Drawing that line clearly makes latency easier to reason about, removes pointless periodic wakeups, improves power efficiency, and makes the code’s intent obvious.
References
- 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
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Where to `catch`, log, and handle exceptions — sorting out call-hierarchy boundaries and responsibilities for real-world code
A practical breakdown of where in the call hierarchy you should catch exceptions, where the primary log belongs, and where to decide betw...
Checklist for Unexpected Exceptions - A Quick Decision Table for Whether to Exit or Keep Running
A practical guide for deciding whether an app should exit or keep running when an unexpected exception occurs. Three options and a decisi...
A Minimum Security Checklist for Windows Application Development
A minimum security checklist for Windows app development (WPF, WinForms, WinUI, C++, C#) covering privileges, signing, secrets, communica...
Pitfalls in COM, OCX, and ActiveX Development - Visual Studio Bitness, Registration, and Admin-Rights Traps
The traps that bite COM, OCX, and ActiveX work in practice: 32-bit/64-bit mismatches, regsvr32 vs Regasm, HKCU vs HKLM scope, and admin-r...
What ClickOnce Actually Is: How It Works, How Updates Flow, and Where It Fits in Practice
A practical look at ClickOnce — how the manifests, auto-updates, per-version cache, and signing fit together, why it shines for internal ...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
We support Windows desktop applications that involve resident processing, device integration, operational logging, and maintainable structure.
Technical Consulting & Design Review
We help clarify design direction, architectural boundaries, lifetime ownership, and how to handle legacy Windows assets.