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
Enter fullscreen mode Exit fullscreen mode
Now imagine two coroutines trying to increment it.
async def increment():
global counter
temp = counter
await asyncio.sleep(1)
counter = temp + 1
Enter fullscreen mode Exit fullscreen mode
Initially I expected the final value to become
2
Enter fullscreen mode Exit fullscreen mode
because two coroutines are incrementing the counter.
But that wasn’t what happened.
Let’s See What Actually Happens
Initially
counter = 0
Enter fullscreen mode Exit fullscreen mode
Now Coroutine A starts executing.
Read counter
↓
temp = 0
↓
await
Enter fullscreen mode Exit fullscreen mode
The coroutine reaches await.
The event loop suspends it and starts another coroutine.
Now Coroutine B executes.
Read counter
↓
temp = 0
↓
await
Enter fullscreen mode Exit fullscreen mode
Notice something interesting.
Both coroutines have already read
counter = 0
Enter fullscreen mode Exit fullscreen mode
Now Coroutine A resumes.
counter = 1
Enter fullscreen mode Exit fullscreen mode
Then Coroutine B resumes.
counter = 1
Enter fullscreen mode Exit fullscreen mode
The final value becomes
1
Enter fullscreen mode Exit fullscreen mode
instead of
2
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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()
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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())
Enter fullscreen mode Exit fullscreen mode
Output
2
Enter fullscreen mode Exit fullscreen mode
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:
Enter fullscreen mode Exit fullscreen mode
instead of
await lock.acquire()
...
lock.release()
Enter fullscreen mode Exit fullscreen mode
The answer became clear when I introduced an exception.
await lock.acquire()
raise Exception()
lock.release()
Enter fullscreen mode Exit fullscreen mode
The lock is never released.
Every other coroutine waits forever.
Using
async with lock:
Enter fullscreen mode Exit fullscreen mode
Python automatically releases the lock,
even if an exception occurs.
What if Lock Didn’t Exist?
Imagine an online banking application.
Current Balance
₹1000
Enter fullscreen mode Exit fullscreen mode
User A
Deposit ₹500
Enter fullscreen mode Exit fullscreen mode
User B
Withdraw ₹200
Enter fullscreen mode Exit fullscreen mode
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.
답글 남기기