Course outline · 0% complete

0/28 lessons0%

Course overview →

Locks: one at a time, please

lesson 5-3 · ~11 min · 17/28

The mutex

The classic fix for a race is a lock, also called a mutex, short for mutual exclusion. A lock is a token only one thread can hold.

  1. A thread acquires the lock before touching the shared data, and if another thread holds it, this thread waits.
  2. It does the read-modify-write.
  3. It releases the lock, waking one waiter.

The code between acquire and release is called the critical section. Locks turn three interruptible steps into one uninterruptible unit, closing exactly the gap that lesson 5-2 exploited.

PythonMeaning
lock = threading.Lock()create the token
with lock:acquire, run the block, release

The with statement guarantees release even if the code inside raises an exception, which is why it is preferred over manual acquire and release calls. A lock left held by a crashed thread would stop every other thread forever.

thread 1thread 2thread 3critical sectionread, modify, writeuninterruptible as a unitkeep the critical section small, because everything else waits herea lock held across a slow call serializes the whole programlock1 at atime
One token, one thread at a time inside the critical section, and everyone else queues.

The same four threads, now correct

Every increment happens inside the lock, and the answer is exact on every run.

import threading

counter = 0
lock = threading.Lock()

def worker():
    global counter
    for _ in range(100_000):
        with lock:
            counter += 1

threads = [threading.Thread(target=worker) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(counter)

Output

400000

with lock: acquires before the block and releases after, so read-add-write can never be interleaved. Four threads times 100,000 increments gives exactly 400000, every single run.

The determinism is the whole gain. Compare it against the previous lesson, where the identical loop without the lock produced a different number each time and no amount of rerunning made it trustworthy.

Protecting a shared balance

Two threads each deposit 1 into a shared balance 100,000 times.

import threading

balance = 0
lock = threading.Lock()

def deposit_many():
    global balance
    for _ in range(100_000):
        with lock:
            balance += 1

t1 = threading.Thread(target=deposit_many)
t2 = threading.Thread(target=deposit_many)
t1.start()
t2.start()
t1.join()
t2.join()
print(balance)

Output

200000

balance += 1 is indented under with lock:, which is what makes the increment atomic.

The lock must be the same lock object for both threads, and it is here because both use the global lock. Creating a lock inside deposit_many would give each thread its own token, and the code would look protected while providing no protection whatsoever.

The costs of locking

Locks are not free, and they introduce failure modes of their own.

Less parallelism. Threads queue at the lock, so the protected part runs one at a time. Keep critical sections small, because a lock held across a slow network call serializes the entire program.

Deadlock. Thread A holds lock 1 and waits for lock 2 while thread B holds lock 2 and waits for lock 1, and both wait forever. The standard defense is to always acquire multiple locks in the same fixed order, and the next lesson makes one happen live and then fixes it.

Forgetting the lock somewhere. One unprotected access reopens the race. The discipline is that every touch of the shared data goes through the same lock, with no exceptions for code that looks harmless.

CostMitigation
serialized critical sectionkeep it short
deadlockone global lock order
a forgotten accesswrap the data in one class that owns the lock

Databases, git, and the OS's own process table all rely on this exact idea with fancier machinery on top.

Two locks, two threads, opposite orders

Thread A holding the database lock and waiting for the log-file lock, while thread B holds the log-file lock and waits for the database lock, is a deadlock. The classic prevention is to always acquire locks in the same agreed order.

Each thread is waiting for the other, forever, and neither will release what it holds while it waits.

RuleEffect
any order alloweda circular wait can form
database always before logthe circle cannot form

If every thread must acquire database and then log in that order, the circular wait becomes structurally impossible. Lock ordering is also a favorite interview question, so it is worth being able to state in one sentence.