Windows Semaphores: Permit Accounting, Named Object Reuse, and Safe Shutdown
Use Windows semaphores with exact permit accounting, checked waits and releases, named-object identity checks, bounded cancellation, and safe handle retirement.
A Windows semaphore stores a count between zero and a configured maximum. A successful wait consumes one unit; ReleaseSemaphore adds units back. This is useful for limiting simultaneous resource users or representing available work, but those are two different protocols. Confusing them produces phantom work, leaked capacity, or shutdown paths that release permits nobody acquired.
The kernel protects the count, not the application meaning of that count. It does not identify a permit owner, recover a resource transaction, or close a work queue. A production design must define what each unit represents and how acquisition, cancellation, completion, and object retirement preserve that definition.
Choose one accounting invariant
For a capacity limiter with maximum N, each admitted operation consumes one permit and returns it after releasing the resource. The application should be able to explain every outstanding unit as a live resource user. Releasing after a timed-out wait is incorrect because the caller never consumed a unit.
For a work-availability semaphore, a producer adds a unit after publishing an item, and a consumer spends a unit before removing an item. The consumer does not return that unit after processing unless the queue protocol explicitly republishes work. Treating this semaphore like a capacity guard makes completed work reappear as available capacity to dequeue nonexistent items.
Neither pattern substitutes for synchronization protecting queue or resource state. A count can wake a consumer while the application still accesses its container incorrectly. Specify when data is published relative to the release, which lock protects extraction, and what happens if an operation is cancelled after consuming its unit.
Creation can open an existing object
CreateSemaphoreW accepts an initial count and maximum count. The initial count must be nonnegative and no greater than the positive maximum. With a null name, it creates an unnamed object appropriate for a process-local ownership design.
With a name, creation can return an existing semaphore. In that case, the supplied counts are ignored and GetLastError reports ERROR_ALREADY_EXISTS. The caller cannot prove the existing object’s initial configuration merely by passing its preferred values to the function.
For a subsystem requiring a newly created named object, inspect the successful call’s last-error indication immediately and reject unexpected reuse. For an intentionally shared object, define a separate participant/version/bootstrap contract. A recognizable name is not proof that the current peer created the expected protocol state.
Object names share a namespace with several other kernel object types. A same-name object of another type can cause failure. Session/global namespace choices and security descriptors also belong in a reviewed interprocess design. Do not solve an access failure by granting everyone full control or inventing a predictable globally writable name.
Acquire before using the resource
The following Windows C++ function uses an already live process-local capacity semaphore. It runs a bounded synchronous operation only after successful acquisition, then attempts exactly one release. It reports the operation result separately from a release failure.
#include <windows.h>
using Operation = DWORD (*)(); // Contract: bounded and must not throw.
struct Result {
DWORD operationError;
DWORD releaseError;
};
Result RunWithPermit(HANDLE semaphore, DWORD timeout, Operation operation)
{
DWORD wait = WaitForSingleObject(semaphore, timeout);
if (wait == WAIT_TIMEOUT)
return { ERROR_TIMEOUT, ERROR_SUCCESS };
if (wait == WAIT_FAILED)
return { GetLastError(), ERROR_SUCCESS };
if (wait != WAIT_OBJECT_0)
return { ERROR_INVALID_DATA, ERROR_SUCCESS };
DWORD operationError = operation();
DWORD releaseError = ERROR_SUCCESS;
if (!ReleaseSemaphore(semaphore, 1, nullptr))
releaseError = GetLastError();
return { operationError, releaseError };
}
// Owner creates the limiter before admitting operations:
// HANDLE limiter = CreateSemaphoreW(nullptr, 4, 4, nullptr);
// if (limiter == nullptr) { /* capture GetLastError(), do not admit users */ }
The callback contract is deliberately narrow. If application code can throw C++ exceptions, use an ownership guard or an explicit exception-safe cleanup boundary rather than copying this function unchanged. Fatal termination and process crashes are not ordinary scope-exit events, and no RAII wrapper can promise recovery from all such failures.
The caller also guarantees that the handle remains live throughout waiting, resource use, and release. Passing an invalid or concurrently closed handle does not become safe because error checking is present. The example was source-reviewed against Microsoft documentation, not executed on a Windows host during authoring.
Release results are protocol evidence
ReleaseSemaphore requires a positive increment. If the increment would exceed the configured maximum, it fails without changing the count. Check its boolean return and preserve GetLastError on failure. Silently ignoring a failed release hides accounting bugs and can leave future waiters blocked.
An over-release does not always exceed the maximum. If other users are currently holding permits, an extra release may remain within the allowed numeric range while granting more simultaneous users than the resource can support. The kernel’s upper bound is therefore not a complete double-release detector.
The optional previous-count output describes the count at that operation. Other threads can acquire or release immediately afterward. Do not use it as a durable occupancy measurement or a check-then-act authorization decision. Maintain application metrics for admitted operations and resource ownership, with synchronization appropriate to those metrics.
Windows semaphores do not enforce mutex-style ownership. One thread may release a unit consumed through another thread’s work protocol. This flexibility is useful for producer/consumer handoff, but it means the application cannot use the semaphore itself to prove that a particular completion has already returned its permit.
Waiting is not fairness or cancellation
Microsoft explicitly warns against assuming FIFO selection among semaphore waiters. If a product requires starvation bounds or priority scheduling, implement and test that scheduler rather than inferring it from the semaphore.
A finite wait bounds the acquisition interval, not the work performed after acquisition. Blocking a UI thread can still prevent message processing or create a dependency cycle. Choose an executor that can block safely and define a deadline for the actual operation separately from the permit wait.
For cancellation, a separate stop signal and a reviewed multiple-object wait protocol can be useful. Define the tie-breaking rule when shutdown and capacity are simultaneously signaled, and what happens if the worker acquires capacity just before it observes stop. Do not inflate a capacity semaphore during shutdown simply to wake every waiter; that changes resource admission and may exceed its maximum.
Retire handles only after users leave
Closing a semaphore handle does not return consumed units. The object is destroyed when its last handle closes, but application resource ownership still requires an explicit protocol. Microsoft documents that closing a handle while a wait is pending has undefined behavior.
Stop admission first, signal cancellation through the application’s designated mechanism, wait for workers and outstanding resource users to finish, and then close the owner handle. Keep any interprocess peers in the lifetime analysis; another process holding a handle can keep the object alive, while a crashed participant may have consumed units without releasing them.
A semaphore has no mutex abandonment notification that automatically repairs a lost capacity unit. If process death recovery matters, use a supervisory state model that can reconcile resources and rebuild or replace the protocol safely. Adding arbitrary permits after a suspected failure risks over-admission when the original owner is merely slow.
Verify the full accounting model
Test zero available units, timed-out acquisition, successful acquisition and release, injected operation failure, and injected release failure. Assert that a timeout produces no release. Track actual simultaneous resource users and prove the maximum is not exceeded under contention, rather than checking only final count-related observations.
Add named-object reuse, wrong object type, access denial, and controlled shutdown while workers are waiting. Confirm that handles are closed only after waiters leave. Test double completion paths because an in-range over-release can evade the kernel maximum check.
Record supported Windows versions, target architecture, protocol identity, and native fixture results. A correct semaphore integration has an explainable accounting invariant and a safe retirement boundary; a successful wait alone establishes neither.
Related:
- Windows WaitOnAddress: State Predicates, Wake Races, and Shutdown Protocols
- Windows SRW Locks and Condition Variables: Building a Correct Monitor
Sources: