Implementing Concurrency Bounds with Counting Semaphores
Learn how to use counting semaphores to prevent resource exhaustion by bounding concurrency at the trust boundary, including implementation patterns and operational checks.
31 Oct 2025, 18:36 UTC

The Problem: Resource Exhaustion via Unbounded Concurrency
When a system accepts bursty traffic, it often attempts to process every request immediately. If these requests depend on a finite resource—such as a database connection pool, a fixed number of outbound API sockets, or a memory‑intensive processing buffer—unbounded concurrency leads to resource exhaustion. This manifests as cascading failures, where the protected resource slows down, causing more requests to pile up, which further exhausts the resource until the system crashes or times out.
The takeaway is to implement a counting semaphore at the entry point of the protected subsystem. This creates a hard ceiling on concurrent execution, converting a potential system crash into a manageable queue of waiting requests.
Minimal Design Requirements
- Fixed Capacity: The permit count must equal the maximum capacity the downstream resource can handle without degrading performance.
- Single Entry Point: Place the semaphore at the trust boundary—where untrusted or bursty work enters the protected subsystem—rather than scattering limits across call sites.
- Timed Acquisition: To prevent indefinite blocking, the acquire operation must support a timeout or a try‑acquire variant.
Trust and Data Boundaries
Position the semaphore as a bulkhead between the request‑handling layer (untrusted/volatile) and the resource‑access layer (protected/stable). By enforcing the limit at this boundary, internal stability no longer depends on external client behavior.
Semaphore vs. Rate Limiter
| Feature | Counting Semaphore | Rate Limiter |
|---|---|---|
| Controls | Concurrent executions (parallelism) | Requests per second (throughput) |
| Metric | Active workers | Events over time |
| Primary Goal | Prevent resource exhaustion | Prevent API quota violations |
Implementation Example
The following pseudocode shows a typical acquire‑release pattern with a timeout. Adjust the language‑specific API as needed.
// Initialize a semaphore with 5 permits (matches downstream capacity)
const semaphore = new Semaphore(5);
async function handleRequest(request) {
let acquired = false;
try {
// Try to acquire a permit, waiting up to 2 seconds
acquired = await semaphore.acquire({ timeout: 2000 });
if (!acquired) {
throw new Error('System overloaded: capacity exceeded');
}
// Critical section: use the finite resource
return await useProtectedResource(request);
} finally {
// Always release the permit to avoid leaks
if (acquired) {
semaphore.release();
}
}
}
Operational Execution Details
- Permissions: The semaphore instance must be shared by all workers that access the protected resource (e.g., a module‑level singleton).
- Risk: Forgetting to release a permit (e.g., due to an unhandled exception) creates a permit leak, silently reducing available capacity until no permits remain.
- Fairness: Some implementations offer a FIFO‑fair mode; enabling it prevents starvation but can lower overall throughput due to extra context switches.
Failure Modes and Operational Checks
Monitor three key metrics to detect problems:
- Permit Acquisition Latency: Time a caller waits for a permit. Rising latency indicates the resource is saturated.
- Queue Depth: Number of threads or coroutines currently blocked waiting for a permit.
- Release Correctness: Compare total acquire calls with total release calls; a mismatch signals a leak.
Verification Steps
- Saturation Test: Drive more concurrent requests than the permit count (e.g., 12 requests against a semaphore of 5). Verify that exactly 5 proceed immediately while the rest either wait or timeout according to your configuration.
- Fault Injection: Throw an exception inside the critical section and confirm that the finally block still releases the permit, restoring full capacity afterward.
When to Change the Design
- Distributed Systems: A local semaphore limits concurrency per node only. If the aggregate limit must match a shared resource (e.g., a database cluster), replace the local semaphore with a distributed rate limiter or a centralized lock service.
- Priority Access: When certain request classes need to jump the queue, a plain semaphore is insufficient; consider a priority‑aware admission controller or a weighted fair queue.
- Dynamic Scaling: If the downstream capacity can change at runtime (e.g., autoscaling database instances), you need a mechanism to adjust the permit count dynamically, such as a token bucket that can be resized.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.