Synchronization 3: Atomic Instructions, Monitors, Readers/Writers
Source: UC Berkeley CS162, Fall 2020/2021 - Prof. John Kubiatowicz, Lecture 8
The previous lecture built locks out of disabling interrupts and pointed at hardware atomic read-modify-write as the real answer. This lecture finishes that story - it uses atomic instructions to build locks that run at user level, on multiprocessors, and that sleep instead of spinning. Then it climbs one level higher to monitors (a lock plus condition variables), the pattern that makes complex synchronization tractable, and puts them to work on the classic readers/writers problem.
Why disabling interrupts is not enough
The in-kernel lock from Synchronization 2 works by disabling interrupts around a tiny critical section that inspects a lock value. It has two fatal limits:
- Kernel only. You cannot let user code disable interrupts - a single
while (true)with interrupts off would hang the machine. Wrapping it as a system call fixes safety but makes every lock operation pay the kernel-crossing tax. - Uniprocessor only. Disabling interrupts on one core says nothing about the others. On a multicore or multi-socket machine another core is still free to charge into the critical section.
The way out is a hardware instruction that reads a memory word and writes it back atomically, so no other thread can slip between the read and the write. The hardware is responsible for making it work, including the cache-coherence work needed on a multiprocessor - and unlike disabling interrupts, it works across all the cores.
Atomic read-modify-write instructions
Every one of these is a single instruction: everything shown between the braces happens all at once, indivisibly, in a way two threads cannot interleave. On many machines it is implemented by briefly locking the memory bus (or the cache line) for the duration of one load-store.

- test-and-set - reads the old value and unconditionally writes
1. The old value tells you whether you won. Start a word at0; if12,000threads all test-and-set it at once, exactly one reads back the0, the rest read1. - swap - a generalized test-and-set that exchanges a register with memory.
- compare-and-swap - only writes if memory still holds an expected value. This is the powerful one - it lets you say "change X to Y, but only if nobody changed X out from under me."
- load-linked / store-conditional - the RISC decomposition:
llloads,scstores back but fails if any other write touched the address in between, so you loop. Enough to construct the other three.
Lock-free structures with compare-and-swap
Compare-and-swap is strong enough that some shared structures need no lock at all. Consider pushing onto a singly linked list that thousands of threads share.

The loop reads the current root into r1, links the new object's next to it,
then tries to swing root to point at the new object - but only if root is
still r1. If a competing thread pushed first, root changed, the
compare-and-swap fails, and we loop and retry. Nobody's insertion is ever lost.
The retry loop looks like spinning, but it is not the pathological busy-wait we worry about. Under zero contention it makes exactly one pass - a load, a store, and one compare-and-swap - and returns. It only re-loops when another thread actually made progress, so the system as a whole is always moving forward. That is the defining property of a lock-free algorithm.
Building a lock with test-and-set: the spinlock
Now use test-and-set to build a real acquire/release. Start the lock at 0
(free):
If the lock is free, test&set reads 0 (so the while exits) and sets it to
1 - we hold it. If it is busy, test&set reads 1, writes 1 (no change),
and we keep spinning. release just stores 0, and the next thread to
test-and-set wins.
This is correct, needs no kernel crossing, and works on a multiprocessor. But it busy-waits, which is bad.
- A waiting thread burns its entire time slice - roughly
100 ms- spinning and doing no useful work, then hands the CPU to the next waiter, which spins for another100 ms, and so on. - Worse, the spinner steals cycles from the thread that actually holds the lock, delaying the very release it is waiting for.
- If a spinning thread has higher priority than the lock holder, it can spin forever while the holder never runs - a priority inversion (the bug behind the original Mars Pathfinder rover).
There is one honest fix for the cache traffic. Every test&set is a write, so
on a cache-coherent machine the lock's cache line ping-pongs between every
spinning core - ironically, all of them writing the same 1 over and over.
Spinning on a plain read first avoids that:
This test-and-test-and-set stops the ping-ponging, but it still busy-waits. When a critical section can be long (and once you have monitors, it can be arbitrarily long), what you really want is to sleep.
A lock that sleeps, at user level
Apply the same trick the kernel lock used with interrupts: do not make the atomic operation be the lock, use it to protect the few instructions that implement the lock. Introduce a global guard variable; spin on the guard only for the handful of instructions that inspect the real lock, then either take it or go to sleep.

The guard is held for only a few instructions, so the spin is negligibly short.
The expensive part - actually sleeping - happens by parking the thread on a wait
queue and yielding. Note the subtlety, identical to the interrupt version: you
must reset guard to 0 as you go to sleep, otherwise you would sleep holding
the guard and no one could ever release the lock.
- User level in the common case -
test&setruns like any arithmetic instruction; no system call is needed to grab an uncontended lock. - Sleeps under contention - a thread that cannot get the lock is put on a wait queue, not left spinning. Going to sleep does require the kernel, but if you are about to sleep anyway, that crossing is cheap by comparison.
- One wait queue per lock - so
releasealways knows exactly whom to wake.
Futex: fast user-space mutex
The one piece still missing is a clean interface for "put me to sleep on this memory word." Linux provides it as the futex (fast user-space mutex) system call:
FUTEX_WAIT says "put me to sleep, but only if *uaddr still equals val";
FUTEX_WAKE wakes up to val sleepers. The conditional check closes a race: if
the lock is released in the window between our test-and-set and our call into the
kernel, FUTEX_WAIT returns immediately instead of sleeping on a wakeup that
already happened. A first cut:
This never busy-waits, and acquire can be as fast as a single atomic
instruction. Its flaw is that every release makes a system call, even when
nobody is waiting. A second cut adds a maybe_waiters flag so release only
enters the kernel when a waiter might exist - but the clean solution is to give
the lock three states and drive it entirely with atomics.

From semaphores to monitors
Semaphores solved the bounded buffer, but a semaphore is doing two jobs at
once - mutual exclusion (the mutex) and a scheduling constraint (the
full/empty counts). That dual purpose is exactly why the solution is fragile:
swap the order of two P operations and it deadlocks, and it is hard to look at
the code and prove it is correct.
A monitor separates the two concerns:
- a lock for mutual exclusion, and
- one or more condition variables for the scheduling constraints.
A monitor is really a programming pattern, not just a data type. Some languages (Java) provide it natively; in C you get condition variables from the pthreads library. Once the pattern clicks, surprisingly intricate synchronization becomes easy.
Condition variables
A condition variable is a queue a thread can sleep on when the conditions are not right to proceed - and, strangely, it is used while holding the lock. It has three operations:
Wait(&cv, &lock)- atomically release the lock and go to sleep; re-acquire the lock before returning.Signal(&cv)- wake one waiter, if any.Broadcast(&cv)- wake all waiters.
Every prior lecture said sleeping while holding a lock deadlocks the system.
Condition variables are the deliberate exception: Wait releases the lock under
the covers as it puts you to sleep, and re-acquires it before it returns. The
rule that makes it all work is simple - you must hold the lock when performing
any condition-variable operation. Program as if you hold the lock continuously
from acquire to release, even across a Wait; the release-and-reacquire is
invisible to you.
A synchronized (infinite) buffer
Start with half of the bounded-buffer problem: an infinite queue where a consumer must block when it is empty (no full check needed).

The consumer's pattern is the heart of every monitor: grab the lock, check a
condition in a while loop, sleep if it is not satisfied, and re-check on
wakeup - always holding the lock.
Mesa vs. Hoare: why while, not if
The while loop around cond_wait is not optional, and the reason comes down to
what exactly happens when a thread is signaled.

Under Hoare semantics (named after Tony Hoare), signal immediately hands
the lock and the CPU to the woken waiter, which runs right away. Nothing can
change between the signal and the waiter running, so an if would suffice. It is
clean to reason about but ugly to implement and bad for the cache - the signaler
is forced to give up the CPU and all its cached state mid-stream.
Under Mesa semantics (named after the Xerox PARC Mesa OS), signal just
moves the waiter to the ready queue and the signaler keeps running with the
lock. The waiter runs "sometime later," and in that gap another thread can sneak
in and invalidate the condition.

Because a Mesa signal only makes the waiter runnable, another thread can
acquire the lock and consume the resource before the woken thread ever runs. If
you guarded the wait with an if, the thread would fall straight through to
dequeue on an empty queue. Guarding it with a while forces it to re-check the
condition after every wakeup and go back to sleep if it is still false. On a
Mesa-semantics system - which is all of them in practice, including pthreads and
Java - always use while.
| Hoare monitors | Mesa monitors | |
|---|---|---|
| On signal | Lock and CPU handed straight to the waiter | Waiter moved to the ready queue; signaler keeps running |
| Waiter runs | Immediately | Sometime later, after re-scheduling |
| Guard with | if is sufficient | while is mandatory (re-check on wakeup) |
| Cost | Extra context switches; bad for cache locality | No forced switch; signaler keeps its cache state |
| Used by | Mostly of theoretical interest | Essentially every real OS, pthreads, Java |
The bounded buffer with monitors
With monitors the full bounded (circular) buffer is just the infinite version with a second condition variable and a full check. One lock, two condition variables - one for "buffer full," one for "buffer empty":

Because every check, wait, and signal runs while holding the lock, you never have to reason about anything changing between the check and the action - the thing that made the semaphore solution so easy to get wrong.
The readers/writers problem
The payoff example. A shared database has two classes of users: readers, which never modify it, and writers, which do. Many readers can safely read at once, but a writer needs exclusive access - no other writers and no readers.

A single lock around the database is too coarse: it would serialize the readers, throwing away the concurrency we care about. Instead we use a monitor whose lock guards a little bit of bookkeeping, with two condition variables. The correctness constraints:
- Readers may access the database when there are no writers.
- A writer may access it when there are no active readers or writers.
- Only one thread manipulates the shared state at a time.
The lock protects four counters and gates two condition variables:
AR- number of active readers (currently reading); starts at0.WR- number of waiting readers (blocked); starts at0.AW- number of active writers (at most1); starts at0.WW- number of waiting writers; starts at0.okToRead,okToWrite- the two condition variables to sleep on.
Code for a reader

Notice the reader releases the lock before AccessDatabase and re-acquires it
afterward. The lock is not protecting the database - it is protecting the
counters that decide who may enter. This is "meta-locking": the entry code checks
whether your constraints are satisfied and marks you active, then hands the actual
database access off with the lock released, which is precisely what lets multiple
readers be in the database at the same time. A reader waits while AW + WW > 0
(writers are also prioritized: a waiting writer blocks new readers so it does
not starve).
Code for a writer

Suppose you broadcast to many waiting writers by accident. They all wake, but each
must re-acquire the lock before returning from cond_wait - so they emerge one
at a time. The first decrements WW, sees no active readers or writers,
increments AW, and enters. Every other woken writer then re-checks its while
condition, finds AW > 0, and goes right back to sleep. There is no interleaving
inside the monitor because the lock serializes the check-and-update, and the
while loop catches the stale wakeups. This is exactly why Mesa monitors are
robust: an over-eager wake never breaks correctness, it only costs a little work.
The lecture stops here, before walking a full simulation of the state variables - that comes at the start of the next lecture.
Recap
- Disabling interrupts builds locks only in the kernel and only on a uniprocessor. Hardware atomic read-modify-write (test-and-set, swap, compare-and-swap, load-linked/store-conditional) reads and writes a word indivisibly and works at user level and across cores.
- Compare-and-swap is strong enough for lock-free structures: a linked list push that retries only when a competitor actually made progress.
- A
test&setspinlock is correct but busy-waits, wasting cycles and risking priority inversion. Test-and-test-and-set fixes the cache ping-pong but still spins. Using an atomic to protect a guard variable lets the lock sleep instead, and the futex gives a three-state lock whose uncontended path never enters the kernel. - A monitor is a lock plus one or more condition variables, separating
mutual exclusion from scheduling. You use
Wait/Signal/Broadcastwhile holding the lock;Waitreleases and re-acquires the lock under the covers. - Under Mesa semantics (which real systems use) a signal only makes a waiter
runnable, so you must re-check the condition with a
whileloop, never anif. - The readers/writers monitor guards four counters (
AR,WR,AW,WW) and two condition variables, releasing the lock before the database access so many readers run concurrently. Writers get priority; a finishing writer signals one writer but broadcasts to all readers.