Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Managing `asyncio` Concurrency with Semaphores

Hello! Welcome to the next lesson in our module on Python Concurrency and Parallelism.

In the previous lesson, we explored the fundamentals of asyncio, learning how to achieve concurrency by creating and running multiple tasks with asyncio.create_task(). This allows us to fire off a large number of I/O-bound operations, like network requests, and let the event loop manage them.

However, unleashing an unlimited number of concurrent requests can be problematic. This is a challenge you've likely managed directly when designing systems like Rudi, where controlling request throughput is critical for Quality of Service (QoS) and system stability. Trying to open thousands of network connections or database connections simultaneously can overwhelm not just the remote server, but also your local machine's resources.

This brings us to today's learning outcome: to manage concurrency levels in asyncio applications using semaphores. We'll learn how to apply a constraint on our concurrent tasks, ensuring we don't exceed a desired level of parallelism.

1. What is a Semaphore?

A semaphore is a synchronization primitive that limits the number of coroutines that can access a shared resource or block of code at the same time. It maintains an internal counter which is:

  • Decremented by an acquire() operation.
  • Incremented by a release() operation.

If a coroutine tries to acquire() the semaphore when its counter is zero, the coroutine will pause and wait until another coroutine calls release(), incrementing the counter and allowing the waiting coroutine to proceed.

This is the direct asyncio equivalent of tokio::sync::Semaphore which you may have used in Rust. A common special case is a semaphore with a counter of 1, which behaves exactly like a mutex (asyncio.Lock), allowing only one coroutine at a time.

The image below illustrates this concept. Without a semaphore, tasks might run uncontrollably. With a semaphore, we establish a "gate" that only allows a fixed number of tasks through at any one time.

Sequential vs. Concurrent Tasks with Semaphore
This image contrasts uncontrolled sequential execution with controlled concurrent execution. The semaphore acts as a gatekeeper, limiting the number of tasks running in parallel to a predefined limit (in this case, two).

In asyncio, the idiomatic way to use a semaphore is with an async with block, which guarantees that acquire() is called on entry and release() is called on exit, even if errors occur.




# Create a semaphore that allows 3 concurrent operations
semaphore = asyncio.Semaphore(3)

async def do_work():
    async with semaphore:



        # This block will only be entered by 3 coroutines at a time.
        # The 4th coroutine will wait here until one of the first 3 exits.
        print("Acquired semaphore, doing work...")
        await asyncio.sleep(2)
    print("Work done, released semaphore.")

2. Implementing a Simple Rate Limiter

One of the most common use cases for a semaphore is to limit the rate of outgoing requests to an external API. Let's see how this works in practice.

First, we'll see the problem: making many requests without any limits. Then, we'll see how asyncio.Semaphore provides a simple and elegant solution.

Asynchronous requests and rate limiting (HTTPX and asyncio.Semaphore)

This video provides a concise demonstration of the exact problem we're solving. It starts by showing how a naive asyncio script can overwhelm a server and then introduces asyncio.Semaphore to control the concurrency.

Watch the video from 07:33 to 11:34. Pay attention to: The initial run where all requests are sent simultaneously (07:33). How asyncio.Semaphore is instantiated with a limit (08:56). The use of async with semaphore: to wrap the request logic (09:36). The final run, which clearly shows the controlled, sequential execution when the limit is 1, and controlled concurrency when the limit is 5 (10:37).

As the video shows, wrapping the I/O-bound call (client.get(...)) inside the async with semaphore: block is all that's needed to enforce the concurrency limit. The event loop and the semaphore handle the details of pausing and resuming the waiting tasks for you.

To reinforce this pattern, let's look at a well-structured code example from a blog post. This example is particularly relevant to your goals, as it shows how to limit concurrent calls to the OpenAI API.

Semaphores in Python: Use Case 5 - Calling OpenAI API

This article, "Semaphores in Python Async Programming," provides several real-world use cases. We'll focus on the one for rate-limiting API requests, which is highly practical.

Please read the section "Use Case 5: Calling OpenAI API". Notice how the semaphore is integrated into a helper function make_api_call that wraps the API request. This is a clean pattern for encapsulating rate-limiting logic.

3. Integrating Semaphores into a Larger Application

In our previous lesson, you watched a video from Corey Schafer (Python Tutorial: AsyncIO - Complete Guide) where he built a concurrent image downloader. He demonstrated running all downloads in parallel, but what if we need to be more "polite" to the server?

This is where a semaphore comes in. Let's see how to add one to that existing application.

Python Tutorial: AsyncIO - Complete Guide to Asynchronous Programming with Animations

Continuing with the example from our last lesson, we'll now watch the part of Corey Schafer's video where he addresses the need to limit concurrency and implements a semaphore.

Watch the section from 01:31:26 to 01:36:10. This part covers: The motivation for limiting concurrency (not hammering servers). Creating an asyncio.Semaphore with a specific limit. Passing the semaphore into the task's coroutine. Wrapping the core logic of the coroutine in an async with block for the semaphore.

This demonstrates how easily a semaphore can be retrofitted into an existing asyncio application. By isolating the resource-intensive part of your coroutine (await client.get(...) or await aiofiles.open(...)) and wrapping it with the semaphore, you gain fine-grained control over your application's concurrency.

4. Trade-offs and Considerations

As you requested, let's discuss the trade-offs. While semaphores are powerful, they are not without their complexities, especially in large systems.

Semaphores in Python: Drawbacks

The same article on semaphores also has an excellent section on their drawbacks. Understanding these is key to using them effectively in production.

Read the "Drawbacks" section. Pay particular attention to the concepts of starvation and the lack of fairness, which are common issues in concurrent systems.

Key drawbacks to keep in mind:

  • Starvation: A task may repeatedly fail to acquire a semaphore if other tasks are acquiring and releasing it rapidly. asyncio's semaphore does not guarantee fairness (i.e., first-in, first-out).
  • Deadlocks: If a task holds one semaphore and waits for another, while a second task holds the second semaphore and waits for the first, you have a classic deadlock. Careful design is needed when using multiple semaphores.
  • Lack of Backpressure: A semaphore limits the number of producers (e.g., download tasks), but if the consumer of the results is slow, results can pile up in memory. A semaphore alone doesn't solve this. The problem of backpressure is typically solved with a bounded queue, which we will explore in the next lesson.

Conclusion

Today we've added a crucial tool to our asyncio toolkit. We moved from simply running tasks concurrently to precisely controlling how many tasks run concurrently.

Key Takeaways:

  • asyncio.Semaphore is a synchronization primitive used to limit concurrent access to a resource.
  • It is instantiated with a number representing the maximum allowed concurrency.
  • The async with semaphore: syntax is the safest and most readable way to use it, ensuring the semaphore is always released.
  • Common use cases include rate-limiting API calls, managing connection pools, and controlling access to CPU or memory-intensive operations.
  • While effective, semaphores have potential pitfalls like starvation and don't inherently provide backpressure.

Next Lesson Preview

We briefly touched on the idea of "backpressure" and the producer-consumer pattern. In our next lesson, we will dive deep into this. We will re-implement the producer-consumer pattern using asyncio queues and tasks. This will allow us to build more sophisticated data processing pipelines where we can control not only the number of concurrent workers but also the flow of data between them.

Can't find a good explanation? Sign up and we'll make it for you

Sign up