DEV Community

Cover image for Semaphore
Abhinav Pasham
Abhinav Pasham

Posted on

Semaphore

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.Semaphore exists
  • 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())
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Now imagine creating 1000 coroutines.

1000 Coroutines

↓

1000 API Requests
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Initially, three permits are available.

Now suppose six coroutines arrive.

Task 1

Task 2

Task 3

Task 4

Task 5

Task 6
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

When one task finishes,

Task 2 finishes

↓

Permit Released

↓

Task 4 gets Permit
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)