Why Does Python Need asyncio.Semaphore?
python
INTRODUCTION
While learning asyncio, I understood coroutines, the event loop, and asyncio.gather(). I learned that multiple coroutines can run concurrently by pausing at await, allowing the event loop to switch between them.
After that, one question immediately came to my mind.
If I create 1000 coroutines, will all of them execute at the same time?
Initially, I thought that would be a good thing because more concurrency means better performance. But later I realized that too much concurrency can actually become a problem.
In this article, I'll explain why asyncio.Semaphore exists, the problem it solves, and where it's used in real-world backend applications.
What You Will Learn
- Why
asyncio.Semaphoreexists - The problem it solves
- What happens without a semaphore
- How semaphore works internally
- Real-world use cases
- Practical implementation
Prerequisites
Before learning asyncio.Semaphore, you should understand:
- Coroutines
- Event Loop
- await
- asyncio.create_task()
- asyncio.gather()
The Problem
After learning asyncio.create_task() and asyncio.gather(), I wrote something like this.
import asyncio
async def worker(task_id):
print(f"Task {task_id} started")
await asyncio.sleep(2)
print(f"Task {task_id} finished")
async def main():
tasks = []
for i in range(1000):
tasks.append(asyncio.create_task(worker(i)))
await asyncio.gather(*tasks)
asyncio.run(main())
Initially I thought,
"Great! Now all my tasks are running concurrently."
But then another question came into my mind.
What if every task calls the same database or the same external API?
async def worker():
await call_api()
Now imagine creating 1000 coroutines.
1000 Coroutines
↓
1000 API Requests
Most APIs have rate limits.
Databases also have connection limits.
Even if there is no limit, sending thousands of requests simultaneously wastes resources and increases load on the backend.
So the problem isn't creating many coroutines.
The problem is allowing all of them to access the same resource simultaneously.
This is exactly the problem asyncio.Semaphore solves.
What is asyncio.Semaphore?
A semaphore limits how many coroutines can execute a particular block of code at the same time.
For example,
semaphore = asyncio.Semaphore(3)
means,
At most 3 coroutines are allowed inside the protected block simultaneously.
If a fourth coroutine arrives, it doesn't fail.
It simply waits until one of the running coroutines finishes.
How Python Achieves This
Think of a semaphore as a collection of permission tokens.
Semaphore(3)
↓
Permit
Permit
Permit
Initially, three permits are available.
Now suppose six coroutines arrive.
Task 1
Task 2
Task 3
Task 4
Task 5
Task 6
The execution flow becomes
Task 1 gets Permit ✓
Task 2 gets Permit ✓
Task 3 gets Permit ✓
Task 4 waits
Task 5 waits
Task 6 waits
When one task finishes,
Task 2 finishes
↓
Permit Released
↓
Task 4 gets Permit
The semaphore doesn't stop creating coroutines.
It only limits how many can execute a particular block simultaneously.
Practical Example
import asyncio
semaphore = asyncio.Semaphore(3)
async def worker(task_id):
async with semaphore:
print(f"Task {task_id} started")
await asyncio.sleep(2)
print(f"Task {task_id} finished")
async def main():
tasks = []
for i in range(10):
tasks.append(asyncio.create_task(worker(i)))
await asyncio.gather(*tasks)
asyncio.run(main())
Output (order may vary)
Task 0 started
Task 1 started
Task 2 started
Task 1 finished
Task 3 started
Task 2 finished
Task 4 started
Notice something interesting.
Although we created 10 tasks, only 3 tasks were allowed to execute inside the semaphore block at any moment.
The remaining tasks simply waited for their turn.
What if Semaphore Didn't Exist?
Suppose you're downloading 1000 images.
Without a semaphore,
Download Image 1
Download Image 2
Download Image 3
...
Download Image 1000
Every download starts immediately.
Now imagine the same thing happening for
- API requests
- Database queries
- File uploads
Your application can easily overload the server or hit API rate limits.
Instead of controlling concurrency manually, Python provides asyncio.Semaphore.
Real-world Use Cases
API Rate Limiting
Suppose an external API only allows 20 requests at a time.
Instead of sending hundreds of requests simultaneously,
semaphore = asyncio.Semaphore(20)
ensures only twenty requests are processed concurrently.
Database Connections
Most databases don't allow unlimited active connections.
Instead of allowing every coroutine to access the database simultaneously,
a semaphore limits the number of concurrent database operations.
File Downloads
Suppose you need to download 500 images.
Without limiting concurrency,
every download starts immediately.
Using
asyncio.Semaphore(10)
only ten downloads happen simultaneously, reducing network congestion.
Advantages of Semaphore
- Prevents resource exhaustion
- Prevents API rate-limit errors
- Controls concurrency easily
- Reduces unnecessary load on databases
- Makes applications more stable
Conclusion
Initially, I thought creating more coroutines would always improve performance.
Later I realized that the real problem wasn't creating coroutines—it was allowing too many of them to access the same resource simultaneously.
asyncio.Semaphore solves this by limiting how many coroutines can enter a particular block of code at the same time.
Once I understood the problem it solves, the syntax became much easier to remember.
In the next article, we'll look at another question that came to my mind while learning asyncio.
If only one coroutine executes at a time, why do we still need
asyncio.Lock?
Top comments (0)