Low-level designCore8 min
Threads and shared state
When two threads read and write the same data, their steps can interleave and an update is lost. Code is thread-safe when it stays correct however the threads interleave: by not sharing, by not changing, or by letting only one thread at a time in.
Two turnstiles, one count
Each turnstile runs on its own thread and adds one to the shared Counter for every visitor.
1 · The situation
Two turnstiles, one count
A museum counts its visitors at two entrances. Each turnstile runs on its own thread, and both add one to a shared Counter for every person through. In a single thread, count++ looks like one step. To the machine it's three: read the count, add one, write it back.
Real threads interleave differently on every run, so a program that races can't print the same thing twice. The first version therefore replays one interleaving that real threads can produce: it splits each turnstile's increment into its read step and its write step, and runs the steps in a fixed order.
class Counter {
private int count;
int read() {
return count;
}
void write(int value) {
count = value;
}
}
class Turnstile {
private final String name;
private final Counter counter;
private int seen;
Turnstile(String name, Counter counter) {
this.name = name;
this.counter = counter;
}
void readStep() {
seen = counter.read();
System.out.println(name + " reads count = " + seen);
}
void writeStep() {
counter.write(seen + 1);
System.out.println(name + " writes count = " + (seen + 1));
}
}Two turnstile objects, one per thread, share one counter.
2 · The race
Two visitors, counted as one
Two people pass at the same moment, one at each gate. Both threads read the count before either writes:
Each thread's own steps are right. The interleaving loses North's update: South writes over it with a value computed from a stale read.
class Counter {
private int count;
int read() {
return count;
}
void write(int value) {
count = value;
}
}
// One turnstile's count++, split into the separate steps a thread really takes.
class Turnstile {
private final String name;
private final Counter counter;
private int seen;
Turnstile(String name, Counter counter) {
this.name = name;
this.counter = counter;
}
void readStep() {
seen = counter.read();
System.out.println(name + " reads count = " + seen);
}
void writeStep() {
counter.write(seen + 1);
System.out.println(name + " writes count = " + (seen + 1));
}
}
public class Main {
public static void main(String[] args) {
Counter counter = new Counter();
Turnstile north = new Turnstile("North", counter);
Turnstile south = new Turnstile("South", counter);
// Replays one order two real threads can run in: both read before either writes.
north.readStep();
south.readStep();
north.writeStep();
south.writeStep();
System.out.println("Visitors counted: " + counter.read() + " of 2");
}
}North reads count = 0 South reads count = 0 North writes count = 1 South writes count = 1 Visitors counted: 1 of 2
Two visitors, one count. With real threads this happens only sometimes, which is what makes it hard to find.
This is a race condition: the result depends on the timing of the threads. It's rare, it vanishes when you add logging or step through in a debugger, and it gets more likely under load, exactly when the numbers matter. Each language treats it differently:
Language | Two threads doing count++ with no protection |
|---|---|
Java | Lost updates; the Java Tutorials' thread-interference example splits |
C++ | A data race, and so undefined behavior: anything may happen, not just a lost update |
JavaScript | Not to ordinary variables, since workers share only a |
Python | Lost updates even with the global interpreter lock: |
3 · The fix
One thread at a time, or no sharing at all
From here the programs use real threads, two turnstiles each letting 100,000 visitors through, and print the total. Two fixes give the right answer on every run:
Counter.increment() holds a lock while it reads, adds and writes, so no other thread can come between the steps: synchronized in Java, a std::mutex in C++, a threading.Lock in Python. JavaScript's standard library has no lock; between workers the safe increment is an atomic add on shared memory, the subject of Atomics and memory visibility.
class Counter {
private int count;
synchronized void increment() {
count++;
}
synchronized int value() {
return count;
}
}Visitors counted: 200000 of 200000
Both fixes print this on every run, in every language.
4 · The idea
Shared, mutable, unprotected
A race needs three things at once: data that's shared between threads, mutable so at least one thread writes it, and unprotected so nothing orders the accesses. Code is thread-safe when it stays correct however its threads interleave, and each way of getting there removes one of the three:
Remove | How | Here |
|---|---|---|
Sharing | Confinement: each thread owns its data, and results are combined afterwards | Each turnstile counts its own visitors |
Mutation | Immutability: shared data never changes, so any thread may read it | The museum's opening hours, read by both gates |
Lack of protection | Synchronization: a lock or an atomic operation lets one thread at a time through a critical section |
|
A lock does two jobs. It makes the critical section atomic, so no thread sees it half done. And it makes changes visible: what one thread wrote before releasing the lock is seen by the next thread that takes it. Without that second guarantee a thread may keep reading a stale value; Atomics and memory visibility covers it.
5 · What it costs
Waiting at the lock
A lock makes threads take turns. When both turnstiles want to count at once, one waits: that's contention, and the more threads share one lock, the more of their time goes to waiting. The confined version does the same work with no waiting at all, because the threads never touch the same data until they're done.
Every increment in the locked version takes and releases the lock. Confinement shares nothing until the end.
Locks have other costs: code that takes two locks can deadlock, a slow critical section stalls every thread behind it, and a forgotten lock on one access brings the race back. Confinement isn't always possible, though: a ticket counter that must refuse the 501st ticket needs everyone to see one shared count while they run.
6 · Getting it right
Every access, the same lock
- Protect reads too.
value()takes the lock as well; a read without it may see a stale or half-written value. - One lock per piece of shared state. Every access to
countgoes through the same lock; two locks guarding the same field protect nothing. - Keep critical sections short. Do the slow work, such as printing or network calls, outside the lock.
- Say which lock guards what. A comment or a naming convention for every guarded field saves the next person from adding an unguarded access.
Language | Threads | The usual protection |
|---|---|---|
Java |
|
|
C++ |
|
|
JavaScript | Workers, sharing only |
|
Python |
|
|
7 · Look-alikes
Words that sound alike
Several terms get mixed up. What tells them apart:
Term | Meaning |
|---|---|
Race condition | The result depends on the timing of threads: a design-level bug |
Data race | Two unsynchronized accesses to one memory location, at least one a write; undefined behavior in C++ |
Atomicity | A group of steps happens as one, with nothing in between |
Visibility | A write by one thread is guaranteed to be seen by another |
Concurrency | Several tasks in progress at once, possibly taking turns on one core |
Parallelism | Several tasks running at the same instant on different cores |
A program can have a race condition without a data race: two threads that each take the lock to check "is a seat free?" and then, separately, take it again to book it can both book the same seat. Every access is locked; the check and the act aren't one step.
8 · In practice
Thread-safe containers and their limits
Languages offer containers that are safe for single operations. Java's Collections.synchronizedList() locks each call, yet its documentation warns that iterating requires holding the list's lock yourself. Python's FAQ lists L.append(x) among the operations that are atomic, and i = i + 1 and D[x] = D[x] + 1 among those that are not.
The lesson is the same as the turnstiles': a single safe operation is safe, but a sequence of them, such as read then write or check then act, needs its own protection. Concurrent collections goes further.
Check yourself
4 questions
Takeaways
Remember this
- count++ is a read, an add and a write; two threads can interleave them and lose an update.
- A race needs shared, mutable, unprotected data; remove any one and it's gone.
- Confinement (don't share), immutability (don't change) and synchronization (take turns) are the three fixes.
- A lock makes a critical section atomic and its writes visible; every access, reads included, must use the same lock.
- Locks cost waiting; prefer confinement when the threads can work apart and combine results at the end.