Back to Blog
Python

Using Python Bounded Semaphore to Limit Concurrency

Learn how Python's BoundedSemaphore limits concurrent access with threading and asyncio examples, and how to avoid common mistakes.

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

A bounded semaphore lets a fixed number of threads or async tasks access a shared resource at the same time while catching accidental over-release. This article explains how to use threading.BoundedSemaphore and asyncio.BoundedSemaphore, shows examples in both synchronous and asynchronous code, and covers common mistakes.

When multiple threads or async tasks need to share a limited resource, a plain lock is often too strict. A semaphore maintains an internal counter that allows a fixed number of concurrent acquisitions. A bounded semaphore also remembers the initial counter value and raises ValueError if a release would push the counter above that value. Both threading and asyncio include bounded semaphore implementations, but their APIs differ slightly.

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 initial value of the counter. In Python, threading.BoundedSemaphore raises ValueError if release() is called when the counter is already at that 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. asyncio.BoundedSemaphore applies the same over-release guard in async code.

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 a call to release() would push the counter above the initial value, 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. asyncio provides asyncio.BoundedSemaphore with the same behavior.

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 uses a context manager 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. At most three workers hold the semaphore at any moment.

Using asyncio.BoundedSemaphore for Async Concurrency

Async code can use asyncio.BoundedSemaphore 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 only acquire() is a coroutine; release() is a regular method. The async with statement handles both correctly.

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.BoundedSemaphore(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)

This example uses the third-party aiohttp package; the semaphore pattern is the same with any awaitable HTTP client. The async with semaphore: pattern acquires and releases the semaphore around the block. asyncio.BoundedSemaphore raises ValueError if a release would push the counter above its initial value, which helps catch unbalanced acquire/release calls. If you manage the semaphore manually, remember that acquire() must be awaited but release() must not be.

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, or asyncio.Lock in async code.

The bounded check is a useful safety net, but it does not prevent all misuse. For example, acquiring twice and releasing once leaves the counter one below its initial value; if repeated, the counter will eventually reach zero and a later acquire() can block. 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.BoundedSemaphore 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: Threading and asyncio Examples | RYUSLOG DEV