Skip to content
WindowsDeep Dive Published Updated 7 min readViews unavailable

Windows SRW Locks and Condition Variables: Building a Correct Monitor

Pair SRW locks with Windows condition variables for reader-writer access and blocking queues, while accounting for wake races, non-recursion, and shutdown.

Slim reader/writer locks and condition variables are small user-mode synchronization primitives for coordinating threads inside one Windows process. An SRW lock protects a shared resource with either concurrent shared readers or one exclusive writer. A condition variable lets a thread sleep until a predicate over protected state may have changed, atomically releasing and later reacquiring the associated lock. Used together, they can express a reader-writer cache, a producer-consumer queue, or a service’s stop-and-drain state without turning every notification into a kernel event.

Their compactness comes with sharp rules. SRW locks are not recursive, do not promise FIFO fairness, and cannot be upgraded from shared to exclusive ownership. Condition-variable wakeups are hints to re-check a predicate, not ownership of the resource or proof that work is available. A correct implementation makes state authoritative, changes it under the lock, waits in a loop, and defines how shutdown wakes all blocked participants.

Decide whether this is an in-process lock

SRW locks and Windows condition variables are process-local. They are not named kernel objects for communication between unrelated processes. If independent processes need a shared coordination primitive, choose an appropriate named event, mutex, semaphore, or a higher-level IPC protocol. For portable C++ code, std::mutex and std::shared_mutex can be preferable; the Win32 primitives are useful when the program needs Win32 condition waits, compact native structures, or integration with Windows APIs.

Initialize an SRW lock by assigning SRWLOCK_INIT to a static object or calling InitializeSRWLock for dynamic storage. Initialize a condition variable similarly with CONDITION_VARIABLE_INIT or InitializeConditionVariable. These objects do not have close functions; their containing storage must nevertheless remain alive until no thread can acquire, wait on, or wake through them.

Choose shared mode only for genuinely read-only access. Writers acquire exclusive mode and prevent all other access until they release it. The kernel does not promise FIFO or reader/writer fairness, so a design with a continuous stream of readers must not assume that a waiting writer will be granted the lock after a fixed number of reads. If fairness is an application requirement, make that policy explicit with a higher-level queue or lock abstraction and measure the cost.

A condition variable waits on a predicate

Consider a worker waiting for items or a stop request. The predicate is !queue.empty() || stopping; both fields must be read and modified under the same exclusive SRW lock. SleepConditionVariableSRW atomically releases the lock while sleeping and reacquires it before returning. The wait belongs in a loop because a wake can be consumed by another thread, the condition can change again before the waiter reacquires the lock, or a timeout/error can return without the application predicate becoming true.

SRWLOCK g_lock = SRWLOCK_INIT;
CONDITION_VARIABLE g_changed = CONDITION_VARIABLE_INIT;
std::queue<Job> g_jobs;
bool g_stopping = false;

bool TryTakeJob(Job& result)
{
    AcquireSRWLockExclusive(&g_lock);
    while (g_jobs.empty() && !g_stopping) {
        if (!SleepConditionVariableSRW(
                &g_changed, &g_lock, INFINITE, 0)) {
            const DWORD error = GetLastError();
            ReleaseSRWLockExclusive(&g_lock);
            ReportWaitFailure(error);
            return false;
        }
    }

    if (g_jobs.empty()) { // stopping and no remaining work
        ReleaseSRWLockExclusive(&g_lock);
        return false;
    }

    result = std::move(g_jobs.front());
    g_jobs.pop();
    ReleaseSRWLockExclusive(&g_lock);
    return true;
}

This fragment illustrates the lock-and-predicate relationship rather than a complete queue implementation. A producer must enqueue while holding g_lock, then call WakeConditionVariable or WakeAllConditionVariable after changing the predicate. The wake need not itself be protected by a lock after the state transition, but releasing the lock before waking often keeps the critical section short. Correctness comes from publishing the state under the same lock the waiter reacquires, not from the timing of the wake call.

Use WakeConditionVariable when one newly available item can satisfy one waiter. Use WakeAllConditionVariable when a state transition is relevant to every sleeper, such as setting g_stopping, changing global configuration, or invalidating a resource. Waking all workers for every single queue item can create a thundering herd: all threads compete for one lock, one consumes the item, and the others return to sleep.

Shared reads and exclusive mutation

An SRW lock allows many readers to inspect an immutable snapshot concurrently. A reader acquires shared mode, reads only the fields protected by the lock, and releases shared mode. A writer acquires exclusive mode to replace the snapshot or update related fields atomically. Keep the protected region small: perform file access, logging, callbacks, and expensive computation outside the lock unless their consistency requirements truly demand it.

SRWLOCK g_configLock = SRWLOCK_INIT;
ConfigSnapshot g_config;

ConfigSnapshot ReadConfig()
{
    AcquireSRWLockShared(&g_configLock);
    ConfigSnapshot copy = g_config;
    ReleaseSRWLockShared(&g_configLock);
    return copy;
}

void ReplaceConfig(ConfigSnapshot next)
{
    AcquireSRWLockExclusive(&g_configLock);
    g_config = std::move(next);
    ReleaseSRWLockExclusive(&g_configLock);
}

The copy must be a safe value snapshot; copying a structure that contains mutable pointers does not automatically protect the pointed-to objects after the lock is released. Either make the snapshot own its data, use immutable reference-counted values, or preserve access under a separate lifetime protocol. A lock protects the invariants covered by its critical section, not arbitrary memory reachable from the protected structure.

Non-recursion, upgrades, and lock ordering

Do not acquire the same SRW lock recursively. Recursive shared acquisition is not safe when mixed with an exclusive waiter; exclusive recursive acquisition can deadlock. Likewise, a thread holding a shared lock cannot upgrade itself in place to exclusive mode. It must release shared ownership, acquire exclusive ownership, and re-check the predicate because another thread may have changed the data during the gap.

That re-check is important for read-modify-write logic. If a reader inspects a cache and concludes that a refresh is needed, it should release shared mode, take exclusive mode, and inspect the cache again before doing the refresh. Otherwise multiple threads can all queue behind the exclusive lock and redundantly refresh data based on a stale observation. If avoiding this duplicate work is important, represent the refresh state explicitly and use a condition variable for followers to wait until the owner completes.

Define lock ordering when more than one lock exists. Do not call unknown callbacks while holding an SRW lock: callbacks may re-enter the component and attempt to acquire the same lock. Do not block on a condition while holding another lock needed by a thread that must change the condition. Those cycles are application deadlocks; the primitives cannot detect or resolve them.

Timeouts and shutdown

For a finite timeout, SleepConditionVariableSRW returns failure when the wait times out, with GetLastError() reporting ERROR_TIMEOUT. A timeout does not mean the condition became true. Re-check the protected predicate and calculate a new remaining deadline if the operation has an overall time budget; repeatedly restarting the full timeout can silently extend the caller’s deadline.

Shutdown should be represented in the same protected state as the queue or cache. Under the lock, set g_stopping; release the lock; then wake all waiters. Each worker exits only after checking both the stop flag and the work predicate under the lock. Decide whether workers drain queued jobs or abandon them, and make that policy observable to callers. Do not destroy the condition variable’s storage or protected queue while any worker can still be waiting or running.

Verification under contention

Test with many readers and writers, but assert invariants rather than expecting a particular lock acquisition order. Stress the empty-queue wait/wake boundary by having producers enqueue immediately before and after workers begin waiting. Test a burst of queue items, a stop request with an empty queue, and a stop request with work still pending. Add finite wait timeouts and inject failures in paths that can return an error.

Use Application Verifier or concurrency-aware testing to catch misuse, and record queue depth, wait duration, and time spent holding exclusive access. If readers starve writers or wake-all produces excessive contention, profile before replacing a primitive. SRW locks and condition variables are building blocks: high-quality code comes from a precise predicate, consistent lock ownership, short critical sections, and a shutdown contract that wakes and drains every participant.

Related:

Sources:

Comments