Back to Blog
Python

Using Python Bounded Semaphore to Limit Concurrency

python bounded semaphore: Learn how Python's bounded semaphore controls concurrent access to shared resources, with threading and asyncio examples and common pitfalls.

concurrencythreadingasynciosynchronizationresource limiting
Illustration of a Python bounded semaphore controlling concurrent access to a shared resource, with a counter and a gate.

python bounded semaphore requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

When multiple threads or async tasks need to share a limited resource, a plain lock is often too strict. A semaphore maintains a counter that allows a fixed number of concurrent acquisitions, and a bounded semaphore adds a guard against releasing more times than acquired. The threading and asyncio modules both provide semaphore implementations, but the threading version includes a bounded variant that raises an error on over-release. This article explains how to use the python bounded semaphore correctly in both synchronous and asynchronous code.

What Is a Bounded Semaphore in Python?

A semaphore is a synchronization primitive that holds an internal counter. Each call to acquire() decrements the counter, and each call to release() increments it. When the counter reaches zero, further acquire() calls block until another thread or task calls release(). This allows a fixed number of concurrent entries into a critical section or resource access.

A bounded semaphore is a semaphore that tracks the maximum value of the counter. In Python, threading.BoundedSemaphore raises ValueError if release() is called more times than the initial value, which indicates a programming error. The plain threading.Semaphore does not enforce this bound, so a stray extra release() silently increases the counter and may allow more concurrent accesses than intended.

BoundedSemaphore vs Semaphore in Python

The difference between threading.Semaphore and threading.BoundedSemaphore is subtle but important. Both accept an initial counter value, but the bounded version remembers that value and checks it on every release. If you call release() more times than the initial counter, BoundedSemaphore raises ValueError. The plain Semaphore allows the counter to grow without limit, which can mask bugs.

import threading s = threading.Semaphore(2) s.release() # counter becomes 3, no error b = threading.BoundedSemaphore(2) b.release() # ValueError: Semaphore released too many times

The bounded version is almost always the safer choice in production code because it catches an accidental extra release early. The asyncio module only provides asyncio.Semaphore, which behaves like the unbounded threading.Semaphore; there is no bounded async variant in the standard library.

Using threading.BoundedSemaphore to Limit Thread Concurrency

A common use case is limiting how many threads can access a network service or a shared file handle at the same time. The following example shows a worker function that acquires the semaphore before performing work and releases it in a finally block to guarantee release even if an exception occurs.

import threading import time import random semaphore = threading.BoundedSemaphore(3) def worker(worker_id): with semaphore: print(f"Worker {worker_id} acquired") time.sleep(random.uniform(0.1, 0.5)) print(f"Worker {worker_id} releasing") threads = [threading.Thread(target=worker, args=(i,)) for i in range(5)] for t in threads: t.start() for t in threads: t.join()

Using with semaphore: is equivalent to calling acquire() before the block and release() after it. This context-manager pattern is less error-prone than manual acquire/release because it guarantees release even when an exception propagates. The output shows that at most three workers hold the semaphore at any moment.

Using asyncio.Semaphore for Async Concurrency

Async code uses asyncio.Semaphore to limit concurrent coroutines, for example when making HTTP requests to an API with a rate limit. The usage is similar to the threading version, but acquire() and release() are coroutines and must be awaited.

import asyncio import aiohttp async def fetch(session, url, semaphore): async with semaphore: async with session.get(url) as response: return await response.text() async def main(): semaphore = asyncio.Semaphore(5) urls = ["https://example.com"] * 10 async with aiohttp.ClientSession() as session: tasks = [fetch(session, url, semaphore) for url in urls] results = await asyncio.gather(*tasks)

The async with semaphore: pattern acquires and releases the semaphore around the block. Because asyncio.Semaphore is not bounded, you must ensure that each acquire() is matched by exactly one release(). Using the context manager prevents accidental over-release.

Common Mistakes and Edge Cases

Forgetting to release the semaphore is the most frequent error. If a thread acquires a semaphore and an exception occurs before release, the counter remains decremented and other threads may block forever. Always use the context manager or a try/finally block.

Another mistake is using a semaphore where a lock is more appropriate. A semaphore with initial value 1 behaves like a lock, but it does not enforce ownership. Any thread can call release() on a semaphore, even if it never acquired it. This can lead to subtle bugs where one thread releases on behalf of another. For mutual exclusion, use threading.Lock.

Releasing a BoundedSemaphore more times than its initial value raises ValueError. This is a useful safety net, but it does not prevent all misuse. For example, acquiring twice and releasing once leaves the counter at one less than the initial value, which will eventually cause a deadlock if repeated. The bounded check only catches over-release, not under-release.

Performance and Operational Considerations

Semaphores are lightweight, but they are not free. Each acquire and release involves an atomic operation and, when blocked, a context switch or task suspension. For very short critical sections, the overhead of a semaphore may be higher than the work itself. In such cases, consider whether the resource really needs limiting.

The choice of the initial value affects throughput and latency. A lower limit reduces contention but may underutilize the resource. A higher limit increases concurrency but may overwhelm the underlying system. Monitor the actual resource usage and adjust the limit based on observed behavior rather than guessing.

In threaded code, the global interpreter lock (GIL) means that CPU-bound threads do not run in parallel. Semaphores are still useful for I/O-bound threads where the GIL is released during blocking operations. For CPU-bound parallelism, use processes with multiprocessing and its own semaphore equivalent, or consider concurrent.futures with a process pool.

When to Choose a Bounded Semaphore Over Other Primitives

A bounded semaphore is the right tool when you need to allow a fixed number of concurrent accesses to a resource, and you want to catch accidental over-release. Use a plain Semaphore only when you deliberately need to allow the counter to grow, which is rare.

For mutual exclusion, use a Lock instead of a semaphore with value 1. A lock enforces ownership and is simpler to reason about. For passing data between threads, a Queue provides a higher-level abstraction that internally uses locks and condition variables, and it is often easier to use correctly than a semaphore.

When the number of concurrent operations is dynamic and depends on available capacity, consider a ThreadPoolExecutor or asyncio.Semaphore with a limit derived from a configuration value. The bounded semaphore is most valuable when the limit is fixed and known at startup, and when a programming mistake that releases too many times should fail loudly rather than silently degrade the system.

python bounded semaphore: Practical Usage and Code Examples | RYUSLOG DEV