Why You Should Prefer Event Waits over Sleep(1) on Windows

· Updated: · · 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.

Event-driven waitingDiagram 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.What you wait for is an occurrenceThe side where it happens signalsThe waiting side waits on an eventNatural 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
Drawing the line between timer and eventDiagram 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.Time itselfAn occurrenceAre you waiting for time or an occurrenceA job for a timerA job for waiting on an eventWork 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.

Timer polling versus event-driven waitingDiagram 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 jobsrequiresmay causemay causenot recommended forrecommended forusesrecommended forrecommended fornot recommended forrecommended fornot recommended forrecommended forrecommended formay causerecommended fornot recommended forverified bymay causeusesusesnot recommended forTimer Polling (Status Check Loop)Event-Driven Wait DesignSystem Clock ResolutionScheduler LatencyQueue Arrival WaitWindows Event ObjectWindows Wait FunctionsOverlapped I/OI/O Completion WaitI/O Completion Port (IOCP)Stop-Request WaitWaitOnAddress APIIn-Process Value Change WaitData raceWaitable TimerTime-Based WaittimeBeginPeriod (Timer Resolution Request)GetSystemTimeAdjustmentInterrupt-Induced Latency (ISR/DPC)

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.

Two ways to check the raw granularityDiagram 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.You want to know the granularity on your machineCall GetSystemTimeAdjustmentRun ClockResIt returns the update interval in 100ns unitsIt 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.

  1. The timeout determination itself is pulled along by the timer granularity
  2. Even after the timeout, when execution starts is up to the scheduler
The two layers of uncertainty in a short timer waitDiagram 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.Start a short timer waitTimeout determination depends on timer granularityThe thread only becomes readyThe start of execution is up to the schedulerInfluence 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
What a Sleep(1) loop really doesDiagram 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.Step execution time is addedIt does not become a 1ms periodThe wait is pulled along by the granularityRunning right after waking is not guaranteed

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.

event wait: you are woken because something happenedThe side where it happened signalsWaitThe reason is settled at the moment of wakingProcess ittimer wait: you wake because the time cameNoYesWake on timer granularityWaitDid anything happen?Process it

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.”

What an event wait removes and what remainsDiagram 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.Wait with an event waitScheduler and similar effects remainSleeping until the timer tick is removedA 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.

  1. The thread wakes up periodically even when the queue is empty
  2. Latency is pulled along by the timer granularity
  3. It also loses on power
The three problems with Sleep(1) pollingDiagram 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.Check the queue with Sleep(1)Wakes periodically even when emptyLatency is pulled along by the granularityLoses 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 WaitForSingleObject or WaitForMultipleObjects
  • When it wakes, it drains the queue
The shape where the producer signalsDiagram 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.consumerqueueproducerconsumerqueueproducerPut an item inSetEvent immediately afterWake from the waitDrain 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 SetEvent when an item arrives
  • The worker waits on stop and work at the same time

One more note on handling the return value, since this is easy to get wrong in practice.

  • When bWaitAll is FALSE, the success return value is in the range WAIT_OBJECT_0 through WAIT_OBJECT_0 + nCount - 1, and subtracting WAIT_OBJECT_0 from it gives you the index into the array. If you write only the two branches == WAIT_OBJECT_0 and != 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 stop sits 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 calling GetLastError. If you lump everything into “rc is not the expected value, so it failed,” you lose causes such as a closed handle or a missing SYNCHRONIZE right
  • If you mix a mutex into the wait set, the WAIT_ABANDONED_0 values 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.

  1. Always use it paired with WakeByAddressSingle or WakeByAddressAll. 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
  2. WaitOnAddress can 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
  3. The sizes you can wait on are 1 / 2 / 4 / 8 bytes
  4. Make the flag atomic. WakeByAddressSingle only 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);
The paired flow of WaitOnAddressDiagram 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.the waiting sidethe atomic flagthe writing sidethe waiting sidethe atomic flagthe writing sideRead with acquireWaitOnAddress until it changesWrite with releaseWakeByAddressSingleRe-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.

  1. It has a power / performance cost
  2. On recent versions of Windows, the behavior is a bit more complicated
  3. It often means you have not fixed the root cause
Why not to make timeBeginPeriod a habitDiagram 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.The accuracy bothers youYou are tempted to add timeBeginPeriodDo not make it your default first choiceIt has a costThe behavior is a bit complicatedThe 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 stop and work be 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.

Timer for time, event for occurrencesDiagram 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.TimeAn occurrenceWhich one are you waiting forWait with a timerWait with an eventThe boundary becomes clearLatency becomes easier to reason aboutWasted 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

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

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

This article connects naturally to the following service pages.

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.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog