DEV Community

Abhinav Pasham
Abhinav Pasham

Posted on

ASYNC SYNCHRONIZATION IN PYTHON

INTRODUCTION

While learning Python's asyncio, I understood coroutines and the event loop. Coroutines can run concurrently by pausing at await, allowing the event loop to switch between them.

But after understanding this, I had a few questions:

If I create 1000 coroutines, will all of them access the database simultaneously?
If two coroutines update the same variable, won't they overwrite each other's changes?
If producers generate work faster than consumers process it, where does that work wait?

These questions led me to Python's asynchronous synchronization primitives. They are not used to make coroutines asynchronous—they are used to coordinate asynchronous coroutines safely.

In this article, I'll explain why Semaphore, Lock, and Queue exist, the problems they solve, and how they are used in real-world backend applications.

What You Will Learn
Why synchronization primitives exist
Why the event loop alone is not enough
The problem solved by asyncio.Semaphore
The problem solved by asyncio.Lock
The problem solved by asyncio.Queue
Internal working of each concept
Real-world backend examples
How these concepts work together
Prerequisites

Before reading this article, you should understand:

Coroutines
asyncio
Event Loop
await
asyncio.create_task()
asyncio.gather()
The Problem

When I first learned asyncio, I thought:

Since Python uses only one thread with the event loop, why do we even need synchronization?

Initially this made sense because only one coroutine executes at a particular instant.

But later I realized something important.

A coroutine can pause whenever it reaches an await.

Coroutine A

Reads shared data

await

Coroutine B starts executing

Modifies same data

Coroutine A resumes

Now both coroutines are working on the same resource.

Similarly,

1000 Coroutines

All call the same API

Server overloaded

Or,

1000 Jobs

Only 5 workers

Where should remaining jobs wait?

These problems cannot be solved by the event loop itself.

They require synchronization.

Why Async Synchronization Exists

The event loop schedules coroutines.

Synchronization primitives coordinate coroutines.

These are completely different responsibilities.

Event Loop

Decides WHO executes

Synchronization

Controls HOW they execute

Python provides three important synchronization primitives.

Semaphore

Limit concurrent access

Lock

Protect shared resources

Queue

Store and distribute work
asyncio.Semaphore
The Problem

Suppose you create 1000 coroutines.

Each coroutine calls an external API.

Coroutine1

Coroutine2

Coroutine3

...

Coroutine1000

Without any limit,

all 1000 requests may start together.

This can:

overload the backend
exceed API rate limits
consume unnecessary memory
Why Semaphore Exists

A semaphore limits how many coroutines are allowed to execute a particular section simultaneously.

Semaphore(3)

Permit

Permit

Permit

Only three coroutines may enter.

The remaining coroutines wait.

Internal Working
Task1 enters

Permit Count = 2

Task2 enters

Permit Count = 1

Task3 enters

Permit Count = 0

Task4 arrives

Wait

Task2 finishes

Permit released

Task4 enters

The semaphore is automatically released when execution leaves the async with block.

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")
Real-world Example

Suppose your backend downloads images.

1000 Images

Semaphore(20)

Only 20 downloads happen simultaneously.

This prevents excessive resource usage.

What if Semaphore didn't exist?
1000 Tasks

1000 API Calls

Rate Limit

Failures
asyncio.Lock
The Problem

Imagine two coroutines updating the same bank balance.

Balance = ₹1000

Coroutine A

Read Balance

Add ₹500

Write Balance

Coroutine B

Read Balance

Subtract ₹200

Write Balance

Both read the same value before either writes it.

One update overwrites the other.

This is called a race condition.

Critical Section

A critical section is a block of code that accesses or modifies shared resources and therefore should only be executed by one coroutine at a time.

Why Lock Exists

A lock ensures only one coroutine executes the critical section at a time.

Other coroutines wait until the lock is released.

Internal Working
Coroutine1 acquires lock

Critical Section

Coroutine2 waits

Coroutine1 finishes

Lock Released

Coroutine2 enters
Example
lock = asyncio.Lock()

async def update_balance():

async with lock:

    balance = await get_balance()

    balance += 500

    await save_balance(balance)
Enter fullscreen mode Exit fullscreen mode

Real-world Example

Inventory Management

Product Quantity = 1

Two users purchase simultaneously.

Without Lock,

both may successfully purchase the same product.

With Lock,

only one coroutine updates the inventory at a time.

What if Lock didn't exist?
Coroutine A

Read

await

Coroutine B

Modify

Coroutine A resumes

Incorrect Data
asyncio.Queue
The Problem

Imagine customers placing orders faster than chefs can prepare them.

Orders

1

2

3

4

5

6

Only two chefs are available.

Where should the remaining orders wait?

Why Queue Exists

A queue temporarily stores work until workers become available.

It follows the FIFO (First In, First Out) principle.

Internal Working
Producer

queue.put()

Queue

queue.get()

Consumer

queue.task_done()
Example
queue = asyncio.Queue()

await queue.put("Order1")

job = await queue.get()

queue.task_done()
Real-world Example

Image Processing

User uploads image

Queue

Background Worker

Compress Image

Generate Thumbnail

Upload

The user gets an immediate response while background workers process the image.

What if Queue didn't exist?
1000 Jobs

Workers Busy

Jobs Lost

or

The producer must continuously wait for a worker to become free.

How These Three Work Together

In a real backend system, these primitives are often used together.

User Request

Queue

Worker

Semaphore

Lock

Database

Each primitive solves a different problem.

Queue stores incoming work.
Semaphore limits concurrent processing.
Lock protects shared data.
Advantages
Semaphore
Prevents resource exhaustion
Controls concurrency
Helps respect API rate limits
Lock
Prevents race conditions
Protects shared resources
Ensures data consistency
Queue
Buffers incoming work
Implements producer-consumer architecture
Decouples producers from workers
Conclusion

Initially, I thought the event loop alone was enough because only one coroutine executes at a time. But while learning more, I realized that asynchronous applications face a different set of problems:

Too many coroutines may compete for limited resources.
Multiple coroutines may modify shared data.
Producers and consumers may run at different speeds.

These problems are solved by asyncio.Semaphore, asyncio.Lock, and asyncio.Queue.

Understanding why these synchronization primitives exist is much more valuable than simply memorizing their syntax, because once the problem is clear, the solution becomes intuitive.

Top comments (0)