Tour 15 · Concurrency primitives · about 34 minutes · 18 steps
Two harts want the same page of memory at the same moment. There is one list of free pages
in the whole machine, kmem.freelist, and if both harts pop it at once they can
both walk away with the same page. Two processes would then scribble over each other’s
memory, and nothing would crash until much later, somewhere unrelated.
This tour follows one call to acquire(&kmem.lock) on hart 1 while hart 2 wants the same
lock, down to the individual instructions the compiler produced (quoted from
kernel/kernel.asm of this build). You will see interrupts switched off with one
csrrci, a nesting counter that remembers whether to switch them back on,
the amoswap.w.aq that decides who wins, the spin of the loser, and the
fence rw,w; sw zero that hands the lock over.
Every other synchronization mechanism in xv6 (sleep-locks, sleep and wakeup, the context switch) is built on these two functions. If you understand why each instruction in them is there, the rest of the concurrency tours (Tour 16: sleep and wakeup, and the lost-wakeup problem to Tour 19: Memory ordering across harts) are variations on a theme.
Best after: 5. Life of a system call, 11. From a timer tick to a context switch
The machine has three harts. You pasted two lines at once, forktest & and
echo hi, so forktest (pid 4) is still forking child after child in the background
when the shell starts on echo hi. (If the process table were completely full at that
instant, the shell’s own fork would fail.) When the tour starts:
| Hart | What it is doing |
|---|---|
| 0 | Running whatever else is runnable, or idle in its scheduler |
| 1 | The shell’s new child is parsing echo hi. Its first malloc needs heap, so it is in sbrk: the thread this tour follows |
| 2 | forktest is inside fork, setting up a new child: it will need a page too |
Neither hart holds any spinlock when the tour begins, except that hart 2 is about to take
one (the new child’s p->lock) before it asks for memory.
Step 1 of 18
The shell forks a child to parse and run each command line (user/sh.c:172).
Parsing builds a tree of command structs with malloc, and the child’s first
malloc finds no free memory, so morecore asks the kernel for at least 4096
Headers: sbrk(65536), 64 KiB (user/umalloc.c:54). User sbrk is the eager kind
(user/ulib.c:155), so sys_sbrk calls growproc, which calls uvmalloc.
uvmalloc loops over the new range one page at a time, and every iteration starts
with kalloc: 16 pages, 16 calls. (Mapping them can call kalloc a few more times
inside walk, for page-table pages.)
This thread holds no locks, and interrupts are on: usertrap turned them on before
dispatching the system call. Keep those two facts in mind; the first thing
acquire does is change the second one.
Step 2 of 18
Free physical pages are kept as a linked list threaded through the pages themselves
(free-list allocator). kmem pairs the list head with the lock that protects it.
In this build kmem sits at 0x8000f980 (kernel/kernel.sym): the lock’s locked
word is at 0x8000f980 and freelist at 0x8000f998 (kmem+0x18).
kalloc pops the head: r = kmem.freelist; kmem.freelist = r->next. Compiled, that
is a load from kmem+0x18, a load of r->next, and a store back to kmem+0x18
(0x80000b28, 0x80000b2e, 0x80000b34). Three instructions, and another hart can
run its own three in between. Without a lock:
| Time | Hart 1 (sh’s child) | Hart 2 (forktest) |
|---|---|---|
| t1 | r = kmem.freelist → page A |
|
| t2 | r = kmem.freelist → page A |
|
| t3 | kmem.freelist = A->next (B) |
|
| t4 | kmem.freelist = A->next (B) |
|
| t5 | maps A into the shell child | uses A as the new trapframe |
Both harts got page A. The program’s heap and a process’s saved registers now share memory. This is a textbook race condition: it needs exact timing, so it might happen once a week, and the crash comes long after the cause.
Step 3 of 18
A spinlock is a uint: 0 means free, 1 means held. The other two fields are for
debugging and for holding: name (here "kmem", set by initlock in kinit)
and cpu, the struct cpu of the holder.
The compiled code confirms the layout: acquire swaps into offset 0 of the lock
(amoswap.w.aq a5,a5,(s1)), and holding reads cpu with ld a5,16(a0). Offset 8
is name; the 4 bytes after locked are padding so that the pointer is 8-byte
aligned.
All the cleverness of a spinlock is in how locked is read and written: not with
ordinary C assignments, but with instructions that are indivisible and that order the
hart’s other memory accesses around them. The rest of this tour is about those
instructions.
Step 4 of 18
acquire has three phases: interrupts off (push_off), a self-check
(holding), and the spin on the atomic swap. The order matters. Interrupts go off
before the swap, because if they went off after it there would be a window, one or
two instructions wide, in which this hart held the lock with interrupts still on.
The compiled prologue (0x80000be8) saves lk in the callee-saved register s1, so
every later step can find the lock: mv s1,a0, then jal push_off.
Why interrupts at all? The comment says “to avoid deadlock”, and step 7 shows the
deadlock. There is a second reason in push_off's own comment: mycpu reads the
hart number from tp, and a timer interrupt could move this thread to another hart
between reading tp and using the answer. With interrupts off, this thread cannot be
moved, so “this hart” stays meaningful until the matching release.
Step 5 of 18
rc_sstatus compiles to a single instruction (0x80000bb8):
csrrci a5,sstatus,2 # a5 = old sstatus; clear bit 1 (SIE)
csrrci reads the old value of sstatus and clears
the SIE bit in one step, so no interrupt can slip in between “was it on?” and “turn
it off”. old is that bit (srli a5,s1,1; andi a5,a5,1).
Then the bookkeeping, in this hart’s struct cpu. cpus is at
0x8000f9d0 and each entry is 128 bytes (slli a5,a5,0x7 in mycpu), so hart 1’s
is at 0x8000fa50; noff is at offset 120 and intena at 124 (lw a5,120(a0),
sw a5,124(a0)).
noff was 0: this is the outermost lock, so intena = 1 records “interrupts were on
before”.noff becomes 1.Only the pop_off that brings noff back to 0 may turn interrupts on again, and
only if intena says so (interrupts and spinlocks (push_off / pop_off), Locks and interrupt state).
new child's p->lockStep 6 of 18
On hart 2, allocproc scanned the process table taking each p->lock in turn, found
an UNUSED slot and kept that slot’s lock: from line 117 on, it holds the new child’s
p->lock. Its push_off found noff == 0, saved intena = 1 and set noff = 1.
Interrupts on hart 2 are off.
Line 125 already nested one lock deeper and came back: allocpid took pid_lock
(noff 1 → 2) and released it (noff 2 → 1), and interrupts stayed off, because
noff did not reach 0.
Now line 129 calls kalloc for the trapframe, so hart 2 also heads for
acquire(&kmem.lock). Its push_off reads the old SIE: 0, interrupts are already
off. Because noff is 1, not 0, it leaves intena alone (still 1, from the first
lock) and only increments noff to 2.
This is why push_off keeps a counter instead of a flag. A plain “off on acquire, on
on release” would turn interrupts back on at the inner release, while the new child’s
p->lock is still held (Locks and interrupt state).
stack0hart 0’s slice of stack0, with a kernelvec frame on topStep 7 of 18
kmem.lock is never taken by an interrupt handler, but some spinlocks are, and
acquire treats every lock the same way. Here is one: the disk driver’s
disk.vdisk_lock, taken by virtio_disk_rw in process context and by
virtio_disk_intr in the disk’s interrupt handler.
In this step the disk interrupt is being handled on hart 0, and line 303 wants
vdisk_lock. Hart 0 was idle, so the interrupt was taken in its scheduler’s interrupt
window and kernelvec pushed its frame onto hart 0’s scheduler stack. If a process on hart 1 holds it, hart 0 spins until hart 1 releases it. A
short wait; harmless.
Now imagine acquire did not turn interrupts off:
| Time | Hart 1 | Hart 0 |
|---|---|---|
| t1 | virtio_disk_rw: acquire(&disk.vdisk_lock) |
|
| t2 | disk interrupt arrives on hart 1 | |
| t3 | virtio_disk_intr: acquire(&disk.vdisk_lock) |
|
| t4 | waits for the lock holder… which is itself, frozen at t2 | |
| t5 | later wants vdisk_lock and spins too |
The stack shows it plainly. At t2 kernelvec pushes its frame onto the stack hart 1
is already using, the process’s kernel stack, directly on top of the frames of the
virtio_disk_rw that holds the lock (… bread, virtio_disk_rw, kernelvec, kerneltrap, devintr, virtio_disk_intr, acquire). The holder is buried underneath the
waiter on one stack, and only the waiter’s return could uncover it.
The holder cannot run until the handler returns, and the handler cannot return until
the holder releases. In xv6, holding would usually catch this and panic with
acquire. But if the interrupt arrived in the few instructions between the swap and
lk->cpu = mycpu(), lk->cpu would still be 0 and hart 1 would spin forever, as it
would in a kernel without the check, and every hart that later wanted the disk would
join it. With interrupts off, the interrupt stays pending on
hart 1 (or a hart with interrupts on claims it) and is taken after the release.
That is the invariant: whenever noff > 0, SIE is 0, so a handler never
interrupts a critical section on its own hart (Locks and interrupt state). Hart
0’s handler itself got here at noff 0, and its push_off records intena = 0,
because the trap had already turned interrupts off.
Step 8 of 18
Back on hart 1, acquire calls holding: “does this hart already hold lk?” If
it did, the spin below could never end, because the only hart that could release the
lock is the one spinning. Panicking with acquire turns a silent freeze into a
message.
The compiled code is short-circuited (0x80000b82): lw a5,0(a0), and if locked is
0, return 0 at once. Only if the lock is held does it load lk->cpu (ld a5,16(a0))
and compare it with mycpu.
These are plain loads, not atomics, and lk->cpu can be changing on another hart. Why
is the answer still right? Only this hart ever writes this hart’s struct cpu
pointer into lk->cpu, and it clears it again in release before freeing the lock.
With interrupts off, nothing on this hart can run between those points. So
“lk->cpu equals me” is true exactly when this hart holds the lock; another hart’s
writes can only ever put its own pointer there. The comment “Interrupts must be off”
on line 79 is the condition that makes this reasoning work (Locks and interrupt state).
Step 9 of 18
The loop compiles to four instructions (0x80000c02–0x80000c0a), exactly as the
comment predicts, with a4 = 1 loaded once before it:
mv a5,a4 # a5 = 1
amoswap.w.aq a5,a5,(s1) # a5 = lk->locked; lk->locked = 1, indivisibly
sext.w a5,a5
bnez a5,<mv> # old value was 1: someone holds it, try again
amoswap.w is an atomic read-modify-write from the RISC-V “A” extension. No other hart’s access to that word can come between its load and its store. When two harts swap the same word “at the same time”, the memory system puts the two swaps in some order:
| Time | Hart 1 | Hart 2 |
|---|---|---|
| t1 | amoswap: issued |
amoswap: issued |
| t2 | gets 1: hart 2 was first | gets 0: lock was free, now 1 |
| t3 | bnez: loop |
bnez falls through: hart 2 holds kmem.lock |
In this run hart 2 wins. Which hart wins is not decided by xv6 and is not fair: a hart that just released the lock can win it straight back. Writing 1 over 1 at t2 changed nothing, so losing costs nothing but time.
new child's p->lockkmem.lockStep 10 of 18
Hart 2 is inside the critical section. It records itself in kmem.lock.cpu
(kernel/spinlock.c:41) and pops page A off the list: r = A,
kmem.freelist = A->next. Now no other hart can reach these lines until hart 2
releases the lock, so the t1–t4 interleaving from step 2 is impossible.
The critical section is three instructions of real work. That is deliberate: the
memset that fills the page with junk (line 80) touches 4096 bytes, and it happens
after the release, because page A is already private to hart 2 and needs no
protection. Hold a spinlock for as short a time as you can: every hart that wants it
is wasting its time meanwhile.
new child's p->lockkmem.lockStep 11 of 18
release checks that hart 2 really holds the lock, clears lk->cpu (before the
lock becomes free, so it never wipes out the next owner’s record), and frees it. The
build matches the comment exactly (0x80000c82–0x80000c8a):
sd zero,16(s1) # lk->cpu = 0
fence rw,w # all earlier loads and stores before any later store
sw zero,0(s1) # lk->locked = 0
The fence is the point of __ATOMIC_RELEASE. RISC-V’s memory model
(RVWMO) lets a hart make its stores visible to other harts in a different order than
the program wrote them, when they go to different addresses. Without the fence:
| Time | Hart 2 | Hart 1 |
|---|---|---|
| t1 | store kmem.freelist = B, not yet visible |
spinning |
| t2 | store locked = 0 becomes visible first |
swap gets 0: lock acquired |
| t3 | reads kmem.freelist: still A |
|
| t4 | freelist = B becomes visible |
hart 1 hands out page A again |
fence rw,w forbids t2 before t1: every load and store of the critical section is
ordered before the store that frees the lock. The loads matter too: a load from inside
the critical section must not be satisfied late, after the next holder has changed the
data. The compiler is bound by the same rule: it may not move memory accesses of the
critical section below the atomic store.
new child's p->lockStep 12 of 18
pop_off first checks two invariants: interrupts must still be off (otherwise
someone turned them on inside a critical section, a bug), and noff must be at least
1 (more pops than pushes, also a bug).
Then it decrements noff, from 2 to 1 on hart 2. noff is not 0, so the
csrsi sstatus,2 at 0x80000c4c is skipped: hart 2 still holds the new child’s
p->lock, and interrupts must stay off until that one is released too
(Locks and interrupt state).
kalloc returns page A to allocproc, which fills in the trapframe pointer and
continues building the child with its p->lock held.
kmem.lockStep 13 of 18
Hart 1’s swap returned 0. It now holds kmem.lock and records itself:
lk->cpu = mycpu() (jal mycpu; sd a0,16(s1)).
The .aq (“acquire”) bit on the swap is the mirror image of the release fence. It
guarantees that no later load or store of hart 1 can be seen to happen before the
swap. Without it, the hart could perform the critical section’s first load early:
| Time | Hart 1 | Hart 2 |
|---|---|---|
| t1 | load of kmem.freelist performed early: A |
still in its critical section |
| t2 | freelist = B; fence rw,w; locked = 0 |
|
| t3 | swap gets 0: “acquired” | |
| t4 | uses the stale A it loaded at t1 |
Together: .rl (or a fence rw,w before the store, which is what GCC emits for
release) means “everything before me stays before me”; .aq means “everything after
me stays after me”. A critical section is boxed in on both sides. An AMO with both
bits set (.aqrl) would be a two-way barrier; xv6’s acquire needs only one.
kmem.lockStep 14 of 18
Hart 1 reads kmem.freelist. Thanks to hart 2’s fence rw,w and hart 1’s .aq, it
is guaranteed to see B, the value hart 2 stored, not the stale A. It pops B and
sets the head to B->next. If the list were empty, r would be 0 and kalloc would
return 0; uvmalloc would undo its partial work and sbrk would fail.
Look at what made this correct. Not one property, but three working together:
Then line 77: release again, the same fence rw,w; sw zero as in step 11.
Step 15 of 18
In release, hart 1’s pop_off brings noff from 1 to 0. intena is 1, saved by
the push_off in step 5, so it runs csrsi sstatus,2 (0x80000c4c): interrupts are
on again. A timer interrupt that became pending while hart 1 spun is taken now, and
this thread may be switched out right here. That is fine: it holds no locks.
memset then fills page B with the byte 5. That is 4096 stores, outside the lock:
the page belongs to hart 1’s caller alone now. Junk instead of zeros catches code that
forgets to initialize what it allocated; uvmalloc zeroes the page itself right
after.
Count what one uncontended acquire + release costs on the outermost lock: one
csrrci, one amoswap.w.aq, one fence rw,w, one sw, one csrr (the
intr_get check in pop_off, 0x80000c34), one csrsi, and six calls
to mycpu (three in push_off, one in acquire, one in release’s holding, one
in pop_off). Contended, add one swap per spin.
new child's p->lockStep 16 of 18
allocproc returned to kfork still holding the new child’s p->lock. kfork
copies memory, registers and open files, then line 294 releases it. That
pop_off takes noff from 1 to 0, finds intena == 1 (saved way back when
allocproc took the lock) and turns interrupts on.
So interrupts were off on hart 2 across many nested acquire/release pairs (pid_lock,
kmem.lock again and again, ftable.lock in filedup, itable.lock in idup),
never more than two deep, and a whole memory copy, and came back on exactly once, at
the outermost release. This is what
the counter buys.
Notice too that kfork releases the child’s lock before taking wait_lock on line
296, and then takes the child’s lock again on line 300. That is not carelessness: xv6
requires wait_lock to be taken before any p->lock, and Tour 18: Lock ordering: how xv6 avoids deadlock shows the
deadlock that rule prevents.
kernelvec frame sits in the middle, between uvmalloc and kerneltrapsh child's p->lockStep 17 of 18
A timer interrupt finally arrives on hart 1 while the shell’s child is between two
kalloc calls, holding no locks. kerneltrap calls yield, which takes the
child’s own p->lock and calls sched.
The interrupt came from kernel code, so there was no stack switch: kernelvec pushed
its 256-byte register frame onto the child’s kernel stack, right below uvmalloc. When
swtch saves the child, that frame is frozen in the middle of the stack:
sh child's kernel stack
top ─► usertrap
syscall
sys_sbrk
growproc
uvmalloc
kernelvec registers of the interrupted uvmalloc
kerneltrap
yield
sched ◄─ saved sp
Whichever hart resumes the child will unwind through it: sched returns, yield
releases the lock, kerneltrap returns, and kernelvec restores the registers and
srets back into uvmalloc. kernelvec deliberately does not restore tp
(kernel/kernelvec.S:44), because that hart may not be hart 1.
sched checks the spinlock rules explicitly: the caller must hold p->lock, and
noff must be exactly 1, meaning no other spinlock is held. This is the check
that would have caught a switch inside kmem.lock’s critical section.
p->lock is special: it is acquired by this thread and released by a different
thread, the scheduler, on the other side of swtch. Since intena describes this
thread rather than this hart, sched saves it in a local variable across the switch
and puts it back afterwards. The thread may resume on any hart, hart 0 or 2 as easily as hart 1, and it must
bring its own “were interrupts on?” with it. Tour 13: swtch and the lock handed across a context switch follows that handoff.
Here that value is 0, not 1: yield’s acquire ran inside kerneltrap, where
the trap had already turned interrupts off. When the child resumes, yield’s
release leaves them off, and kernelvec's sret turns them back on for
uvmalloc from the SPIE bit the trap saved (Locks and interrupt state).
kmem.lockStep 18 of 18
Step back and look at acquire as a whole (the state shown is at line 41, the moment
the lock is fully taken). Each line exists because of a specific
failure:
| Line | Without it |
|---|---|
push_off() |
an interrupt handler on the same hart spins on a lock its own hart holds (step 7); or the holder is preempted and other harts spin |
holding() check |
a hart re-acquiring its own lock freezes silently |
amoswap |
two harts both see 0 and both enter (step 9) |
.aq |
the critical section’s loads run before the lock is held (step 13) |
lk->cpu |
holding cannot tell whose lock it is |
and release mirrors it: clear cpu, fence rw,w, one store, pop_off.
A spinlock is cheap when uncontended and wasteful when contended: the waiter burns its hart with interrupts off. So xv6 uses spinlocks only for short critical sections and never waits for anything slow while holding one. For waits that can take milliseconds, such as a disk read, it builds sleep-locks on top of spinlocks (Tour 17: Sleep-locks); for “wait until something happens”, it uses sleep and wakeup (Tour 16: sleep and wakeup, and the lost-wakeup problem). Both are made of the instructions you have just seen.
Tour 15 · wrap-up
| Lock | Taken in | Protects |
|---|---|---|
kmem.lock (spinlock) | kalloc, kfree | The free-page list kmem.freelist, shared by all harts |
new child's p->lock (spinlock) | allocproc (taken), kfork (released at line 294) | The new process’s state and fields while it is being built; nested outside kmem.lock |
pid_lock (spinlock) | allocpid | nextpid; taken and released inside allocproc, nested inside p->lock |
ftable.lock (spinlock) | filedup (kernel/file.c:50), called by kfork line 287 | Open-file reference counts; nested inside the new child’s p->lock |
itable.lock (spinlock) | idup (kernel/fs.c:286), called by kfork line 288 | Inode-table reference counts; nested inside the new child’s p->lock |
wait_lock (spinlock) | kfork (kernel/proc.c:296) | np->parent; taken only after the child’s p->lock is released (step 16, Tour 18: Lock ordering: how xv6 avoids deadlock) |
tickslock, cons.lock (spinlocks) | sys_pause / clockintr, consoleread / consoleintr | Further examples of locks shared with interrupt handlers (step 7) |
disk.vdisk_lock (spinlock) | virtio_disk_rw, virtio_disk_intr | The virtio rings and descriptors; the example of a lock shared with an interrupt handler |
sh child's p->lock (spinlock) | yield → sched, released by the scheduler | p->state; the only spinlock that may be held across swtch |
cpus[].noff / intena (no lock: per-hart, interrupts off) | push_off, pop_off | Each hart touches only its own entry, so no lock is needed |
Why does acquire turn interrupts off before the amoswap, not after it?
If the swap came first, there would be a window in which this hart holds the lock with interrupts still on. An interrupt in that window whose handler wants the same lock would spin on a lock held by the code it interrupted.
Hart 2 holds a p->lock and then takes kmem.lock. When it releases kmem.lock, why do interrupts stay off?
push_off counts nesting in noff. Releasing kmem.lock takes noff from 2 to 1, and pop_off only turns interrupts on when noff reaches 0 and intena (saved at the outermost push_off) is 1. That happens when the p->lock is released.
Replace the amoswap loop with while (lk->locked) ; lk->locked = 1;. Trace an interleaving that breaks mutual exclusion.
Both harts load locked == 0, both leave the loop, both store 1 and both enter the critical section. Testing and setting must be one indivisible operation, which amoswap provides.
release executes fence rw,w before sw zero,0(s1). What could another hart observe if the fence were missing?
It could see locked == 0 before the stores of the critical section (for example the new kmem.freelist), acquire the lock and read stale data, handing out the same page twice. The fence orders every earlier load and store before the freeing store.
A timer interrupt is pending on hart 1 while it holds kmem.lock. When is it taken, and why is that safe?
holding() reads lk->cpu without any atomic or lock while another hart may be writing it. Why is its answer reliable?
Only this hart ever writes its own struct cpu pointer into lk->cpu, and with interrupts off nothing else on this hart can run while it checks. Another hart can only write its own pointer or 0, so the comparison with mycpu() is true exactly when this hart holds the lock.
Keys: ← → step · Home start