Why Windows Code Should Prefer Event Waits Over Timer Polling - Avoiding ~15.6 ms Granularity

· · Windows Development, Synchronization, Events, Timers, Architecture

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:

  1. wakes up periodically even when the queue is empty
  2. latency is dragged toward the timer granularity
  3. 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 SetEvent when an item arrives
  • the worker waits for stop and work in 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:

  1. it has a power and performance cost
  2. its behavior on recent Windows is more nuanced than it used to be
  3. 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) or Task.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 stop and work be 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

Related Articles

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

Related Topics

These topic pages place the article in a broader service and decision context.

Where This Topic Connects

This article connects naturally to the following service pages.

Back to the Blog