Why You Should Prefer Event Waits over Sleep(1) on Windows
· Updated: · Go Komura · Windows Development, Synchronization, Events, Timer, Design
Revision history (1 updates, last updated Sep 1, 2026)
A log of the changes made to this article. Where a pre-update version was archived, it stays readable at a permanent DOI link.
- Retranslated as a full translation of the Japanese original. The previous English version was an abridgement that carried only part of the source, so sections, tables, Mermaid diagrams, figure captions and FAQ entries were missing. All of them have been restored to match the Japanese original, and the technical claims are the same as in the Japanese version. Read the version before this update (DOI: 10.5281/zenodo.21614517)
- First published
Cite this article(DOI: 10.5281/zenodo.21614516)
This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.
Go Komura (2026). Why You Should Prefer Event Waits over Sleep(1) on Windows. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21614516 https://comcomponent.com/en/blog/2026/03/16/006-windows-timer-vs-event-wait/
- DOI (latest version)
- 10.5281/zenodo.21614516
- DOI (this version)
- 10.5281/zenodo.22217154
In our previous post, A Practical Guide to Soft Real-Time on Windows, we discussed avoiding periodic loops that lean on Sleep.
This time we narrow in on a single point from that discussion: why you should prefer an event wait over a short timer wait.
This article stands on its own. The conclusion of the previous post fits in one line: a check-back loop that leaves the period up to Sleep guarantees neither the wait time nor the wake-up timing, so do not build periodic processing on top of it. With just that much in mind, you can follow everything below without reading the previous post.
On Windows, a design that “checks back at fixed intervals” using Sleep(1) or waits with short timeouts is inevitably affected by the granularity of the system clock and the scheduling delay that follows.
Under typical settings, a platform timer resolution on the order of 15.6ms is the usual baseline, so even if you intend to “look again in 1ms,” the wait you actually get is often quite coarse.
On the other hand, if what you really want to wait for is an “occurrence” rather than “time” – work arriving, I/O completing, a stop request, a state change – there is no need to go check at fixed intervals. The side where the event occurs signals, and the waiting side waits on the event. That approach is more natural for latency, CPU, and power alike.
flowchart TB
accTitle: Event-driven waiting
accDescr: Diagram showing that when what you really want to wait for is an occurrence rather than time, it is better for latency, CPU, and power to have the side where it happens signal and the waiting side wait on an event, instead of going to check at fixed intervals.
d1["What you wait for is an occurrence"] --> d2["The side where it happens signals"]
d2 --> d3["The waiting side waits on an event"]
d3 -.-> d4["Natural for latency, CPU, and power"]
Figure 1: If you are waiting for an occurrence, let the side where it happens tell you instead of checking back at fixed intervals.
These are the four questions this article aims to answer.
- Why are
Sleep(1)and short timer waits less accurate than you might expect? - Why are event waits less subject to that limitation?
- In which situations should you choose an event instead of a timer?
- When should you still use a timer?
Terms used in this article
Here are just the abbreviations that show up in the body without further explanation.
| Term | Meaning |
|---|---|
| platform timer resolution / system clock resolution | The interval at which the OS updates the time. The timeout determination of a timed wait is pulled along by this granularity |
| ISR (Interrupt Service Routine) | The code that runs at the highest priority when an interrupt fires. While it is running, your thread is kept waiting |
| DPC (Deferred Procedure Call) | High-priority deferred work that an ISR queues up so it can finish the rest later. For this article, it is enough to read ISR and DPC together as the sources of delay that come with interrupt handling |
| IOCP (I/O Completion Port) | The Windows mechanism that collects asynchronous I/O completion notifications into a queue and hands them to a dedicated pool of threads |
WaitOnAddress |
A synchronization API for waiting until the value at a given memory address changes. Single-process only (covered in 5.3) |
| signal | To satisfy the condition the waiting side is blocked on. For an event, that means calling SetEvent |
1. The Conclusion First
- If you are waiting for work to arrive or I/O to complete, wait on an event, not a timer.
- Timed waits on Windows are inevitably affected by the granularity of the system clock.
Sleep(1)does not mean “wake up exactly 1ms later.”- And even after the timeout elapses, the thread merely becomes ready first – immediate execution is not guaranteed.
- That is why a design that “is really waiting for an occurrence but goes to check with a timer” loses on both latency and power.
- It is cleaner to reserve timers for cases where time itself is genuinely the condition.
Put in practical terms, it comes down to roughly this.
- “Send metrics every 5 seconds” -> a job for a timer
- “Run as soon as work lands in the queue” -> a job for an event / semaphore / condition variable /
WaitOnAddress - “Continue once the I/O finishes” -> a job for a completion / event
- “Stop when a stop request arrives” -> a job for a stop event / cancellation
flowchart TB
accTitle: Drawing the line between timer and event
accDescr: Diagram showing the dividing line where a timer is used only when time itself is genuinely the condition, and an event is waited on for occurrences such as work arriving, I/O completing, or a stop request.
q1{"Are you waiting for time or an occurrence"}
q1 -->|"Time itself"| t1["A job for a timer"]
q1 -->|"An occurrence"| e1["A job for waiting on an event"]
e1 -.-> rei["Work arriving / I/O completion / stop request"]
Figure 2: Reserve timers for cases where time itself is the condition, and wait on events for occurrences.
Knowledge map for this article
A short timer wait on Windows is bound to the granularity of the system clock resolution, and even when the timeout arrives the thread only becomes ready, with the start of execution left to the scheduler, so a polling design that uses Sleep(1) to keep checking a queue or a stop request is less accurate than it looks. Switching to an event-driven design, in which the producer signals with SetEvent and the consumer waits with WaitForSingleObject or WaitForMultipleObjects, makes the wait end on a signal rather than on a timeout and removes the wasted empty passes. Picking the right tool means using an overlapped I/O event or IOCP for I/O completion, WaitOnAddress for a value change inside the same process, and a waitable timer only when the time itself is the condition, and raising precision with timeBeginPeriod is not a fundamental fix.
flowchart LR
accTitle: Timer polling versus event-driven waiting
accDescr: Diagram showing that a short timer wait carries the double uncertainty of system clock resolution and scheduling delay, how event-driven waiting handles work arriving in a queue, I/O completion, stop requests, and value changes inside the same process, and how waitable timer and WaitOnAddress are used for different jobs
timer_polling["Timer Polling (Status Check Loop)"]
event_driven_wait["Event-Driven Wait Design"]
system_clock_resolution["System Clock Resolution"]
scheduler_latency["Scheduler Latency"]
queue_arrival_wait["Queue Arrival Wait"]
windows_event_object["Windows Event Object"]
wait_functions["Windows Wait Functions"]
overlapped_io["Overlapped I/O"]
io_completion_wait["I/O Completion Wait"]
iocp["I/O Completion Port (IOCP)"]
stop_request_wait["Stop-Request Wait"]
waitonaddress["WaitOnAddress API"]
same_process_value_change_wait["In-Process Value Change Wait"]
data_race["Data race"]
waitable_timer["Waitable Timer"]
time_based_wait["Time-Based Wait"]
timebeginperiod["timeBeginPeriod (Timer Resolution Request)"]
getsystemtimeadjustment["GetSystemTimeAdjustment"]
interrupt_processing_delay["Interrupt-Induced Latency (ISR/DPC)"]
timer_polling -->|"requires"| system_clock_resolution
timer_polling -.->|"may cause"| scheduler_latency
event_driven_wait -.->|"may cause"| scheduler_latency
timer_polling -->|"not recommended for"| queue_arrival_wait
windows_event_object -->|"recommended for"| queue_arrival_wait
wait_functions -->|"uses"| windows_event_object
overlapped_io -->|"recommended for"| io_completion_wait
iocp -->|"recommended for"| io_completion_wait
timer_polling -->|"not recommended for"| io_completion_wait
windows_event_object -->|"recommended for"| stop_request_wait
timer_polling -->|"not recommended for"| stop_request_wait
wait_functions -->|"recommended for"| stop_request_wait
waitonaddress -->|"recommended for"| same_process_value_change_wait
waitonaddress -.->|"may cause"| data_race
waitable_timer -->|"recommended for"| time_based_wait
timebeginperiod -->|"not recommended for"| queue_arrival_wait
system_clock_resolution -->|"verified by"| getsystemtimeadjustment
interrupt_processing_delay -.->|"may cause"| scheduler_latency
iocp -->|"uses"| overlapped_io
wait_functions -->|"uses"| waitable_timer
timer_polling -->|"not recommended for"| same_process_value_change_wait
In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (21 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle
2. What the Problem Is
2.1 Timed waits are bound by the system clock granularity
The timeout accuracy of the Windows wait functions depends on the system clock resolution.
The same goes for Sleep: the milliseconds you specify are not guaranteed to be honored as “exactly that duration.”
The key point here is that specifying 1ms does not mean you will wake up 1ms later.
Checking the granularity of your own environment
“On the order of 15.6ms” is a general statement, so it is faster to look at the value on your own machine. There are two ways to check.
One is to call GetSystemTimeAdjustment. The second argument, lpTimeIncrement, returns the interval at which the system updates the time-of-day clock, in 100-nanosecond units. On a machine in the 15.6ms class, you get a value in the 150,000s.
#include <windows.h>
#include <cstdio>
int main()
{
DWORD adjustment = 0;
DWORD increment = 0;
BOOL adjustmentDisabled = FALSE;
if (!GetSystemTimeAdjustment(&adjustment, &increment, &adjustmentDisabled))
{
std::printf("GetSystemTimeAdjustment failed. GetLastError=%lu\n", GetLastError());
return 1;
}
// increment is in 100ns units, so convert it to ms before looking at it
std::printf("time increment = %lu (100ns) = %.4f ms\n",
increment,
increment / 10000.0);
return 0;
}
The other is to run ClockRes from Sysinternals. It is a small tool that calls the same GetSystemTimeAdjustment internally and prints the resolution of the system clock, that is, the maximum timer resolution an application can get. When you want to check without writing code, this is the faster route.
Note that the documentation describes lpTimeIncrement as a fixed value the system determines at boot that does not change while the system is running. In other words, it tells you “the raw granularity of this environment”; it is not a way to measure the effect of calling timeBeginPeriod. We come back to that in 6.3.
flowchart TB
accTitle: Two ways to check the raw granularity
accDescr: Diagram showing that you can find the timer granularity of your own environment either by calling GetSystemTimeAdjustment and looking at the update interval in 100-nanosecond units, or by running Sysinternals ClockRes, which calls the same API internally.
g1["You want to know the granularity on your machine"] --> g2["Call GetSystemTimeAdjustment"]
g1 --> g3["Run ClockRes"]
g2 --> g4["It returns the update interval in 100ns units"]
g3 -.-> g5["It calls the same API internally"]
Figure 3: Either call the API from code or run ClockRes to find the raw granularity of your environment.
2.2 Even when the deadline arrives, execution is not necessarily immediate
What makes it even trickier is that the thread does not start running the moment the timeout elapses.
As the documentation for Sleep notes, once the wait interval ends the thread becomes ready, but there is no guarantee it gets the CPU and runs right away.
It is affected by other threads, priority, CPU idle states, DPCs / ISRs, lock contention, and so on.
In other words, a short timer wait has at least two layers of uncertainty.
- The timeout determination itself is pulled along by the timer granularity
- Even after the timeout, when execution starts is up to the scheduler
flowchart TB
accTitle: The two layers of uncertainty in a short timer wait
accDescr: Diagram showing that a short timer wait has two layers of uncertainty, in that the timeout determination itself is pulled along by the timer granularity and, after the timeout, the thread only becomes ready so the start of execution is up to the scheduler.
s1["Start a short timer wait"] --> s2["Timeout determination depends on timer granularity"]
s2 --> s3["The thread only becomes ready"]
s3 --> s4["The start of execution is up to the scheduler"]
s3 -.-> s5["Influence of other threads and DPC / ISR"]
Figure 4: The wait drifts away from the duration you asked for in two places: the timeout determination and the start of execution.
2.3 Sleep(1) does not mean a 1ms period
When you see Sleep(1), it is easy to read it as “a loop that spins every 1ms.”
But in reality, you must not read it that way.
while (!g_stop)
{
Step();
Sleep(1);
}
What this loop actually does is this.
- The execution time of
Step()is added every iteration - The wait time of
Sleep(1)itself is pulled along by the granularity - Even after waking, the thread is not guaranteed to run immediately
flowchart TB
accTitle: What a Sleep(1) loop really does
accDescr: Diagram showing that a Sleep(1) loop does not amount to a 1 millisecond period because the execution time of Step is added every iteration, the wait itself is pulled along by the granularity, and the thread is not guaranteed to run immediately after waking.
p1["Step execution time is added"] --> p4["It does not become a 1ms period"]
p2["The wait is pulled along by the granularity"] --> p4
p3["Running right after waking is not guaranteed"] --> p4
Figure 5: Three sources of drift stack up, so Sleep(1) does not give you a period of one millisecond.
3. Why Event Waits Win
3.1 The wait ends on a “signal,” not on “time running out”
Event waits are advantageous because they change the meaning of the wait.
A timer wait works like this.
- Even if nothing has happened yet
- You wake up when a fixed amount of time has passed
- After waking, you check whether anything happened
An event wait works like this.
- The side where something happened signals
- When signaled, the wait is satisfied
- At the moment you wake, there is already a reason
Drawn as a diagram, you can see that the way the wait ends is fundamentally different.
flowchart TB
subgraph TimerWait["timer wait: you wake because the time came"]
T1["Wait"] --> T2["Wake on timer granularity"]
T2 --> T3{"Did anything happen?"}
T3 -- "No" --> T1
T3 -- "Yes" --> T4["Process it"]
end
subgraph EventWait["event wait: you are woken because something happened"]
E1["Wait"] --> E2["The side where it happened signals"]
E2 --> E3["The reason is settled at the moment of waking"]
E3 --> E4["Process it"]
end
Figure 6: Only the timer wait has a loop that comes back empty-handed; with an event wait, the reason is already settled at the moment of waking.
Only the timer wait side has a loop that comes back with nothing and goes around again. That is where both latency and power take the hit.
3.2 Pick the tool based on what you are waiting for
So which tool do you actually choose? For a first cut, this table is usually enough.
| What you want to wait for | Bad example | First choice |
|---|---|---|
| Work landing in a queue | TryPop with Sleep(1) |
event / semaphore |
| I/O completing | Polling the status with a timer | overlapped I/O event / IOCP |
| A stop request arriving | Checking a stop flag every 100ms | stop event / cancellation |
| A value changing within the same process | while (flag == 0) Sleep(1) |
WaitOnAddress |
| A point in time arriving | Forcing it onto an event | timer / waitable timer |
3.3 Events are not magic either
Event waits are advantageous in the sense that they do not need to wake on timer granularity, but that does not mean the thread runs with absolutely zero delay the instant it is signaled.
Even an event wait is still affected by:
- scheduler latency
- thread priority
- CPU power states
- lock contention
- page faults
- DPCs / ISRs
But at the very least, you get rid of the unnecessary kind of waiting where the thread is “asleep until the next timer tick.”
flowchart TB
accTitle: What an event wait removes and what remains
accDescr: Diagram showing that an event wait is still affected by scheduler latency, thread priority, and DPC / ISR, but that it does remove the unnecessary form of waiting where the thread sleeps until the next timer tick.
e1["Wait with an event wait"] --> e2["Scheduler and similar effects remain"]
e1 --> e3["Sleeping until the timer tick is removed"]
e2 -.-> e4["A signal does not mean zero delay"]
Figure 7: Events are not magic, but they do reliably remove the need to wake on timer granularity.
4. Typical Anti-Patterns
4.1 Polling a queue with Sleep(1)
This is the one you see most often.
for (;;)
{
if (g_stop)
{
break;
}
WorkItem item;
if (TryPop(item))
{
Process(item);
continue;
}
Sleep(1);
}
This style looks simple at first glance, but it has three problems.
- The thread wakes up periodically even when the queue is empty
- Latency is pulled along by the timer granularity
- It also loses on power
flowchart TB
accTitle: The three problems with Sleep(1) polling
accDescr: Diagram showing the three problems with polling a queue using Sleep(1): the thread wakes up periodically even when the queue is empty, latency is pulled along by the timer granularity, and it wastes power.
a1["Check the queue with Sleep(1)"] --> b1["Wakes periodically even when empty"]
a1 --> b2["Latency is pulled along by the granularity"]
a1 --> b3["Loses on power too"]
Figure 8: A polling loop that looks simple costs you on three fronts: wakeups, latency, and power.
4.2 Watching state with Thread.Sleep(1) / Task.Delay(1)
The same smell shows up in C# / .NET as well.
while (!stoppingToken.IsCancellationRequested)
{
if (_queue.TryDequeue(out WorkItem? item))
{
await ProcessAsync(item, stoppingToken);
continue;
}
await Task.Delay(1, stoppingToken);
}
It may look gentle and async on the surface, but the essence of the design is still polling.
5. How to Fix It
5.1 The producer signals on arrival
If you are waiting for queue arrivals, change the design so that the producer signals instead of polling.
- The producer puts an item into the queue
- Immediately after enqueueing, it calls
SetEvent - The consumer waits with
WaitForSingleObjectorWaitForMultipleObjects - When it wakes, it drains the queue
sequenceDiagram
accTitle: The shape where the producer signals
accDescr: Diagram showing the flow in which the producer calls SetEvent immediately after putting an item into the queue, the consumer waits with WaitForSingleObject or similar, and drains the queue once it wakes.
participant P as producer
participant Q as queue
participant C as consumer
P->>Q: Put an item in
P->>C: SetEvent immediately after
C->>C: Wake from the wait
C->>Q: Drain the queue
Figure 9: The producer knows about the arrival and reports it, so the consumer stops waking up with nothing to do.
5.2 Waiting on work and stop together with WaitForMultipleObjects
For a simple worker, this shape is easy to follow.
HANDLE waits[2] = { _stopEvent, _workEvent }; // index 0 = stop, index 1 = work
for (;;)
{
// bWaitAll = FALSE, so the return value is the index of the handle signaled first
DWORD rc = WaitForMultipleObjects(2, waits, FALSE, INFINITE);
// Failure comes back as WAIT_FAILED ((DWORD)0xFFFFFFFF). Only GetLastError tells you why
if (rc == WAIT_FAILED)
{
throw std::system_error(
static_cast<int>(GetLastError()),
std::system_category(),
"WaitForMultipleObjects failed.");
}
if (rc == WAIT_OBJECT_0) // stop
{
return;
}
if (rc == WAIT_OBJECT_0 + 1) // work
{
DrainQueue();
continue;
}
// Reaching here is unexpected for an INFINITE wait
// (WAIT_TIMEOUT or a WAIT_ABANDONED_0 value). Do not swallow it; fail loudly
throw std::runtime_error("WaitForMultipleObjects returned an unexpected value.");
}
There are three key points in this example.
Sleep(1)is gone- The producer calls
SetEventwhen an item arrives - The worker waits on
stopandworkat the same time
One more note on handling the return value, since this is easy to get wrong in practice.
- When
bWaitAllisFALSE, the success return value is in the rangeWAIT_OBJECT_0throughWAIT_OBJECT_0 + nCount - 1, and subtractingWAIT_OBJECT_0from it gives you the index into the array. If you write only the two branches== WAIT_OBJECT_0and!= WAIT_OBJECT_0 + 1, the code breaks the moment you grow the handle array to three - When several objects are signaled at the same time, the lower index wins. That is why
stopsits at index 0 in the example above: so a stop request is never dropped - Failure comes back not as an exception but as the return value
WAIT_FAILED((DWORD)0xFFFFFFFF). You cannot learn the cause without callingGetLastError. If you lump everything into “rcis not the expected value, so it failed,” you lose causes such as a closed handle or a missingSYNCHRONIZEright - If you mix a mutex into the wait set, the
WAIT_ABANDONED_0values become possible as well. This example waits only on events, so it treats them as unexpected
5.3 Within a single process, WaitOnAddress is also a candidate
If all you want within the same process is to “wait until some value changes,” WaitOnAddress is a strong option.
It removes the chore of creating and initializing an event and keeping the value and the synchronization object from drifting apart.
As a rough sense of when to use which, it comes out about like this.
| Aspect | event / semaphore / waitable object | WaitOnAddress |
|---|---|---|
| Scope of the wait target | Works across processes. Can be named | Same process only |
| How to wake it | SetEvent / ReleaseSemaphore and so on |
WakeByAddressSingle / WakeByAddressAll |
| Setup required | You need to create a kernel object and manage its handle | All you need is the variable you wait on |
| Available since | Available for a long time | Windows 8 / Windows Server 2012 and later |
| Link library | Kernel32.lib |
Synchronization.lib |
There are three points you do not want to miss when using it.
- Always use it paired with
WakeByAddressSingleorWakeByAddressAll. If the side that changed the value does not call one of these, the waiting thread never wakes. Use Single to wake one thread, All to wake them all WaitOnAddresscan return even when it has not been signaled. The documentation explicitly says it may wake early, for example under low-memory conditions. Once it returns, always read the value again and confirm that it really changed, which means writing it as a while loop- The sizes you can wait on are 1 / 2 / 4 / 8 bytes
- Make the flag atomic.
WakeByAddressSingleonly wakes the waiting thread; it makes the preceding write neither atomic nor visible. Reading and writing a plain variable from both sides is a data race in C++ (undefined behavior), and in an optimized build the thread can stay stuck because it never sees the update. Line up release on the writing side and acquire on the reading side
// The waiting side and the waking side are different threads, so the flag must be atomic.
// Reading and writing a plain ULONG from both sides is a data race in C++ (undefined behavior),
// and in an optimized build the value can stay in a register so the update is never seen,
// leaving the thread stuck even after it is woken
std::atomic<ULONG> g_ready{ 0 };
static_assert(std::atomic<ULONG>::is_always_lock_free,
"it is passed to WaitOnAddress, so it has to be lock-free");
// Minimal form of "wait until g_ready is no longer 0"
ULONG undesired = 0;
ULONG captured = g_ready.load(std::memory_order_acquire);
while (captured == undesired)
{
// It can return early, so always re-read the value once it returns
WaitOnAddress(&g_ready, &undesired, sizeof(ULONG), INFINITE);
captured = g_ready.load(std::memory_order_acquire);
}
The side that changes the value updates it and then wakes the waiter.
// Store with release. Written this way, the data prepared before this line (g_payload below)
// is guaranteed to be visible to anyone who read the flag with acquire. It is this store that
// establishes the ordering, not WakeByAddressSingle -- that one only wakes the waiting thread
// and makes the preceding write neither atomic nor visible
g_payload = ...; // data you want to hand over along with it
g_ready.store(1, std::memory_order_release);
WakeByAddressSingle(&g_ready);
sequenceDiagram
accTitle: The paired flow of WaitOnAddress
accDescr: Diagram showing the paired flow in which the writing side prepares the data, writes the flag with release, and wakes the waiter with WakeByAddressSingle, while the waiting side waits in a while loop that re-reads the value with acquire to cope with early returns.
participant W as the writing side
participant F as the atomic flag
participant S as the waiting side
S->>F: Read with acquire
S->>S: WaitOnAddress until it changes
W->>F: Write with release
W->>S: WakeByAddressSingle
S->>F: Re-read once woken
Figure 10: WakeByAddress does the waking; release and acquire are what guarantee the visibility of the value.
6. When You Still Use a Timer
6.1 When time itself is the condition
Of course, there are legitimate uses for timers.
- Sending metrics every 5 seconds
- Retrying after 200ms
- Sweeping a cache every minute
- Waiting until a deadline and treating it as a timeout
In these cases, what you want to wait for really is time.
6.2 Use a waitable timer
If you are waiting for “time itself” on Windows, using a waitable timer makes the intent clearer than carelessly stacking up Sleep calls.
6.3 Do not make timeBeginPeriod a habit
When the accuracy of short timer waits starts to bother you, it is tempting to throw in timeBeginPeriod(1).
But this should not be your default first choice.
There are three reasons.
- It has a power / performance cost
- On recent versions of Windows, the behavior is a bit more complicated
- It often means you have not fixed the root cause
flowchart TB
accTitle: Why not to make timeBeginPeriod a habit
accDescr: Diagram showing that even when the accuracy of short timer waits bothers you, timeBeginPeriod should not be your default first choice because it has a power and performance cost, its behavior on recent versions of Windows is complicated, and it often leaves the root cause unfixed.
t1["The accuracy bothers you"] --> t2["You are tempted to add timeBeginPeriod"]
t2 --> t3["Do not make it your default first choice"]
t3 -.-> r1["It has a cost"]
t3 -.-> r2["The behavior is a bit complicated"]
t3 -.-> r3["The root cause is left unfixed"]
Figure 11: Before you raise the resolution, recall the three reasons not to make it a habit.
7. Review Checklist
- Are you building a check-back loop with
Sleep(1)/Thread.Sleep(1)/Task.Delay(1)? - Are you timer-polling when what you are really waiting for is a queue arrival, I/O completion, or a stop request?
- Is the design such that the producer / completion side can signal?
- Can
stopandworkbe waited on together in a single wait? - For a value change within the same process, could it be written with
WaitOnAddress? - Wherever a timer is used, is what you actually want to wait for really “time”?
8. Summary
On Windows, a design that uses short timer waits to “check back at fixed intervals” is inevitably affected by timer granularity and the scheduler.
As a result, Sleep(1) and short timeouts are not as precise a wait as they appear.
On the other hand, if what you really want to wait for is an “occurrence” – work arriving, I/O completing, a stop request, a state change – an event wait is the more natural fit.
It all boils down to this one line.
Wait on a timer for time; wait on an event for occurrences.
Just having this boundary clearly drawn pays off:
- Latency becomes easier to reason about
- Unnecessary periodic wakeups go down
- Power consumption improves
- The intent of the code becomes easier to read
Those are the ways it shows up.
flowchart TB
accTitle: Timer for time, event for occurrences
accDescr: Diagram showing that once the line between waiting on a timer for time and waiting on an event for occurrences is clear, latency becomes easier to reason about, unnecessary periodic wakeups go down, power consumption improves, and the intent of the code becomes easier to read.
m1{"Which one are you waiting for"}
m1 -->|"Time"| m2["Wait with a timer"]
m1 -->|"An occurrence"| m3["Wait with an event"]
m2 --> m4["The boundary becomes clear"]
m3 --> m4
m4 -.-> m5["Latency becomes easier to reason about"]
m4 -.-> m6["Wasted wakeups go down"]
Figure 12: Sticking to that one-line boundary changes latency, power, and how readable the intent of the code is.
9. References
- Sleep function (Win32)
- Wait Functions
- WaitForSingleObject function
- WaitForMultipleObjects function
- Event Objects (Synchronization)
- Using Event Objects
- WaitOnAddress function
- WakeByAddressSingle function
- WakeByAddressAll function
- GetSystemTimeAdjustment function
- ClockRes - Sysinternals
- 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.
Code Design for Business Systems — Deciding Product and Customer Codes, and Check Digits
A practical guide to deciding the code scheme for a business system, including product and customer codes. Covers a decision table for me...
An Introduction to ADRs (Architecture Decision Records) — The Minimal Way to Record 'Why We Designed It This Way' on a Small Team
Code never explains why it was written that way. We cover how to use an ADR (Architecture Decision Record) — one decision, one Markdown f...
Sleep, Hibernation, Modern Standby, and Long-Running Apps — Designing Around 'It Stopped Overnight'
Why a long-running Windows app can end up 'stopped by the time you check it in the morning,' worked through from the differences between ...
When Not to Move a Windows App to the Web: A Decision Table and the Practical Answer of Splitting
Requests to move in-house Windows apps to the web are increasing, but for apps built around device integration, local file processing, of...
A Decision Table for Whether to Exit or Continue After an Unexpected Exception
When an unexpected exception occurs, should the app exit or keep running? We organize the decision from the perspectives of state corrupt...
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.
UI Threading & Timers
Topic page for WPF / WinForms UI threading, async flow, Dispatcher usage, and timer decisions.
Where This Topic Connects
This article connects naturally to the following service pages.
Technical Consulting & Design Review
This topic covers wait design, choosing synchronization primitives, and the latency/power trade-offs in soft real-time systems, so it pairs well with technical consulting and design reviews.
Windows App Development
Replacing timer polling with event-driven design in Windows applications and services directly affects implementation quality, making it a core Windows app development theme.
Frequently Asked Questions
Common questions about the topic of this article.
- Why doesn't Sleep(1) on Windows wake up exactly 1 millisecond later?
- Because the timeout accuracy of a timed wait on Windows depends on the system clock resolution, and under typical settings a platform timer resolution on the order of 15.6ms is usually the baseline. On top of that, once the wait interval ends the thread merely becomes ready; there is no guarantee it gets the CPU and runs right away. It is affected by other threads, priority, CPU idle states, DPCs/ISRs, lock contention, and more. In other words, a short timer wait carries at least two layers of uncertainty: the timeout determination is pulled along by the timer granularity, and when execution actually starts after the timeout is up to the scheduler.
- What is wrong with polling a queue using Sleep(1) or Task.Delay(1)?
- There are three problems: the thread wakes up periodically even when the queue is empty, latency is pulled along by the timer granularity, and it wastes power. An await Task.Delay(1) loop in C# looks gentle on the surface, but the essence of the design is still polling. The fix is to have the producer call SetEvent immediately after putting an item into the queue, and to have the consumer wait with WaitForSingleObject or WaitForMultipleObjects. Waiting on the stop event and the work event in a single wait also lets you react to a stop request immediately.
- How should I decide between a timer wait and an event wait?
- The dividing line is simple: wait on a timer for time, wait on an event for occurrences. Work whose condition is time itself, such as sending metrics every 5 seconds, is a job for a waitable timer. Work arriving in a queue suits an event or a semaphore, I/O completion suits an overlapped I/O event or IOCP, a stop request suits a stop event or cancellation, and a value changing within the same process suits WaitOnAddress. Once this line is clear, latency becomes easier to reason about, unnecessary periodic wakeups go down, and the intent of the code becomes easier to read.
- Doesn't raising the timer resolution with timeBeginPeriod(1) solve the problem?
- It should not be your default first choice. There are three reasons: it has a power and performance cost, its behavior on recent versions of Windows is somewhat more complicated, and it often leaves the root cause unfixed. If what you really want to wait for is an occurrence such as work arriving or I/O completing, then changing the design so that the side where it happens signals is far more natural for latency, CPU, and power than raising the timer resolution.