Synchronization Primitives and Dispatcher Objects

Windows Internals
Time it takes to read this article 6 minutes.

The moment a system runs more than one thread, correctness depends on synchronization. Two threads incrementing the same counter, a driver touching a shared list while an interrupt fires, a service handing work to a pool of workers — each is a race waiting to happen unless access is coordinated. Windows does not offer a single lock; it offers a layered toolbox that ranges from cheap user-mode locks that never enter the kernel to full kernel dispatcher objects that a thread can block on. Choosing the right primitive is partly a performance decision and partly a correctness one, because each has strict rules about the IRQL it may be used at. This article maps that toolbox, connects it to the waiting states introduced in the scheduler article, and closes with why synchronization objects matter to attackers and defenders.

Dispatcher Objects and Wait States

At the heart of Windows synchronization is the dispatcher object — any kernel object that carries a signaled or non-signaled state and can therefore be waited on. The set includes Event, Mutant (the kernel name for a mutex), Semaphore, Timer, and — perhaps surprisingly — Thread, Process, and Queue objects. A thread object becomes signaled when the thread terminates, which is exactly why WaitForSingleObject on a thread handle blocks until that thread exits.

When a thread calls WaitForSingleObject or WaitForMultipleObjects (which funnel into NtWaitForSingleObject and friends), the kernel checks the object’s state. If it is already signaled, the wait is satisfied immediately; if not, the thread transitions into the Waiting state described in the scheduler article and is removed from the ready queues. The link between the waiting thread and the objects it is blocked on is a KWAIT_BLOCK — one per object in a multiple-object wait. When something signals an object, the kernel walks the wait blocks queued on it, decides which waiters are now satisfied (all objects for a wait-all, any one for a wait-any), and makes those threads ready again. This is the same machinery that turns “blocked on I/O” back into “runnable” when the I/O completes.

Events, Mutexes, and Semaphores

Three dispatcher objects cover most application-level needs:

  • Event — a simple signaled/non-signaled flag. A notification event (manual-reset) stays signaled, releasing all waiters, until it is explicitly reset. A synchronization event (auto-reset) releases exactly one waiter and then resets itself automatically, which makes it ideal for handing off a single unit of work.
  • Mutant / Mutex — a lock with ownership. Only the thread that acquired it may release it, and it supports recursive acquisition by the owner. If a thread holding a mutex terminates without releasing it, the mutex becomes abandoned — the next waiter acquires it but receives a status telling it the protected data may be inconsistent.
  • Semaphore — a counter that permits up to N concurrent holders. It is signaled while the count is above zero; each acquire decrements it, each release increments it. Semaphores model bounded resource pools.

All three can be named, and named versions are created in the object namespace under \BaseNamedObjects (or a per-session equivalent), exactly as described in the Object Manager article. Naming is what lets unrelated processes rendezvous on the same event or mutex — a powerful feature with security implications we return to below.

Kernel-Only Primitives

Dispatcher objects are relatively heavyweight: waiting on one may deschedule the thread. Kernel code that runs at high IRQL, or that holds a lock only briefly, needs cheaper tools whose rules are dictated by the IRQL model from the interrupts article:

  • Spinlocks — the fundamental SMP mutual-exclusion primitive. A thread acquiring a spinlock raises to DISPATCH_LEVEL (or higher) and busy-waits, spinning until the lock is free. Because the holder cannot be preempted by the scheduler and must not page-fault or block, spinlock-protected regions must be extremely short. Spinlocks are the only option for data shared with code running at DISPATCH_LEVEL or above, such as DPCs.
  • Fast mutexes and guarded mutexes — lighter-weight mutual exclusion for PASSIVE_LEVEL/APC_LEVEL code that is cheaper than a full mutant when there is no contention.
  • ERESOURCE — the executive reader/writer lock, allowing many concurrent readers or one exclusive writer. It is used throughout the executive and file systems where reads dominate.
  • Pushlocks — very lightweight reader/writer locks built on a single pointer-sized value, used in hot paths (the object manager and handle tables use them) because they are cheap when uncontended.

The guiding rule is IRQL: if the data can be touched at DISPATCH_LEVEL or above, you must use a spinlock, because anything that can block is forbidden there. If everything runs at passive level, the richer, sleep-capable locks are available.

User-Mode Primitives

Crossing into the kernel for every lock acquisition would be ruinous for performance, so Windows provides user-mode primitives that stay in user space in the common, uncontended case:

  • Critical sections — the classic intra-process lock. Entering an uncontended critical section is a handful of interlocked instructions with no kernel transition; only when a thread actually has to wait does it fall back to blocking, historically via a keyed event. A critical section can also spin briefly before blocking (a tunable spin count) to ride out very short contention.
  • Slim Reader/Writer (SRW) locks — a compact, modern reader/writer lock that fits in a single pointer and is cheaper than a critical section; the preferred choice for new code.
  • Condition variables — used with a critical section or SRW lock to wait for a predicate to become true, avoiding busy polling.
  • Address-based waitsWaitOnAddress/WakeByAddressSingle let a thread sleep until the value at a memory address changes, the low-level building block underneath much of the above.

The theme is “avoid the kernel until you must.” A well-designed lock only pays for a system call when a thread genuinely has to sleep, which is the exception rather than the rule in most workloads.

Deadlocks and Priority Inversion

Locks introduce their own failure modes. A deadlock occurs when two threads each hold a lock the other needs; the standard defense is a strict global lock ordering so that all code acquires locks in the same sequence. Priority inversion is subtler: a high-priority thread waits on a lock held by a low-priority thread that never gets scheduled because a medium-priority thread is monopolizing the CPU. The Windows scheduler mitigates this with the priority-boost mechanism from the scheduler article — periodically boosting a starved thread so it can run, release its lock, and unblock the high-priority waiter. Understanding these interactions is essential when reasoning about hangs, which often trace back to a lock held across a blocking operation it should never have spanned.

Inspecting Synchronization State

A kernel debugger makes locks and waits concrete. These commands are the core toolkit for diagnosing a hang or studying the primitives:

# List contended executive resources (ERESOURCE) and their owners/waiters
!locks

# Inspect the raw dispatcher-object structures
dt nt!_KMUTANT <address>
dt nt!_KEVENT <address>

# Resolve a named event/mutex in the object namespace
!object \BaseNamedObjects

# See what a specific thread is blocked on (its wait blocks and reason)
!thread <ethread-addr>

!locks is the first stop for a suspected deadlock: it lists executive resources, who owns them, and who is waiting. !thread decodes a thread’s wait reason and the objects in its wait blocks, so you can follow a chain of “thread A waits on a lock owned by thread B, which waits on…”. On a live system without a debugger, the user-mode Wait Chain Traversal API (used by Task Manager’s “Analyze wait chain”) reconstructs the same dependency graph to identify the thread at the root of a hang.

Security Relevance

Synchronization objects are not just an engineering concern; they surface repeatedly in security work:

  • Malware infection markers. A great deal of malware creates a named mutex on startup as a single-instance check — “if this mutex already exists, another copy of me is running, so exit.” These mutex names are stable enough to become high-value host indicators, and defenders catalog them; creating the mutex first can even act as a crude vaccination against some families.
  • Named-object squatting and denial of service. Because named objects in \BaseNamedObjects are shared, a low-privileged process can pre-create or perpetually hold a name that a privileged service expects to own (tie back to the Object Manager article). Depending on how the service reacts to an existing object, this can block the service from starting or influence its behavior.
  • Race conditions (TOCTOU). Time-of-check-to-time-of-use bugs — where privileged code validates something and then acts on it, and an attacker changes the state in between — are a whole class of local privilege-escalation vulnerabilities. They exist precisely where synchronization is missing or incorrect around a security-sensitive check.

For defenders, the lessons are concrete: monitor for known malicious mutex names, ensure privileged services fail safely when an expected named object already exists, and treat any security check followed by an action on shared state as a potential race that needs a lock or an atomic operation. (Everything here is for defensive understanding and authorized testing.)

Conclusion

Windows synchronization is a layered story: user-mode critical sections and SRW locks that avoid the kernel until contention forces a wait, kernel dispatcher objects that integrate with the scheduler’s waiting states, and IRQL-bound primitives like spinlocks and pushlocks for the hot, high-IRQL paths. Picking correctly is a matter of both performance and the strict IRQL rules that govern what may block. With concurrency primitives understood, the next article looks at how Windows puts them to work at scale in the thread pool — the infrastructure that lets applications dispatch work to worker threads without managing threads by hand.

You Might Also Like

This article is part of the Windows Internals series. These related deep-dives cover adjacent parts of the system:

Comments

Copied title and URL