Hardware bought correctness (TAS) and even fairness (tickets) โ but every design so far spins, and spinning has a worst case the OS must rescue us from.
28.12 Too Much Spinning: What Now?
Two threads, one CPU: the holder is preempted mid-critical-section; the other thread spins its entire time slice checking a flag that cannot change โ the holder isnโt running. With N threads, Nโ1 time slices can burn this way per incident.
The Crux: How To Avoid Spinning
How can we develop a lock that doesnโt needlessly waste time spinning on the CPU?Hardware support alone cannot solve this. Weโll need the OS.
28.13 A Simple Approach: Just Yield, Baby
First idea: when you would spin, yield yield A system call that does nothing but hand the CPU to the OS โ the cooperative approach's way to share. defined in ch. 6 โ open in glossary instead (Figure 28.8):
void lock() {
while (TestAndSet(&flag, 1) == 1)
yield(); // give up the CPU
}
yield() deschedules the caller โ running โ ready โ promoting
someone else. For two threads on one CPU it works nicely: the waiter
steps aside, the holder finishes. But scale to 100 contending
threads: one holder preempted, and the other 99 each run, find the
lock held, and yield โ 99 context switches per incident, cheaper
than 99 slices but far from free. Worse: starvation is untouched
โ a thread can be caught in an endless run-and-yield loop while
others slip through the lock repeatedly. Too much is still left to
chance.
28.14 Using Queues: Sleeping Instead Of Spinning
The real problem: the scheduler keeps choosing who tries next. To control succession explicitly we need a queue of waiters, and OS primitives to sleep and wake precisely: park() park Solaris's sleep-a-thread primitive (with unpark(tid) to wake one): the OS support that turns spinning locks into sleeping ones โ enqueue yourself, park; the releaser unparks the next waiter, passing the lock DIRECTLY (flag never resets in between). setpark() closes the wakeup/waiting race. defined in ch. 28 โ open in glossary (sleep the caller) and unpark(threadID) (wake that one), per Solaris. The lock (Figure 28.9):
typedef struct __lock_t {
int flag;
int guard;
queue_t *q;
} lock_t;
void lock(lock_t *m) {
while (TestAndSet(&m->guard, 1) == 1)
; // acquire guard lock by spinning
if (m->flag == 0) {
m->flag = 1; // lock is acquired
m->guard = 0;
} else {
queue_add(m->q, gettid());
m->guard = 0;
park();
}
}
void unlock(lock_t *m) {
while (TestAndSet(&m->guard, 1) == 1)
; // acquire guard lock by spinning
if (queue_empty(m->q))
m->flag = 0; // let go of lock; no one wants it
else
unpark(queue_remove(m->q)); // hold lock
// (for next thread!)
m->guard = 0;
}
Step through its three defining moments โ including the race that almost breaks it:
The guard is a tiny TAS spin lock protecting the lock's OWN bookkeeping โ held for a few instructions, never for the user's critical section.
Three subtleties, restated. The guard means spinning isnโt gone,
just bounded โ a few bookkeeping instructions, not a user-defined
critical section. The direct handoff (flag never returns to 0
between holder and successor) is a necessity: the woken thread canโt
safely re-acquire anything at its wake point. And the
wakeup/waiting race โ unparked before you park, then sleeping
forever โ is closed by setpark():
queue_add(m->q, gettid());
setpark(); // new code
m->guard = 0;
(An alternative passes the guard into the kernel, which can then atomically release it and dequeue the sleeper.)
Aside: Priority Inversion โ More Reason To Avoid Spinning
Beyond waste, spin locks can break CORRECTNESS on priority schedulers. T1 (low priority) holds a spin lock; T2 (high) wakes, preempts T1, and spins for the lock โ forever, since T1 can never run under a scheduler that always prefers T2. The system hangs. Nor does avoiding spin locks fully save you: with T3 > T2 > T1 and T1 holding a lock T3 wants, middle-priority T2 can run indefinitely while mighty T3 waits on lowly T1 โ priority inversion priority inversion A high-priority thread stuck behind a low-priority lock holder โ with spin locks it can hang the system outright, and with a middle-priority thread running, mighty T3 waits while lowly T2 rules (see: Mars Pathfinder). An intergalactic scourge. defined in ch. 28 โ open in glossary , an intergalactic scourge (it struck the Mars Pathfinder). Remedies: priority inheritance priority inheritance The inversion fix: temporarily boost the lock-holding low-priority thread to its waiter's priority so it can run, release, and restore order. defined in ch. 28 โ open in glossary (boost the holder to its waiterโs priority), avoiding spin locks, or the bluntest: give everything the same priority.Check yourself
1.Quantify the spinning disaster: N threads, one CPU, and the lock holder gets preempted mid-critical-section.
2.The 'just yield, baby' lock replaces spinning with yield(). What does yield do, and where does this design still bleed?
3.In the queue lock (Figure 28.9), what is the guard variable โ and doesn't it reintroduce spinning?
4.On wakeup, the flag is NOT set back to 0 and re-fought-over. Why is handing the lock directly to the unparked thread a necessity, not a nicety?
5.The wakeup/waiting race, and Solaris's fix: