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.
A board with two fields
A dispatcher writes the platform and the time; screens read them. The two must always match.
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.
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.
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);
}
}Torn reads: 0 of 20000
Both locked versions print this on every run.
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 |
Reentrant lock | The owner may acquire it again, such as a locked method calling another | Java |
Read-write lock | Many readers together, or one writer alone | Java |
Condition | Wait, with the lock released, until another thread signals a change | Java |
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:
synchronizedblocks ortry/finallyin Java,std::scoped_lockin C++,within Python. An exception inside a critical section then can't leave the lock held. - Copy out, then release.
read()returns aDeparturesnapshot; 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 |
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
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.