Counting Semaphores: Throttle Threads, Avoid Deadlocks, and Tune Fairness
When multiple threads race for a limited resource, a counting semaphore can enforce a safe cap. This post walks through a producer‑consumer example, shows how to configure fairness, and highlights pitfalls like permit leaks.
10 Dec 2025, 21:36 UTC

Concrete Problem: Resource Exhaustion in a Thread‑Heavy App
Imagine a web service that accepts file uploads. Each upload spawns a worker thread that writes the file to disk. If the service launches hundreds of uploads at once, the operating system can run out of file descriptors or the disk queue can become saturated, causing slow‑downs or outright failures. A naive solution is to limit the number of concurrent uploads, but without a clear contract between threads, you quickly run into deadlocks or starvation.
Counting Semaphores 101
A counting semaphore is a lightweight primitive that tracks a numeric permit count. The two core operations are:
acquire()– Decrements the count. If the count is zero, the calling thread blocks until another thread releases a permit.release()– Increments the count and optionally wakes one waiting thread.
Key properties:
- Permit count is the maximum number of threads that may hold the semaphore concurrently.
- Fairness flag (e.g.,
trueinnew Semaphore(permits, true)) enforces FIFO granting, preventing starvation. - Unlike mutexes, semaphores have no notion of an “owner”; any thread can release a permit, which simplifies but also complicates debugging.
Producer‑Consumer Example: Throttling a Shared Buffer
Below is a minimal Java illustration where a producer writes data to a shared buffer of fixed capacity, and a consumer reads from it. A counting semaphore limits the buffer size, ensuring the producer never overfills it.
import java.util.concurrent.*;
class Buffer {
private final int capacity;
private final BlockingQueue<String> queue;
private final Semaphore permits;
Buffer(int capacity) {
this.capacity = capacity;
this.queue = new ArrayBlockingQueue<>(capacity);
// Fairness set to true to avoid starvation under high contention
this.permits = new Semaphore(capacity, true);
}
void produce(String item) throws InterruptedException {
permits.acquire(); // Wait for an available slot
try {
queue.put(item); // May block if queue already full (unlikely here)
} finally {
// No release here – consumer will release after consuming
}
}
String consume() throws InterruptedException {
String item = queue.take(); // Blocks if queue empty
permits.release(); // Free a slot for the producer
return item;
}
}
public class ProducerConsumerDemo {
public static void main(String[] args) throws InterruptedException {
Buffer buffer = new Buffer(5); // Only 5 items can be in flight
ExecutorService pool = Executors.newFixedThreadPool(10);
// Producer task
Runnable producer = () -> {
for (int i = 0; i < 20; i++) {
try {
buffer.produce("msg-<" + i + ">);");
System.out.println(Thread.currentThread().getName() + " produced " + i);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
};
// Consumer task
Runnable consumer = () -> {
for (int i = 0; i < 20; i++) {
try {
String msg = buffer.consume();
System.out.println(Thread.currentThread().getName() + " consumed " + msg);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
};
// Start 3 producers and 2 consumers
pool.submit(producer);
pool.submit(producer);
pool.submit(producer);
pool.submit(consumer);
pool.submit(consumer);
pool.shutdown();
pool.awaitTermination(1, TimeUnit.MINUTES);
}
}
In this pattern, the semaphore’s permit count directly equals the buffer capacity. When the buffer is full, the next produce call blocks until a consumer releases a permit. The fairness flag guarantees that threads acquire permits in the order they requested them, preventing a fast producer from starving slower consumers.
Trade‑offs and Common Pitfalls
- Permit Leaks – If a thread calls
release()more times thanacquire(), the semaphore count grows beyond its intended capacity, allowing more concurrent threads than allowed. Conversely, missing a release can permanently block all waiting threads. A defensive pattern is to wrapacquireandreleasein a try‑finally block, or useSemaphore#tryAcquirewith a timeout to avoid indefinite blocking. - High Contention Overhead – When many threads contend for a single semaphore, the JVM may perform frequent context switches and internal lock contention, degrading performance. Profiling with tools like VisualVM can reveal if
Semaphoreis a hotspot. In such cases, consider partitioning the resource or using a bounded thread pool. - Lack of Ownership Semantics – Because any thread can release a permit, accidental releases can happen if a thread releases a semaphore it never acquired. Adding logging around
acquireandreleaseor using a custom wrapper that tracks ownership can mitigate this. - Fairness vs. Throughput – Enabling fairness guarantees FIFO ordering but introduces additional bookkeeping, which can reduce throughput under low contention. Benchmark both configurations to decide which is appropriate for your workload.
Actionable Checklist for Production Use
- Set the correct permit count: Match the semaphore to the real resource limit (e.g., max concurrent DB connections).
- Choose fairness wisely: Enable
trueonly if starvation is a documented risk. - Guard releases: Use try‑finally blocks or a wrapper that tracks the acquisition count per thread.
- Monitor the count: Expose
Semaphore#getQueueLengthand#getQueueLengthvia JMX or a metrics endpoint to detect leaks early. - Profile under load: Verify that semaphore contention does not become a bottleneck. If it does, consider resource partitioning or alternative primitives like
RateLimiter.
By treating a counting semaphore as a gatekeeper rather than a lock, you can throttle resource usage cleanly, avoid deadlocks, and maintain predictable throughput. The key is to pair the semaphore with robust release logic and observability so that any leaks or contention spikes become visible before they impact users.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.