Skip to content
← Back to explainers

Python explainer

Race Conditions and Locks

About 4 min to read. Allow extra time to work through examples.

Watch two threads lose an update to a shared counter, then fix it with threading.Lock and learn the three rules that make a lock work.

This article uses Python. You can make it your practice mode to browse related content across the site.

Here is a page-view counter that eight threads share:

class PageViews:
    def __init__(self) -> None:
        self.total = 0

    def record(self) -> None:
        current = self.total
        save_to_cache(current + 1)  # a slow step between the read and the write
        self.total = current + 1

Each thread calls record() 25 times, so you expect a total of 200. Run it, and the total comes out as 25.

How an update gets lost

record() reads the total, does some work, and writes a new total. When two threads run it at once, this can happen:

Thread A Thread B total
reads 41 41
reads 41 41
writes 42 42
writes 42 42

Both threads recorded a view, but the total went up by one. Thread B wrote a value it had computed from out-of-date data. This bug is a race condition. The result depends on the order in which the threads happen to run.

The slow step in the middle makes the gap wide, so the race happens on every run. Real code often has a step like that, such as a function call, a log line, or a request to a cache or database. The wider the gap between the read and the write, the more likely another thread runs during it.

A lock makes threads take turns

A threading.Lock is an object that only one thread can hold at a time. A thread that tries to take a held lock waits until the holder releases it. Put the read, the work and the write inside one with block:

import threading


class PageViews:
    def __init__(self) -> None:
        self.total = 0
        self._lock = threading.Lock()

    def record(self) -> None:
        with self._lock:
            current = self.total
            save_to_cache(current + 1)
            self.total = current + 1

A second thread that calls record() now waits at with self._lock: until the first thread leaves the block. No thread can read total in the middle of another thread's update, and the total comes out as 200.

Three rules make a lock work:

  • Use one lock for one piece of shared data, and make every thread use that same lock. A lock that only some of the threads take protects nothing.
  • Put the whole update inside the lock, including the read, the change and the write. Reads that need an up-to-date value take the lock too.
  • Keep work that does not touch the shared data outside the lock. Every other thread that needs the lock waits while you hold it.

Avoid holding two locks at once

Suppose thread A holds lock 1 and waits for lock 2, while thread B holds lock 2 and waits for lock 1. Both wait forever. This is a deadlock. The simplest way to avoid one is to never hold more than one lock at a time. If you must hold two, always take them in the same order.

Share less

Often the easiest fix is to stop sharing. Let each thread keep its own count and add them up at the end, or pass work between threads through a queue.Queue, which does its own locking. Data that no thread changes once the threads have started needs no lock at all.

Check your understanding count += 1 on a shared integer, with no slow step, passes your two-thread test every time. Is it safe? Show answer

No. count += 1 is still a read, an add and a write. On a standard Python install, a thread switch in the middle of it is rare, so a small test passes almost every time. The bug shows up later, under load, or on Python's free-threaded build, where threads do run at the same time. Use a lock whenever more than one thread changes the value.

step 6 of 56

Concurrency with Threads and asyncio

Up next Fix a Counter That Loses Updates