Concepts / Concurrency in one process / Locks and mutexes

Locks and mutexes

Making a critical section one-at-a-time, and what it costs.

01The intuition

The problem it solves

A ticket counter sells the last seat. Two clerks, on two threads, both check that one seat remains and both sell it. The code looks correct because each line is correct; the problem is between the lines.

import threading

class Counter:
    def __init__(self, seats: int) -> None:
        self.seats = seats

    def sell(self) -> bool:
        if self.seats > 0:          # thread A reads 1, thread B reads 1
            self.seats -= 1         # both decrement: seats becomes -1
            return True
        return False

Run 2,000 sells across 20 threads on 1,000 seats and you will see negative counts some runs and not others. That is the signature of a race: the check and the change are two steps, and another thread can slip in between them. Any object that several threads update has this problem unless it does something about it.

Build it one piece at a time

Step one: a lock. A lock is a token that only one thread can hold; the check-and-change pair runs while holding it, so the pair becomes one indivisible step from every other thread's point of view. The region inside the lock is the critical section, and the with statement guarantees the lock is released even if the code raises.

class Counter:
    def __init__(self, seats: int) -> None:
        self.seats = seats
        self.lock = threading.Lock()

    def sell(self) -> bool:
        with self.lock:                 # one thread at a time from here
            if self.seats > 0:
                self.seats -= 1
                return True
            return False

Step two: keep the critical section small. If selling also sends an email that takes 200 ms, holding the lock during the email serialises every sale behind the slowest network call. Decide inside the lock, act outside it.

class Counter(Counter):   # continued
    def sell_and_notify(self, email: str) -> bool:
        with self.lock:
            if self.seats == 0:
                return False
            self.seats -= 1
            seat_no = self.seats + 1    # decided under the lock
        send_email(email, seat_no)      # slow work outside the lock
        return True

def send_email(to: str, seat: int) -> None:
    pass

Step three: one lock per independent thing. If the counter sells seats for 500 shows, one global lock makes every show wait for every other. A lock per show lets unrelated sales proceed in parallel; the dictionary of locks itself needs a lock only while a new show is added.

class BoxOffice:
    def __init__(self) -> None:
        self.seats: dict[str, int] = {}
        self.locks: dict[str, threading.Lock] = {}
        self.registry_lock = threading.Lock()

    def add_show(self, show: str, seats: int) -> None:
        with self.registry_lock:
            self.seats[show] = seats
            self.locks[show] = threading.Lock()

    def sell(self, show: str) -> bool:
        with self.locks[show]:          # only this show waits
            if self.seats[show] > 0:
                self.seats[show] -= 1
                return True
            return False

What two threads see with and without the lock, selling the last seat:

MomentWithout lockWith lock
1A reads seats = 1A acquires, reads 1
2B reads seats = 1B blocks on acquire
3A writes 0, returns TrueA writes 0, releases
4B writes −1, returns TrueB acquires, reads 0, returns False

Why it works

The invariant a lock protects is whatever the object promises about its fields, here seats is never negative and every True corresponds to exactly one seat. The lock makes the critical section atomic with respect to other critical sections on the same lock, so the invariant can be broken inside the section and must be restored before leaving it. Nothing outside the section may read the fields without the lock, or it can see the broken middle state.

Where it breaks: forgetting that reads need the lock too, calling a method that takes the same lock from inside the section (a plain Lock deadlocks against itself; RLock allows re-entry at a cost), and holding two locks in different orders in two places, which is the deadlock discussed in its own article. In Python specifically, the interpreter lock does not save you: seats -= 1 is still several bytecodes.

throughput with one global lock ≈ 1 / (critical section time). With k independent locks and uniform traffic ≈ k / (critical section time)

The trade-off you must say out loud

A lock trades parallelism for correctness. One coarse lock is simplest and often enough; finer locks buy throughput and risk deadlock; lock-free structures and atomics avoid blocking but are hard to get right. Say which you chose, what the critical section contains, and what you deliberately moved outside it. The follow-up that usually comes next is "what if most calls only read", which leads to read-write locks, and "what if two objects must change together", which leads to lock ordering or optimistic versions.

Where it shows up in the 100 Google problems

02Used in these problems