Using Python asyncio Semaphore for Concurrency Control
Learn how to use python asyncio semaphore to limit concurrent coroutines, control resource usage, and avoid overwhelming external services.
python asyncio semaphore requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
When you launch many asyncio tasks at once, you can quickly saturate a remote API, exhaust database connections, or consume more memory than intended. The asyncio.Semaphore primitive lets you cap how many coroutines run concurrently, giving you a simple way to apply backpressure without rewriting your task orchestration logic.
What Is a Semaphore in asyncio?
A semaphore is a synchronization primitive that maintains a counter. In asyncio, asyncio.Semaphore is an awaitable object that you acquire before entering a critical section and release when done. The counter starts at a fixed value; each acquire() decrements it, and each release() increments it. If the counter reaches zero, any coroutine that calls acquire() will wait until another coroutine releases the semaphore.
This is distinct from a lock, which allows only one holder at a time. A semaphore allows a configurable number of concurrent holders, making it ideal for limiting concurrency in async code.
Basic Usage of asyncio.Semaphore
The simplest pattern is to create a semaphore with a desired limit and use it as an async context manager inside each task. Here is a minimal example:
import asyncio async def worker(semaphore, task_id): async with semaphore: print(f"Task {task_id} running") await asyncio.sleep(1) print(f"Task {task_id} done") async def main(): semaphore = asyncio.Semaphore(2) tasks = [worker(semaphore, i) for i in range(5)] await asyncio.gather(*tasks) asyncio.run(main())
The async with semaphore: statement acquires the semaphore before entering the block and releases it automatically when the block exits, even if an exception occurs. This is the recommended way to use it because it prevents leaks.
Limiting Concurrency with asyncio.gather
When you use asyncio.gather to run many coroutines, you can wrap each one with a semaphore. However, if you create all tasks upfront, they all start immediately and the semaphore only controls the execution inside the coroutine. That works, but it still creates all task objects, which may be wasteful for very large numbers of tasks. A better approach is to create tasks lazily, but the semaphore pattern remains the same.
Here is a common pattern for limiting concurrent HTTP requests:
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(): urls = ["https://example.com" for _ in range(10)] semaphore = asyncio.Semaphore(3) async with aiohttp.ClientSession() as session: tasks = [fetch(session, url, semaphore) for url in urls] results = await asyncio.gather(*tasks) print(len(results)) asyncio.run(main())
In this example, only three requests are in flight at any time. The rest wait until a slot frees up.
Handling Timeouts and Errors
When a task waits on a semaphore, it can be cancelled or time out. If you use asyncio.wait_for around a task that is waiting on a semaphore, the cancellation propagates correctly. However, if you are using the semaphore as a context manager, the __aexit__ method will release the semaphore even if the body raises an exception. That is safe.
Consider a scenario where you want to fail fast if a semaphore slot is not available within a timeout:
import asyncio async def limited_task(semaphore, task_id): try: async with asyncio.timeout(2): async with semaphore: await asyncio.sleep(1) except asyncio.TimeoutError: print(f"Task {task_id} timed out waiting for semaphore")
asyncio.timeout is available from Python 3.11; in earlier versions, use asyncio.wait_for on the entire coroutine.
Performance and Resource Considerations
The main benefit of a semaphore is predictable resource usage. Without it, a burst of incoming requests could spawn thousands of coroutines, each holding a network connection or file handle. The semaphore caps the number of coroutines that reach the resource-intensive section, reducing memory pressure and preventing remote service throttling.
There is a small overhead to acquiring and releasing a semaphore, but it is negligible compared to the cost of an I/O operation. If your tasks are CPU-bound, a semaphore will not help; you need a ProcessPoolExecutor or similar. For I/O-bound tasks, the semaphore is a lightweight and effective control mechanism.
Common Mistakes and Edge Cases
One common mistake is creating a new semaphore inside each task. That defeats the purpose because each task gets its own counter. The semaphore must be shared across all tasks that need to be limited.
Another mistake is forgetting to release the semaphore when using manual acquire() and release() calls. If an exception occurs between them, the semaphore is never released, and other tasks may block forever. Always use async with to avoid this.
Also, note that asyncio.Semaphore is not thread-safe. It is designed for use within a single event loop. If you need to share a limit across threads, consider using asyncio.Semaphore with loop.run_in_executor carefully, or use a threading semaphore instead.
When to Use a Semaphore vs. Other Approaches
A semaphore is the right tool when you need to limit the number of concurrent coroutines executing a specific block of code. Alternatives include:
- asyncio.Queue: Useful for producer/consumer patterns where you want to control the flow of work items. The queue itself can limit how many items are pending, but it does not directly limit concurrency.
- asyncio.TaskGroup (Python 3.11+): Provides structured concurrency but does not offer a built-in concurrency limit. You still need a semaphore.
- Rate limiting libraries: If you need to enforce a rate (requests per second) rather than just a concurrency cap, a semaphore is not sufficient. You need a token bucket or sliding window algorithm.
Use a semaphore when you know the maximum number of simultaneous operations you want to allow. For example, limit database connection pool usage to 10, or limit API calls to 5 concurrent requests.
Advanced Pattern: Dynamic Semaphore with asyncio.Queue
For large numbers of tasks, you can combine a semaphore with a queue to avoid creating all tasks at once. Here is a pattern that processes items from a queue with a limited number of workers:
import asyncio async def worker(queue, semaphore): while True: item = await queue.get() async with semaphore: await process(item) queue.task_done() async def process(item): await asyncio.sleep(0.1) async def main(): queue = asyncio.Queue() semaphore = asyncio.Semaphore(3) for i in range(20): await queue.put(i) workers = [asyncio.create_task(worker(queue, semaphore)) for _ in range(5)] await queue.join() for w in workers: w.cancel() asyncio.run(main())
Here, the semaphore limits how many process calls run concurrently, while the workers themselves are independent. This decouples the number of workers from the concurrency limit, giving you flexibility.
Compatibility and Version Notes
asyncio.Semaphore has been part of the standard library since Python 3.4, but its behavior with async with is stable. In Python 3.10 and later, asyncio.Semaphore can be used with asyncio.timeout and other modern features. If you are supporting older Python versions, avoid using asyncio.timeout and stick to asyncio.wait_for.
Remember that the semaphore's counter is not a guarantee of fairness. If many tasks are waiting, the order in which they acquire the semaphore is not specified. For strict FIFO ordering, you would need to implement a queue, but for most concurrency-limiting use cases, fairness is not critical.
By using python asyncio semaphore correctly, you can keep your async applications stable and responsive under load.