Critical Sections & Shared State
A critical section is the part of code that touches shared mutable state and must run without interleaving from other threads. Identify it first, then pick a primitive (mutex, atomic, channel) to protect it. Most concurrency bugs come from missing or oversized critical sections.
What it is
A critical section is a stretch of code that touches data shared with other threads, where the code only behaves correctly if no other thread touches that data in the middle.
It's not a syntactic feature. It's a property of how the code uses data. The same lines can be safe in one program (because nothing else touches the variable) and racy in another (two threads call the same function).
A picture of the bug
The function logically does three things in one shot:
read counter
add 1
store counter
Now run that function on two threads at the same time. Counter starts at 5. The OS interleaves the operations:
The read, the add, and the store have to happen as one indivisible unit. Those three operations together are the critical section. Otherwise another thread can sneak in between any two and corrupt the result.
How to spot one
Walk the function. For each read or write of a field, ask two questions:
- Can another thread touch this field while this code runs? → shared state?
- Does the code rely on the value not changing between two reads, or on a write being visible before the next op? → critical section?
The classic shape is read-modify-write: read a counter, change it, store it back. The read and the write together form the section. Protecting just one of them is not enough.
How big should it be
As small as possible while preserving the invariant. Two failure modes show up in real code.
The first is undersized: the lock covers the write but not the read that depends on it. Two threads check the same balance and both proceed to spend it. Classic over-withdraw.
The second is oversized: the lock covers a network call, an expensive computation, or a logging line. Now every other thread waits while unrelated work runs. Throughput collapses.
How to remove the section entirely
The strongest fix is not a better lock. It is removing the shared mutable state.
Three ways to remove sharing
- Make the data immutable. Two threads cannot race on something neither can write.
- Confine the data to one thread. Other threads send messages and let that thread mutate.
- Use per-thread copies and merge at the end (the LongAdder pattern, the per-worker map in MapReduce).
Once sharing stops, primitives are no longer needed. That is why Go's "share by communicating" line is more than aesthetics. It removes the critical section instead of guarding it.
When locking is necessary
Sometimes shared mutable state is genuinely required. A cache. An accounts table. A request counter. Then pick the lightest primitive that preserves the invariant: an atomic for one variable, a lock for several, a queue if the work is naturally a stream of events.
The skill is not knowing every primitive. It is identifying the invariant, drawing the smallest box around the code that maintains it, and then picking the cheapest tool to enclose that box.
Implementations
The whole transfer body looks like one operation, but only the two balance updates need to be atomic together. Pulling the validation out of the lock keeps the section short and avoids holding the lock across logging or external calls.
1 class Account {
2 private final ReentrantLock lock = new ReentrantLock();
3 private long balance;
4
5 public void transfer(Account to, long amount) {
6 if (amount <= 0) throw new IllegalArgumentException(); // outside lock
7
8 lock.lock();
9 to.lock.lock();
10 try {
11 if (this.balance < amount) throw new IllegalStateException("insufficient");
12 this.balance -= amount;
13 to.balance += amount;
14 } finally {
15 to.lock.unlock();
16 lock.unlock();
17 }
18
19 log.info("transferred {} from {} to {}", amount, this, to); // outside lock
20 }
21 }Key points
- •Shared mutable state is the source of every race. Remove sharing or remove mutation, and the problem disappears.
- •A critical section is any code path that reads-then-writes (or writes-then-reads) shared state and depends on the data not changing in between.
- •Two failures to avoid: forgetting to protect a critical section, and protecting too much (locking around I/O).
- •The right size of a critical section is the smallest set of statements that preserve the invariant in question.