Why Does Python Need asyncio.Lock?
INTRODUCTION
After understanding asyncio.Semaphore, I thought I had learned everything required to control multiple coroutines.
A semaphore limits how many coroutines can execute simultaneously.
Then another question came to my mind.
If Python's event loop executes only one coroutine at a time, why do we even need a Lock?
Initially, I assumed a lock was unnecessary because there was only one thread.
But after experimenting with shared variables, I realized that even though only one coroutine executes at a particular instant, multiple coroutines can still interfere with each other.
In this article, I'll explain the problem that led to asyncio.Lock, how it works, and why almost every backend application uses it.
What You Will Learn
- Why
asyncio.Lockexists - What is a race condition
- What is a critical section
- How Lock works internally
- Practical examples
- Real-world backend use cases
Prerequisites
Before learning asyncio.Lock, you should understand:
- Coroutines
- Event Loop
- await
- asyncio.Semaphore
The Problem
Suppose we have a shared variable.
counter = 0
Now imagine two coroutines trying to increment it.
async def increment():
global counter
temp = counter
await asyncio.sleep(1)
counter = temp + 1
Initially I expected the final value to become
2
because two coroutines are incrementing the counter.
But that wasn't what happened.
Let's See What Actually Happens
Initially
counter = 0
Now Coroutine A starts executing.
Read counter
↓
temp = 0
↓
await
The coroutine reaches await.
The event loop suspends it and starts another coroutine.
Now Coroutine B executes.
Read counter
↓
temp = 0
↓
await
Notice something interesting.
Both coroutines have already read
counter = 0
Now Coroutine A resumes.
counter = 1
Then Coroutine B resumes.
counter = 1
The final value becomes
1
instead of
2
This is called a Race Condition.
Why Did This Happen?
Initially I blamed the Event Loop.
Later I realized,
the Event Loop didn't do anything wrong.
Its job is simply to switch between coroutines whenever they reach an await.
The real problem was that both coroutines were modifying the same data.
The Critical Section
The block of code that accesses or modifies shared data is called the Critical Section.
temp = counter
await asyncio.sleep(1)
counter = temp + 1
If multiple coroutines execute this block, the shared data can become inconsistent.
So this block should only be executed by one coroutine at a time.
Python's Solution
Python introduced
asyncio.Lock()
A Lock ensures that only one coroutine can execute the critical section at a time.
If another coroutine tries to enter,
it simply waits until the lock is released.
How Lock Works
Imagine only one key exists.
Coroutine A
↓
Acquires Lock 🔒
↓
Critical Section
↓
Releases Lock
↓
Coroutine B acquires Lock
Unlike Semaphore,
a Lock always allows only one coroutine inside.
Practical Example
import asyncio
lock = asyncio.Lock()
counter = 0
async def increment():
global counter
async with lock:
temp = counter
await asyncio.sleep(1)
counter = temp + 1
async def main():
await asyncio.gather(
increment(),
increment()
)
print(counter)
asyncio.run(main())
Output
2
Now both coroutines execute safely because only one coroutine can enter the critical section.
Why async with?
Initially I wondered why we write
async with lock:
instead of
await lock.acquire()
...
lock.release()
The answer became clear when I introduced an exception.
await lock.acquire()
raise Exception()
lock.release()
The lock is never released.
Every other coroutine waits forever.
Using
async with lock:
Python automatically releases the lock,
even if an exception occurs.
What if Lock Didn't Exist?
Imagine an online banking application.
Current Balance
₹1000
User A
Deposit ₹500
User B
Withdraw ₹200
Without a Lock,
both operations may read the same balance before updating it.
The final balance becomes incorrect.
The same problem occurs in
- Inventory systems
- Payment gateways
- Order processing
- Shared counters
- Database updates
Real-world Use Cases
Banking Systems
Updating account balances safely.
Inventory Management
Preventing two customers from purchasing the last available product.
Order Processing
Generating unique order numbers.
Shared Cache
Updating shared cache values safely.
Advantages of Lock
- Prevents race conditions
- Protects shared resources
- Maintains data consistency
- Automatically releases resources with
async with - Makes concurrent applications reliable
Conclusion
Initially I thought that because asyncio uses only one thread, race conditions couldn't happen.
But after understanding how the event loop switches between coroutines at every await, I realized that multiple coroutines can still interfere with shared data.
asyncio.Lock doesn't make the program asynchronous.
It simply makes shared resources safe by ensuring that only one coroutine executes the critical section at a time.
Once I understood the problem it solves, using a Lock became much more intuitive than simply remembering its syntax.
In the next article, we'll answer another question that came to my mind while learning asyncio.
If workers are busy processing tasks, where should newly arriving tasks wait?
That's where asyncio.Queue comes in.
Top comments (0)