Windows Timer-Queue Timers: Callback Overlap, Cancellation, and Drain
Manage legacy Windows timer-queue callbacks safely: prevent overlap surprises, stop new expirations, wait for callbacks, and release callback state last.
Timer-queue timers are a legacy Win32 API for scheduling callbacks after a relative delay and optionally at recurring intervals. The wait is managed through the Windows thread pool; when a timer expires, a worker executes the supplied callback. That is convenient for short background actions, but the API’s simple surface hides three important lifecycle facts: callback execution can overlap when a recurring interval elapses before previous work completes, cancellation does not automatically mean the callback has drained, and the callback parameter must remain valid until all execution has ended.
Modern applications should compare this API with the Vista-era thread-pool timer objects before adopting it for new code. The legacy queue can still be appropriate when maintaining an existing subsystem or when its API contract fits the job, but it should be treated as an asynchronous callback system with explicit ownership and shutdown. A timer is not a thread and the callback is not guaranteed to run at an exact instant.
Delay, period, and callback scheduling
Create a timer queue with CreateTimerQueue or use the default queue by passing NULL when creating a timer. CreateTimerQueueTimer accepts an initial due time and period in milliseconds. A zero period is one-shot; a positive period causes subsequent expirations. The callback runs on a thread-pool worker by default, so system scheduling and other application work affect when it actually starts.
The most important periodic-timer rule is that the callback is invoked each time the period elapses even if the previous callback has not finished. A slow callback can therefore overlap with itself and mutate the same state concurrently. Make the callback idempotent and reentrant, serialize it with a lock or an explicit “work outstanding” state, or use a queue that coalesces ticks into one pending unit. Do not assume the timer queue will skip or merge missed work.
struct RefreshState {
std::atomic<bool> stopping{false};
std::mutex refreshMutex;
Configuration* configuration; // Owned by the service until callbacks drain.
};
VOID CALLBACK RefreshTimer(PVOID raw, BOOLEAN)
{
auto* state = static_cast<RefreshState*>(raw);
if (state->stopping.load(std::memory_order_acquire)) {
return;
}
std::lock_guard<std::mutex> guard(state->refreshMutex);
if (!state->stopping.load(std::memory_order_relaxed)) {
RefreshConfiguration(*state->configuration);
}
}
The sample shows one possible serialization policy; it intentionally omits timer creation and error paths. A lock prevents simultaneous refresh bodies, but it does not make the callback’s duration fit the timer period. If the callback takes longer than the period, later workers can accumulate waiting on the same mutex and delay unrelated work. A better design may atomically mark one refresh pending, let a worker drain a queue, or increase the period based on measured completion time.
The callback parameter is borrowed until drain
The Parameter passed to CreateTimerQueueTimer is delivered to every callback invocation. The API does not copy or own the pointed-to object. Keep that object, the callback function’s code, and any resources it references valid until all pending and running callbacks have completed.
DeleteTimerQueueTimer removes the timer and can notify or wait for callback completion. Passing INVALID_HANDLE_VALUE as the completion event makes the call wait for running callbacks to finish. Passing NULL marks the timer for deletion and returns immediately; if a callback is active, it continues, and the caller receives no completion notification. For most teardown paths, asynchronous deletion is not enough if the caller plans to free the callback parameter immediately afterward.
bool StopTimerAndDrain(HANDLE queue, HANDLE timer)
{
if (timer == nullptr) {
return true;
}
if (!DeleteTimerQueueTimer(queue, timer, INVALID_HANDLE_VALUE)) {
const DWORD error = GetLastError();
ReportTimerDeletionFailure(error);
return false; // Caller must retain callback state until drain is confirmed.
}
return true; // No callback is still using its parameter after a successful drain.
}
With a NULL completion event, ERROR_IO_PENDING indicates that callbacks remain while asynchronous deletion proceeds. The example instead requests a blocking drain with INVALID_HANDLE_VALUE; if that call reports failure, the owner must not treat the callback state as safe to free. Do not retry blindly or free state because the first call returned. A robust owner can use a completion event when it needs to continue shutdown asynchronously, then release the callback context only after that event is signaled.
Avoid waiting from the callback itself
Blocking timer deletion can deadlock if a callback waits for itself or if two callbacks perform mutually dependent blocking deletions. Run teardown on an owner thread that is outside the callback being drained. The owner should first prevent any code from creating or changing timers, then delete timers and wait for callbacks, then release the shared state.
Do not hold a lock that the callback needs while making a blocking delete call. The classic cycle is simple: shutdown owns the lock and waits for the callback; the callback waits for the same lock before returning. Set the stop state under a short critical section, release the lock, then stop and drain the timer. Callback code should not call blocking deletion on its own timer.
Thread-pool callbacks should be short and restore any thread-specific state before returning. A worker may be reused for other work. If a timer callback changes impersonation, COM state, priority, or thread-local values, make sure it restores that state even on error paths. Prefer a dedicated thread when the task requires a long-lived thread-affine environment rather than asking a shared worker callback to simulate one.
Sleep, hibernation, and timing policy
Due time and period are specified in milliseconds relative to the current time. Callback start is subject to scheduling delay. On modern Windows, time spent in sleep or hibernation does not count toward expiration; older Windows versions had different behavior. A service that must catch up after resume should inspect system/power notifications and recompute its deadline rather than assuming a periodic callback fired for every interval while the machine was suspended.
The interval should express product policy, not a guess at a precise scheduler cadence. Use coalescing or event-driven notifications for work whose exact timing is unimportant. For an absolute deadline, store the intended target time in application state and compare it when the callback runs. For fixed-rate jobs, define how to handle overdue intervals; for fixed-delay jobs, schedule the next action after completion.
Choose the right timer abstraction
Waitable timers expose a signaled synchronization object and are useful when a thread should wait on a timer together with other handles. APC timer completion routines run on a specific alertable thread. Legacy timer-queue timers schedule callbacks on pool workers. The modern thread-pool timer API creates PTP_TIMER objects and provides pool-specific callback-drain behavior. These are not interchangeable: choose based on where work should run, whether the consumer needs a waitable state, and how shutdown is owned.
Timer queues require extra care when using WT_EXECUTEINTIMERTHREAD or persistent-thread flags. The timer API documents restrictions: work on the timer thread must be short, and a blocking delete from certain persistent-thread callbacks can deadlock. The default callback scheduling is usually the least surprising choice. Avoid raising global thread-pool worker limits to compensate for callbacks that block; reduce blocking or use a dedicated worker model instead.
Operational verification
Record timer creation success, due time, period, callback start/end timestamps, overlap count, and shutdown-drain duration. Inject a callback that runs longer than the period and confirm that state remains correct. Race stop against expiration repeatedly; verify that no callback runs after the owner’s state is freed. Test the queue deletion path with several timers and one stuck callback, and confirm the application logs which timer delayed teardown.
Never call TerminateThread to make a timer callback appear canceled. The callback may own locks or be halfway through an external side effect. Set a cooperative stop flag, let the operation reach a safe point, drain it, and then reclaim resources. The timer queue is only the scheduling layer; correctness depends on the callback’s concurrency policy and the owner’s lifetime boundary.
Related:
- Windows Waitable Timers: Deadlines, Periodicity, and Safe Cancellation
- Windows APCs and Alertable Waits: Thread-Affine Completion Callbacks
Sources: