Week 10 · Locks, Semaphores, and Turning the MMU On¶
Thu Oct 29
37k_spinlocks,38k_semaphores· Fri Oct 3039k_virtual_memoryRead Essentials before Tuesday: it is what Thursday and Friday assume. Going deeper is optional and is not on the exam.
Slides Thursday prep Friday prep
This week¶
In 40k the whole filesystem sits behind one lock, and from 43k on every
address the kernel uses passes through a page table. Both rest on one change
this week: the kernel stops assuming nothing else is looking.
On Thursday something else is: another hart, or an interrupt, arriving between
two of your instructions. You build a spinlock from one atomic instruction and
a counting semaphore on it, and the kernel gets a heap. On Friday the
hardware looks: you identity-map the kernel, pack satp, and install the
table. 33k came before Midterm 1 and the break, so Friday's part opens with a
recap.
By Friday night the kernel has a lock, a semaphore two owners share through
Arc, and a page table of its own in satp.
Essentials¶
Thursday · 37k Spinlocks¶
In 46k the shell's ls, cd and mkdir each take the filesystem's lock.
37k_spinlocks builds that lock from one atomic instruction.
A race, built by hand¶
Your 32k allocator pops a page: read the head, then move the head to that
page's link. On two harts, pages named by their last four hex digits:
hart A hart B head
1 reads head: 3000 3000
2 reads head: 3000 3000
3 head = its link 2000
4 head = its link 2000
both return page 3000
Nothing keeps B out of A's gap. The result depends on how A and B interleave: a race condition. Code that must not run in two flows at once is a critical section, and keeping it to one flow is mutual exclusion. One hart is no cure: an interrupt can land in the gap.
One indivisible step¶
The hardware closes the gap with an atomic read-modify-write: a load, a decision and a store nothing can split. Here is compare-and-exchange (CAS) on a one-shot flag:
use core::sync::atomic::{AtomicUsize, Ordering::Relaxed};
const NOBODY: usize = usize::MAX;
static FIRST: AtomicUsize = AtomicUsize::new(NOBODY);
match FIRST.compare_exchange(NOBODY, hart, Relaxed, Relaxed) {
Ok(_) => report("first"), // we turned NOBODY into our id
Err(winner) => report_after(winner), // it already held another id
}
Read compare_exchange(NOBODY, hart, ..) as "if FIRST still holds NOBODY,
make it hart, in one step". So exactly one hart ever sees Ok. A spinlock claims a flag this way, and a loser tries again until
the holder lets go.
Compilers and CPUs reorder memory accesses. Take a lock with Acquire, so no later access moves above it; give it back
with Release, so no earlier access moves below it. Paired, they carry one
holder's writes to the next. FIRST guards no data, so Relaxed suffices.
Shared, yet mutable: UnsafeCell, Send and Sync¶
A plain static is read-only: TOTAL += 1 on static TOTAL: u64 fails with
"cannot assign to immutable static item". UnsafeCell<T> is the way
through: its safe get(&self) returns a *mut T from a plain &, which is
interior mutability. The cell enforces nothing; a lock beside it keeps the
writers to one.
A static must be Sync, safe to share by & between threads; Send
means safe to move to another thread. Wrap an UnsafeCell in a static, and
rustc refuses:
pub struct Mailbox { slot: UnsafeCell<u64> }
static INBOX: Mailbox = Mailbox { slot: UnsafeCell::new(0) };
// error[E0277]: `UnsafeCell<u64>` cannot be shared between threads safely
The answer is a signed promise, unsafe impl Sync for Mailbox {}. A generic
lock signs only for T: Send, or a hart could clone an Rc out of it and
carry it off, and Rc's count is not atomic.
The guard unlocks for you¶
Close a valve by hand, and one early return leaves it open. RAII ties
the close to a value: valve.open() returns an Open, whose Drop runs on
every path out of the scope:
pub struct Open<'a> { valve: &'a Valve }
impl Drop for Open<'_> {
fn drop(&mut self) { self.valve.close(); }
}
let _open = valve.open(); // the valve stays open while _open lives
fill_tank(); // close() runs at the brace, on every way out
A lock's guard is this shape: lock() returns one, Deref and DerefMut
make *g the data, and its Drop unlocks. Read let _open as "alive until
the closing brace". Write let _ = valve.open(); and _ binds nothing: the
valve closes at the semicolon, before fill_tank, without a warning. In rv6
a panic aborts, so no guard drops.
Interrupts off while you hold it¶
On one hart, a spinning waiter and the holder cannot both run:
kernel code takes lock L and starts its critical section
interrupt the hart jumps to the handler, mid-section
handler tries L: held, so it spins
L's holder resumes only when the handler returns: never
Hence the rule: a lock an interrupt handler also takes is held with
interrupts off. xv6 turns them off under every lock. rv6 survives without,
because no handler takes one: 45k's console passes bytes through a ring with
no lock.
The one thing to get right: the banner prints, then silence, then a QEMU timeout blaming the stack, which the banner rules out. The test holds the lock and tries again, expecting a refusal; a try that waits spins forever on its own hart's lock.
Thursday · 38k Semaphores and the heap¶
In 46k the shell keeps its directory in a Vec and each line in a String,
both on a heap. 38k_semaphores counts permits on your lock and turns the heap on.
Permits: P and V¶
A semaphore is a count of permits. wait (Dijkstra's P) takes one, post (V) returns one, and the count never goes below zero. Starting at 1 makes a lock, a binary semaphore; starting at N meters N interchangeable things, a counting semaphore. Three DMA channels, five transfers:
count
start 3
A waits 2 A takes a channel
B waits 1
A posts 2 A is done
C, D wait 0
E waits 0 refused: none left
This week's wait never sleeps, since nothing else could run to post: at zero
it refuses (why). The count lives behind
your 37k lock.
The heap arrives¶
Box, Vec and Arc come from the alloc crate, which a no_std kernel
pulls in with extern crate alloc;. But alloc owns no memory: with no
allocator named, rustc stops with "no global memory allocator found but one is
required". Name one with #[global_allocator] on a static whose type
implements GlobalAlloc. rv6's given KernelHeap (kheap.rs) answers every
request with a whole kalloc page, so the page allocator must exist before
the first Box:
| You write | Bytes requested | What you get |
|---|---|---|
Box::new(7u64) |
8 | a page, 4,088 bytes idle |
Arc::new(x), x 16 bytes |
32: two counts, then x |
a page |
Vec::<u8>::with_capacity(5000) |
5,000 | null, then a panic |
Arc: one value, many owners¶
Arc<T>, atomically reference-counted, shares one heap value among owners
until the last lets go:
let a = Arc::new(Gauge::new()); // one Gauge on the heap; count 1
let panel = Panel { g: Arc::clone(&a) }; // count 2
let log = Arc::clone(&a); // count 3, still one Gauge
drop(a); // count 2: the first owner is not special
let n = Arc::strong_count(&log); // n == 2
drop(panel); drop(log); // count 0: Gauge dropped, page freed
Read Arc::clone(&a) as "one more pointer, one more on the count": nothing is
copied. Arc hands out only &T, so
a.level += 1 fails with "cannot assign to data in an Arc". Shared data that
changes needs a lock inside.
The one thing to get right: the third wait succeeds too:
[fail] no permits should remain after taking both. The count went to −1: a permit that never existed was handed out.
Friday · 39k Turning the MMU on¶
From 43k on, every kernel access goes through a page table, and in 48k
each program gets its own. 39k_virtual_memory builds the kernel's table.
The next fetch is translated¶
In 33k, on Oct 9, only software read your tree: building a table takes only
stores, and using one is the hardware's job. Once satp names
the root, the MMU
walks the tree
on every access and enforces the leaf's R, W and X.
Hence the bootstrap paradox, like renaming the streets while you drive
them: the instruction after the switch is fetched through the new table, at
the same pc and sp. If pc's page is not mapped executable there, that
fetch faults.
The way out is an identity map, va == pa for every page the kernel uses:
translation is on, but each answer equals its question. Map anything the
kernel will fetch, load or store after the switch, devices included: whatever
you leave out stops existing. Give each region a start and a length, not an
end.
satp by hand¶
satp has three fields. MODE sits in bits 63..60 (8 is Sv39, 0 is off),
and an address-space ID in 59..44 (0 in rv6). Bits 43..0 hold the root's
page number: its physical address without the low 12 bits, as in a
PTE. For a
root at 0x8052_1000:
root table at 0x0000_0000_8052_1000
drop the low 12 bits 0x0000_0000_0008_0521 the PPN: three hex digits gone
MODE 8 in bits 63..60 0x8000_0000_0000_0000
both fields together 0x8000_0000_0008_0521 the value to install
Read it upward to decode, as
Week 7 did for
0x8000_0000_0008_7FFF. MODE 0 is legal, "translate nothing", so a wrong mode
never faults.
Two instructions, and the TLB¶
The switch is two given instructions:
csrw satp, t0 # the value above; the next fetch is translated
sfence.vma zero, zero # discard every cached translation
The second is for the TLB, the MMU's cache of recent translations, which
spares most accesses the walk. It is not coherent with memory:
rewrite a PTE and the cached copy can stay. sfence.vma discards cached
translations and orders earlier page-table stores before later walks:
load 0x8000_2F00 miss: walk, cache page 0x8000_2000 -> 0x8000_2000
leaf now names page 0x8060_0000; load again: may hit, still 0x8000_2F00
sfence.vma; load again: miss, walk, 0x8060_0F00
Fence after writing satp, and after changing an entry the hardware may have
used. A table built before it is installed needs none. From 48k the
trampoline's switch between two live tables fences on both sides.
Silence, and checking first¶
A wrong table fails in silence: even the report of the failure needs the translation that broke.
A pilot runs the checklist on the ground, not after takeoff, and 39k's test
does the same: it inspects your table with translation still off, so a mistake
prints a [fail] line instead of going dark. When a failure would be silent, check before the
dangerous step.
39k runs in machine mode, which translation never touches, so the table
carries nothing yet; from 43k, in supervisor mode, it carries every access.
The one thing to get right: every mapping checks out, then
[fail] make_satp: wrong root page number. The low 44 bits hold a page number, not an address. An address there names a root 4,096 times higher, about 8 TiB up, where there is no memory at all.
For the exam¶
Not needed on Thursday or Friday. On Midterm 2.
Test-and-set versus CAS · Midterm 2. Test-and-set always writes true
and returns the old value; CAS writes only on a match, so it works beyond
flags. Spinning test-and-set writes every try,
bouncing one cache line between spinners. Test-and-test-and-set
spins on a plain load until the flag reads free, then tries the atomic. A
separate atomic load and store still leave a gap.
Deadlock · Midterm 2. It needs mutual exclusion, hold-and-wait, no preemption and a circular wait. Opposite lock orders on two harts close the circle; one global order breaks it. On one hart, a lock and a handler can close it; interrupts off break that.
The lost wakeup · Midterm 2. A blocking wait that tests "count is 0",
then sleeps, has a gap: a post landing there wakes nobody, and the
waiter sleeps beside a free permit. The cure: test under a lock,
become Sleeping before releasing it, and retest in a while. rv6's
try_wait refuses at zero and never sleeps: no gap.
Bounded buffers · Midterm 2. N slots: empty = N, full = 0,
mutex = 1; empty + full = N between operations.
Take the counting semaphore first: a producer blocked on empty, holding
mutex, stalls every consumer.
Megapages, and 66 pages · Midterm 2. Identity-mapping 128 MiB in 4 KiB pages takes 32,768 leaves in 64 level-0 tables, plus a level-1 table and the root: 66 pages. A level-1 leaf maps a 2 MiB megapage, so the root and one level-1 table of 64 leaves suffice: 2 pages.
Going deeper¶
Optional. Nothing here is needed on Thursday or Friday, and nothing here is on the exam.
What a CAS compiles to¶
riscv64gc includes the A extension, with two kinds of atomic. An AMO, such
as amoor.w, reads a word, combines it with a register and writes it back in
one instruction, returning the old value. LR/SC is a pair: lr.d loads and
reserves an address, and sc.d stores only if nothing touched it since.
Compile the one-shot FIRST with -O for riscv64gc-unknown-none-elf, and it
becomes an lr.d, bne, sc.d, bnez retry loop. A CAS from false to
true on an AtomicBool becomes one amoor.w.aq instead, .aq for
Acquire: OR-ing in a bit
already set changes nothing, so on a bool, CAS is test-and-set. It works on
the containing word, with shifts around it, because RV64A has no byte-wide
AMOs.
CAS compares values, so a word that went A → B → A between your read and your CAS looks untouched: the ABA problem, and why lock-free lists carry version counters.
Ordering on one hart¶
RISC-V's memory model lets one hart's stores reach other harts out of order.
A single hart sees its own accesses in order, yet the compiler still moves
loads and stores it sees no reason to keep, and an interrupt handler sees the
result. A Release store compiles to fence rw, w, then the store. A losing
CAS changed nothing, so its failure ordering owes the data nothing. And any
"is it held?" check is stale the instant it returns: a plain load of the
flag followed by a store rebuilds the race out of an atomic.
What the heap costs¶
More than a page fails, and "memory allocation of 5000 bytes failed" never
shows: the test's panic handler prints only OSLINGS:FAIL (panic).
Push ten u64s onto a Vec, and its capacity grows 4, 8, 16, taking three
pages in turn. Each growth allocates the new block, copies, then frees the old,
so two pages are live at that instant; the final block is 128 bytes of a page.
A real allocator is a project of its own: Linux carves objects out of
buddy-allocated pages with SLUB, and xv6 has no kernel heap at all. rv6's is
also unsafe on two harts, since kalloc's free list has no lock and two
allocations could return the same page. The cure is to put the
free list in a SpinLock.
What a table costs¶
A page table's size follows how scattered its pages are, not how many there are. Two 4 KiB pages in one 2 MiB region share a level-0 table. Two pages in different 2 MiB regions need two level-0 tables, and two pages in different 1 GiB regions need two level-1 tables as well. A few device pages spread across the low gigabyte can cost more tables than a contiguous megabyte of RAM.
A hardened kernel also splits RAM by permission: code R X, data R W, so no page is both writable and executable. That is W^X, and each split costs one more region boundary, placed from a linker symbol. xv6 pays for one (below).
When you must flush¶
RISC-V needs sfence.vma after writing satp, after changing an entry the
hardware may have used, and even after making an invalid entry valid: a hart
may remember that an address was unmapped. rv6 never fences while building a
table, correctly, by an invariant: a table is complete before it is installed.
Add lazy allocation or copy-on-write and every edit needs a fence. QEMU never
remembers a failed walk, so a missing fence after adding a page passes every
test here and can fail on a real board.
Reading the silence¶
When the kernel goes quiet after the switch, attach GDB and read four
registers. satp starting with 8 says the switch happened. scause 12 is an
instruction page fault, the table refusing a fetch. stval holds the address,
and a pc stuck at 0 means the fault trapped to an unset vector and faulted
again. The spec says a root that points at no memory gives an access fault,
scause 1; QEMU 10 reports that case as 12 too, so check satp itself before
blaming a mapping. QEMU and GDB
has the playbook.
How xv6 and Linux do it¶
xv6 splits its kernel map at etext, the end of .text: code R X, the rest
R W. That is W^X for one extra mapping. Linux runs at a high virtual address
but is loaded wherever the bootloader chose, so the fetch after its switch
cannot translate to itself. On RISC-V it points stvec at the high address of
that next instruction first: the fetch faults, and the trap lands exactly
there. Linux also tags TLB entries with ASIDs, so a process switch need not
flush, and asks other harts to flush by interrupt, since sfence.vma reaches
only its own.
Practice problems¶
Problem 1: Lost updates, counted¶
Two harts share static mut HITS: u64 = 0 with no lock, and each runs
HITS += 1 twice. Each increment compiles to ld, addi, sd.
(a) Which final values are possible? (b) Give an interleaving for the
smallest. (c) Which fixes it: an AtomicU64 read with load and written with
store, fetch_add(1, ..), or a lock around each increment?
Click to reveal solution
(a) 2, 3 or 4. Each lost update costs one.
(b) A's stale last store erases both of B's increments:
A: ld 0, addi, sd 1, ld 1, addi HITS = 1, A holds 2
B: ld 1, addi, sd 2, ld 2, addi, sd 3 HITS = 3
A: sd 2 HITS = 2
(c) fetch_add, one amoadd.d with no gap inside, and the lock, which makes
the three steps a critical section. An atomic load then an atomic store is
two indivisible steps with a gap between them: the same race, with atomics.
Problem 2: When does the guard drop?¶
std::sync::Mutex has a guard like this week's. The mutex starts at 0.
fn tally(m: &Mutex<u32>) -> bool {
*m.lock().unwrap() += 1; // B
let n = *m.lock().unwrap(); // C
let mut g = m.lock().unwrap(); // D
*g += n; // E
drop(g); // F
let _held = m.lock().unwrap(); // G
m.try_lock().is_ok() // H
}
(a) When is the mutex released each time? What does tally return, and what
does the mutex hold? (b) Change G to let _ = m.lock().unwrap();. What
happens, and what would happen with a guard of your own type?
Click to reveal solution
(a) B's guard is a temporary, dropped at B's semicolon. C copies the u32
out, so its guard also drops at the semicolon, with n = 1. D's guard lives
until F; E makes the value 2. G's lives to the end, so H finds the mutex held:
tally returns false, and the mutex holds 2.
(b) It does not compile. rustc's let_underscore_lock lint, an error by
default, says "non-binding let on a synchronization lock". The lint knows only
the standard library's locks: with your own guard type the line compiles
silently, the guard drops at once, and H returns true.
Problem 3: A bounded buffer of two¶
Use the bounded buffer with N = 2 and a blocking P.
(a) The producer offers three items; then one consumer runs once. Give empty
and full after each step, and say where the third production waits.
(b) Start over, empty. A consumer's first two lines are swapped to
P(mutex); P(full), and it runs before any producer. Show the deadlock.
Click to reveal solution
(a)
| After | empty |
full |
|---|---|---|
| production 1 | 1 | 1 |
| production 2 | 0 | 2 |
| production 3 | waits in P(empty), holding nothing |
2 |
| the consumption | 1, taken at once by production 3 | 1 |
| production 3 finishes | 0 | 2 |
The third producer waits before mutex, so the consumer can still get in, and
its V(empty) is exactly what the producer needed.
(b) The consumer takes mutex, then waits on full = 0 while holding it.
The producer passes P(empty) and waits on mutex. Each needs what only the
other can give: a circular wait. Consumers, too, take the counting semaphore
first.
Problem 4: satp both ways¶
(a) A root sits at 0x8043_7000. What goes in satp? (b) Decode
0x8000_0000_0008_7FC2: is the root in RAM? (c) Decode
0x0000_0000_0008_0437: what happens once it is installed? (d) Decode
0x8000_0000_8043_7000: where does the hardware look for the root?
Click to reveal solution
(a) 0x8043_7000 >> 12 is 0x8_0437; with 8 at bit 60,
0x8000_0000_0008_0437.
(b) MODE 8, Sv39. The low 44 bits are 0x8_7FC2, so the root is at
0x87FC_2000, inside 0x8000_0000..0x8800_0000: yes.
(c) MODE 0, Bare: QEMU translates nothing, so the kernel runs on and nothing says the table was never used. (The current privileged spec asks for zeros beside MODE 0 and leaves this pattern UNSPECIFIED.)
(d) The low 44 bits hold 0x8043_7000, an address where a page number
belongs. Three zero digits appended put the root at 0x804_3700_0000, about
8 TiB up, where there is nothing: the first translated access fails inside the
walk.
Problem 5: Count the tables¶
A small kernel identity-maps RAM from 0x8000_0000 to 0x8040_0000 in 4 KiB
pages, plus one device page at 0x2000_0000. (a) How many page-table pages,
root included? (b) How many if RAM uses 2 MiB megapages, and why not the
device page? (c) How many for just the pages 0x801F_F000 and 0x8020_0000?
Click to reveal solution
(a) 6. RAM's 1,024 pages fill two level-0 tables below one level-1 table, under root entry 2. The device is under root entry 0, level-1 entry 256: one level-1 and one level-0 table. With the root, 1 + 3 + 2 = 6.
(b) 4. RAM becomes two level-1 leaves, and its level-0 tables vanish. The device keeps a 4 KiB page, since a 2 MiB leaf would also map every device register beside it.
(c) 4: the root, a level-1 table and two level-0 tables. The pages are adjacent but straddle a 2 MiB boundary, level-1 entries 0 and 1.
Key terms¶
| Term | Meaning | Where you use it |
|---|---|---|
| Race condition | A result that depends on how two flows interleave | 37k, Midterm 2 |
| Critical section | Code that must not run in two flows at once | 37k, 40k |
| Compare-and-exchange | Change a value only if it still holds what you expect, in one step | 37k |
| Acquire / Release | The pair that carries one holder's writes to the next | 37k |
| Interior mutability | A &mut from a &, through UnsafeCell |
37k, 38k |
Send / Sync |
Safe to move / safe to share between threads | 37k |
| RAII guard | A value whose Drop releases what it holds |
37k, 40k, 46k |
| Semaphore | A count of permits: P takes, V returns, never below zero | 38k |
#[global_allocator] |
Names the GlobalAlloc behind Box, Vec and Arc |
38k, 46k |
| Identity map | va == pa, so turning translation on changes no address |
39k, 43k |
satp |
MODE in bits 63..60, the root's page number in 43..0 | 39k, 48k |
TLB, sfence.vma |
The MMU's cache of translations, and the fence that clears it | 39k, 48k |
Further reading¶
- Unsafe Rust and no_std:
UnsafeCell,SendandSyncandcore,alloc, andstd. - Rust for Systems: the guard pattern.
- Sv39 Paging: the
satpregister andsfence.vmaand the TLB. - QEMU and GDB: diagnostic playbook.
- Midterm 2, and Practice Set 2 Parts B and C.
- Mara Bos, Rust Atomics and Locks, chapters 1–4.
- The xv6 book, chapters 3 and 6.
- Arpaci-Dusseau and Arpaci-Dusseau, Operating Systems: Three Easy Pieces, chapters 19 and 28–32.