DEV Community

Cover image for ASYNCIO.LOCK
Abhinav Pasham
Abhinav Pasham

Posted on

ASYNCIO.LOCK

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.Lock exists
  • 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.

Top comments (0)