Low-level designCore8 min

Locks and synchronization

A lock lets one thread at a time into the code that reads or changes shared data, so a group of related fields is never seen half updated. Variants trade simplicity for speed: reentrant locks, read-write locks for data read far more than written, and conditions for waiting until something changes.

Watch it evolve1 / 8
The problem

A board with two fields

A dispatcher writes the platform and the time; screens read them. The two must always match.

80
Start
Stage 1
Stage 2
Final

1 · The situation

A departure board with two fields

A station's DepartureBoard holds the next train: a platform and a departure minute. A dispatcher thread changes both when a new train is announced, and screen threads around the station read them to show passengers. In this example every train leaves at ten minutes past the hour times its platform number, so platform 3 leaves at 10:30 and platform 4 at 10:40, and a screen can tell when it shows a pair that never existed.

The first program replays one order real threads can run in: the dispatcher's two writes, with a screen's read between them.

Two fields, written one at a timeJava
class DepartureBoard {
    private int platform = 3;
    private int minute = 30;

    void writePlatform(int value) {
        platform = value;
    }

    void writeMinute(int value) {
        minute = value;
    }

    Departure read() {
        return new Departure(platform, minute);
    }
}
Dispatcher writes platform 4
Screen reads platform 4, 10:30
Dispatcher writes 10:40
Screen shows a real departure: no

2 · The race

A train that doesn't exist

The screen showed platform 4 at 10:30, a departure no one announced: the new platform with the old time. Each write on its own was fine, and making each one atomic wouldn't help. The rule that matters, "this platform goes with this time", spans two fields, and a reader must never see one changed without the other.

A torn read: the screen sees the board halfway through an update.

What's needed is a critical section: a stretch of code that only one thread may be in at a time, covering both writes in update() and both reads in read().

3 · The fix

One lock around the whole update

The next two programs use real threads: a dispatcher announcing 10,000 trains, and two screens reading the board 10,000 times each and counting the departures that don't exist.

update() and read() both run under one lock, so a reader sees the board either before an update or after it. Java uses synchronized, C++ a std::mutex with std::scoped_lock, and Python a threading.Lock. JavaScript has no lock in its standard library, so the version builds a small one from Atomics.compareExchange(), Atomics.wait() and Atomics.notify() on shared memory.

Java
class DepartureBoard {
    private int platform = 3;
    private int minute = 30;

    synchronized void update(int platform, int minute) {
        this.platform = platform;
        this.minute = minute;
    }

    synchronized Departure read() {
        return new Departure(platform, minute);
    }
}
«create»«record»Departure− platform: int− minute: int~ consistent(): booleanDepartureBoard− platform: int− minute: int~ update(int, int)~ read(): DepartureDispatcher− board: DepartureBoard+ run()Screen− board: DepartureBoard− torn: int+ run()~ torn(): int
Torn reads: 0 of 20000

Both locked versions print this on every run.

«create»«record»Departure− platform: int− minute: int~ consistent(): booleanDepartureBoard− platform: int− minute: int~ update(int, int)~ read(): DepartureDispatcher− board: DepartureBoard+ run()Screen− board: DepartureBoard− torn: int+ run()~ torn(): int

One board shared by a dispatcher thread and two screen threads; the lock lives inside the board.

4 · The idea

Mutual exclusion, and its variants

A lock, or mutex (for mutual exclusion), has an owner: one thread acquires it, and any other thread that tries waits until it's released. Code between acquiring and releasing is a critical section. Releasing also publishes the owner's writes to the next thread that acquires the lock, so the screens see the board's latest pair, not a stale copy.

Kind

What it allows

Where

Mutex

One thread inside at a time

Java synchronized, ReentrantLock; C++ std::mutex; Python threading.Lock

Reentrant lock

The owner may acquire it again, such as a locked method calling another

Java synchronized and ReentrantLock; C++ std::recursive_mutex; Python threading.RLock

Read-write lock

Many readers together, or one writer alone

Java ReentrantReadWriteLock; C++ std::shared_mutex

Condition

Wait, with the lock released, until another thread signals a change

Java wait() and notify(), Condition; C++ std::condition_variable; Python threading.Condition

Reentrancy matters more than it looks. In Java, a synchronized method can call another synchronized method on the same object. In C++, a thread that locks a std::mutex it already holds has undefined behavior, usually a hang.

A condition solves a different problem: waiting. A screen that should sleep until the next announcement holds the lock, checks whether there's a new train, and if not calls wait(), which releases the lock and sleeps in one step. The dispatcher updates the board and signals. The woken screen re-acquires the lock and checks again, in a loop, because a wait can also return without a signal. Producer-consumer uses this to hand work between threads.

5 · What it costs

Waiting, and the risks that come with holding

Locks are the simplest correct tool, and they cost:

  • Contention. Threads wait for each other. A slow operation inside the lock, such as formatting or writing to a network, makes every other thread wait for it.
  • Deadlock. Code that holds one lock while waiting for another can wait forever. Deadlock, livelock and starvation covers it.
  • Read-write locks aren't free. They cost more per acquisition than a plain mutex, so they pay off only when reads are frequent and long enough to overlap. Under a steady stream of readers, some implementations can also keep a writer waiting a long time.

Situation

Usually

Several fields that must change together

A mutex around every read and write of them

One counter or flag

An atomic instead of a lock

Data read constantly, written rarely, reads not tiny

A read-write lock, or publish a new immutable snapshot on each update

A thread must wait until something becomes true

A condition, with the check in a loop

The last option for the board is worth knowing: make Departure immutable and swap the whole object with one atomic reference write. Readers then need no lock at all, because a pair can't be seen half built.

6 · Getting it right

Always release, and hold briefly

  • Release on every path. Use the language's scoped form: synchronized blocks or try/finally in Java, std::scoped_lock in C++, with in Python. An exception inside a critical section then can't leave the lock held.
  • Copy out, then release. read() returns a Departure snapshot; the screen formats it after the lock is released.
  • Same lock for every access. A reader that skips the lock can still see a torn pair.
  • Wait in a loop. After a condition wakes you, check the condition again before acting on it.

A JavaScript lock built from Atomics.wait() blocks the thread, which is allowed in Node's workers and main thread but not on a browser's main thread. Most JavaScript code avoids locks altogether by not sharing memory: workers exchange messages instead.

7 · Look-alikes

Tools that coordinate threads

Each tool answers a different question.

Tool

The question it answers

Mutex

Who may be in this code right now? One thread

Read-write lock

Many readers, or one writer?

Semaphore

How many threads may be in at once? Up to a number of permits

Condition

How does a thread sleep until something changes?

Atomic

How is one value changed without a lock?

Monitor

An object whose methods are mutually exclusive and which offers wait and signal: Java objects with synchronized, wait() and notify()

8 · In practice

Read-mostly data in the libraries

Java's ReentrantReadWriteLock documentation shows a dictionary where get() takes the read lock and put() the write lock, and notes that a read-write lock pays off when reads greatly outnumber writes. C++'s std::shared_mutex is documented with a counter whose readers take a shared lock and whose writer takes an exclusive one.

Configuration, price lists, routing tables and departure boards all fit that shape: read on every request, changed now and then.

Check yourself

4 questions

1. Would making platform and minute each an atomic integer fix the torn read?
2. A synchronized method in Java calls another synchronized method on the same object. What happens?
3. When does a read-write lock beat a plain mutex?
4. A screen waits on a condition until a new train is announced. After it wakes, what should it do first?

Takeaways

Remember this

  • A lock makes a critical section run one thread at a time, and publishes its writes to the next thread that takes the lock.
  • A rule that spans several fields needs one lock around every read and write of them; atomics on each field don't help.
  • Reentrant locks may be re-acquired by their owner; a C++ std::mutex may not.
  • Read-write locks let readers share access; they pay off only for frequent, overlapping reads.
  • Conditions let a thread sleep until something changes; always wait in a loop that re-checks the condition.