xv6, line by line
lab 20

Extension labs · lab 20 · Concurrency · ★★☆☆☆

Ticket spinlocks

Every spinlock in this tree is one word and one atomic swap. When the holder lets go, the next hart whose amoswap happens to reach the word first takes the lock. Nothing remembers who has been waiting longest. In this lab you replace that loop with a ticket lock: a hart takes a number and waits until its number is called, like at a deli counter, so harts get the lock in the order they asked.

The change is about thirty lines in two files (spinlock.c and spinlock.h), and every line raises a question about hardware and timing. Which of the two counters must be updated atomically, and which need not be? What memory ordering does each step need, and which instructions does the compiler turn them into? What happens when a 32-bit counter wraps around? Interrupts are already off while a hart waits for a lock: was that only a convenience before, and is it still? What exactly does holding() mean when there is no “locked” flag any more?

You also build the instrument to see the difference: a system call that makes three processes hammer one kernel lock and keeps score. On QEMU the result is not what the textbook predicts, and the lab shows what we measured and why.

Read first: Tour 13: swtch and the lock handed across a context switch, Tour 15: Spinlocks from the hardware up, Tour 19: Memory ordering across harts, Tour 50: noff and intena through a sleep, a yield and an interrupt, Tour 52: Breaking the lock rules · Locks and interrupt state

What this lab teaches

  • Why the swap loop in acquire promises mutual exclusion but no order, and what a fair lock has to remember that it does not.
  • Which parts of a lock need an atomic read-modify-write, which need only ordering, and which need neither; and how C11 atomics with acquire and release ordering turn into RISC-V instructions in this build.
  • How unsigned arithmetic modulo 2^32 behaves when a counter wraps, which comparisons survive the wrap, and how to make a wrap bug show up in seconds instead of hours.
  • Does a hart that is only waiting for a lock, not yet holding it, need interrupts off? Is the answer the same for a free-for-all as for a queue?
  • What holding and lk->cpu promise, why they name a hart, and why the order of two stores in release matters.
  • How to measure fairness on three harts, and why aggregate numbers on an emulator can hide what a per-acquisition measurement shows.

The reference branch

ext/20-ticketlock in ShowMeTheStack/xv6-riscv-labs, branched from the frozen commit 06aad25; 4 commits.

git clone https://github.com/ShowMeTheStack/xv6-riscv-labs
cd xv6-riscv-labs
git checkout -b my-ticketlock 06aad25   # start your own
git diff 06aad25 origin/ext/20-ticketlock   # only when you want the answer

1. The spec

The lock. Replace the swap loop with a lock that hands itself to waiting harts in the order they asked for it: first come, first served (FIFO), with no hart passed over by a later arrival. struct spinlock keeps its size and its name and cpu fields; what replaces the locked flag is yours to design (the think section works it out).

What must not change.

The instrument. A test system call, int lockbench(int nproc, int total, struct lbstat *st). lockbench(0, total, 0) resets it, and fails with -1 while a run is in progress (a reset reinitializes the lock under any holder). Then nproc processes call it at once; each waits at a start line until all have arrived, then acquires and releases one kernel lock, benchlock, until total acquisitions have been made in all. Inside the critical section it checks that nobody else is inside and keeps score: acquisitions per caller and per hart, the wait for each acquisition (timed with the time CSR), and the longest run of acquisitions by one hart.

The test program, locktest [nproc [total]] (3 processes and 300,000 acquisitions by default), prints the numbers and checks one thing, mutual exclusion:

$ locktest
locktest: 3 processes, 300000 acquisitions of one lock
locktest: process 0: 101214 acquisitions, wait mean 768 ns, longest 87 us
locktest: process 1: 98549 acquisitions, wait mean 786 ns, longest 75 us
locktest: process 2: 100237 acquisitions, wait mean 781 ns, longest 88 us
locktest: per hart: 103565 94352 102083
locktest: longest run by one hart: 24
locktest: 203 ms, 1477 acquisitions per ms
locktest: mutual exclusion (300000 counted, 0 overlaps): OK
locktest: fairness is not checked: compare the numbers above

The last line is deliberate. Fairness is something to measure, not to assert: the numbers vary from run to run and with the load on the machine running QEMU, and the measurement that shows the ticket lock’s real property needs a counter inside acquire itself (see Measure).

2. Think first

Answer each question in your head (or on paper) before opening a hint. Hints get more specific; the reference answer comes last.

1What does the swap loop promise, and what does it not?

Three harts want the same lock. Hart 0 holds it; harts 1 and 2 are spinning in acquire. Hart 0 releases it, and a moment later wants it again. Who gets the lock next? Is there any bound on how many times hart 1 can be passed over before it gets in? Commit to an answer before the hints.

Check yourself

1warm-upClick the line

In the original acquire, click the line where the decision “which waiting hart gets the lock next” is made.

kernel/spinlock.c
19// Acquire the lock.
20// Loops (spins) until the lock is acquired.
21void
24 push_off(); // disable interrupts to avoid deadlock.
25 if (holding(lk))
26 panic("acquire");
28 // On RISC-V, __atomic_exchange_n turns into an atomic swap:
29 // a5 = 1
30 // s1 = &lk->locked
31 // amoswap.w.aq a5, a5, (s1)
32 //
33 // Passing __ATOMIC_ACQUIRE to __atomic_exchange_n tells
34 // the C compiler and the processor to not move loads or stores
35 // past this point, to ensure that the critical section's memory
36 // references happen strictly after the lock is acquired.
37 while (__atomic_exchange_n(&lk->locked, 1, __ATOMIC_ACQUIRE) != 0)
38 ;
40 // Record info about lock acquisition for holding() and debugging.
41 lk->cpu = mycpu();

Your pick: none yet (click a line in the code)

2solidTrue or false, and why

True or false: with the original swap loop on three harts, a hart that has been spinning in acquire gets the lock after at most two other acquisitions.

Why?

2How do you hand out turns?

Design the smallest data structure that lets harts get a lock in the order they asked for it, using only one atomic instruction per acquire. Which field must be changed with an atomic read-modify-write, and which field can be changed with an ordinary store? Why?

Check yourself

1solidChoose one

A learner writes the ticket step as my = lk->next; lk->next = my + 1; (plain C, no atomics). What can go wrong on three harts?

2warm-upMatch the pairs

Match each operation of the ticket lock to what it needs.

3What ordering does each step need, and what will the compiler emit?

Before writing code, decide the memory ordering of each of the three steps: taking the ticket, the load in the spin loop, and the store in release. Which of them must be acquire, which release, which can be relaxed? Then predict the RISC-V instructions: will the ticket be amoadd.w or amoadd.w.aq? Where will a fence appear, and which kind?

Check yourself

1solidType a number

In this build, how many fence instructions does one hart execute in acquire + release of a ticket lock if it finds the lock free (its first load already sees its number)? Count only acquire and release themselves.

decimal, 0x hex or 0b binary
2deepChoose one

Why is it safe for the ticket’s __atomic_fetch_add to be __ATOMIC_RELAXED?

4What happens when the counters wrap?

next and serving are 32-bit uints. A busy lock hands out 2^32 tickets sooner than you might think. When next goes from 0xffffffff to 0, does your acquire still work? Would while (serving < my) (wait while it is not yet my turn) work as well as while (serving != my)? What about a signed comparison? And how would you test the wrap without waiting for four billion acquisitions?

Check yourself

1solidChoose one

The holder has ticket 0xffffffff; a waiter has ticket 0x00000000. The code waits with while (__atomic_load_n(&lk->serving, __ATOMIC_ACQUIRE) < my) on uints. What happens?

2deepType a number

The kernel supports up to NCPU = 8 harts. Suppose all 8 run. The counters are 32-bit and compared with !=, and a hart holds at most one ticket for a given lock (it waits with interrupts off, and taking a second ticket for a lock it holds panics). At most how many tickets for one lock can be outstanding at once (handed out and not yet served past), counting the holder’s?

decimal, 0x hex or 0b binary

5Is push_off still only about interrupt handlers?

acquire calls push_off before anything else. In the original lock that stops an interrupt handler on this hart from wanting a lock this hart holds. Suppose a learner moves push_off() after the wait (“interrupts off only once I hold it”). With the swap loop, how bad is that? With tickets, what can an interrupt (or a yield on a timer tick) do to a hart that has taken a ticket but is still waiting?

Check yourself

1solidFill in the machine state

A process is in sys_uptime on hart 0, inside the reference acquire(&tickslock), spinning because another hart holds the lock. Interrupts were on before the system call’s first acquire, and no other lock is held. Fill in the machine state of hart 0 while it spins.

2deepChoose one

With push_off moved after the wait, the recorded freezes all had the same shape. Which?

6What does holding() mean now, and in what order does release write?

There is no locked flag any more. How does holding tell “this hart holds the lock” now? Is lk->cpu == mycpu() alone enough? And release must do two writes: clear lk->cpu and let the next ticket in. Does the order matter? What could another hart see in between?

Check yourself

1solidPut in order

Put the reference release in order.

  1. sw the new serving
  2. lk->cpu = 0
  3. load serving and add 1
  4. check holding(lk), panic “release” if this hart does not hold it
  5. pop_off()
  6. fence rw,w
2solidTrue or false, and why

True or false: with the ticket lock, the scheduler can still acquire a process’s p->lock and let the process release it after swtch.

Why?

7What does fairness cost, and how will you see it?

Before measuring, predict. On real multi-core hardware, what does every spinning hart read in a ticket lock, and what happens to those harts when the holder releases? What happens to the line if the hart whose number is called next is slow to notice (its thread is delayed)? And on QEMU, whose harts are host threads, which of these effects can you expect to see at all? Design an experiment that would show the difference between the two locks.

Check yourself

1deepChoose all that apply

Which of these did the measurements on QEMU (three harts, three processes hammering one lock) actually show?

3. Build it

Start from the frozen commit, in your own clone of xv6:

git checkout -b my-ticketlock 06aad25

Build the instrument first, and measure the old lock.

  1. The system call. SYS_lockbench (23) in kernel/syscall.h, the extern and the table entry in kernel/syscall.c, entry("lockbench") in user/usys.pl, the prototype in user/user.h, a struct lbstat in a header both sides include, and the handler in a new kernel/lockbench.c (add $K/lockbench.o to OBJS). The handler’s loop is push_off, read the time CSR, acquire, read it again, keep score, release, pop_off. Keep the start line (the barrier) outside any lock and with interrupts on. The reset reinitializes the lock, so it must refuse while any caller is mid-run: count the callers under a second lock and make the reset fail unless the count is 0.
  2. The program. user/locktest.c and $U/_locktest in UPROGS. Run it on the original lock and keep the output: that is your “before”.

Then change the lock, in an order that keeps the kernel bootable:

  1. The struct. Replace locked with next and serving; initlock sets both. Fix every use of locked (only kernel/spinlock.c has them: grep -n locked kernel/*.c; the locked in sleeplock.c is a different struct).
  2. acquire, release, holding together: the kernel cannot boot with only half of them. Then read kernel/kernel.asm and find amoadd.w, the lw + fence r,rw loop and the fence rw,w + sw. If the loop has no lw inside it, see clinic 1. Test: boot, locktest, usertests -q.
  3. The wrap. Start the counters near 0xffffffff and run everything again.

Debugging. Run QEMU with three harts (-smp 3) and attach gdb. A lock bug usually shows as one of three things:

4. Debugging clinic

Each of these bugs was put into the reference solution on purpose and run on three harts. The symptom is exactly what happened. Try to explain it before revealing why.

1Spinning on a plain load

The wait reads serving as an ordinary C variable:

-  while (__atomic_load_n(&lk->serving, __ATOMIC_ACQUIRE) != my)
+  while (lk->serving != my)
     ;

(The clinic kernels were built without lockbench’s reset check; spinlock.c is the same, but their kernel addresses are lower than in the branch’s build, e.g. acquire at 0x80000db4 instead of 0x80000e3e.)

The compiler is allowed to assume no other thread changes lk->serving inside the loop, and it does. The whole loop became:

80000dd4:  lw   a4,4(s1)                # serving, loaded once
80000dd6:  bne  a4,a5,80000dd6          # branch to itself

What happened when we ran it

xv6 kernel is booting

hart 1 starting

(the console stays like this; gdb attached after 30 seconds, second boot:)
  Id   Target Id                    Frame
* 1    Thread 1.1 (CPU#0 [running]) 0x0000000080000dd6 in acquire (lk=lk@entry=0x8000fe18 <proc>) at kernel/spinlock.c:52
  2    Thread 1.2 (CPU#1 [running]) 0x0000000080000dd6 in acquire (lk=lk@entry=0x8000fe18 <proc>) at kernel/spinlock.c:52
  3    Thread 1.3 (CPU#2 [running]) 0x0000000080000dd6 in acquire (lk=lk@entry=0x8000f948 <pr>) at kernel/spinlock.c:52

Thread 3 (Thread 1.3 (CPU#2 [running])):
#0  0x0000000080000dd6 in acquire (lk=lk@entry=0x8000f948 <pr>) at kernel/spinlock.c:52
#1  0x000000008000057a in printk (fmt=fmt@entry=0x800070a0 "hart %d starting\n") at kernel/printk.c:71
#2  0x0000000080001064 in main () at kernel/main.c:38

Thread 2 (Thread 1.2 (CPU#1 [running])):
#0  0x0000000080000dd6 in acquire (lk=lk@entry=0x8000fe18 <proc>) at kernel/spinlock.c:52
#1  0x00000000800020fe in sleep_prepare (chan=chan@entry=0x80015848 <bcache+24>) at kernel/proc.c:551
#2  0x0000000080005c7a in virtio_disk_rw (b=b@entry=0x80015848 <bcache+24>, write=write@entry=0) at kernel/virtio_disk.c:288
[...]

Thread 1 (Thread 1.1 (CPU#0 [running])):
#0  0x0000000080000dd6 in acquire (lk=lk@entry=0x8000fe18 <proc>) at kernel/spinlock.c:52
#1  0x0000000080002190 in wakeup (chan=chan@entry=0x80007878 <tx_chan>) at kernel/proc.c:581
#2  0x0000000080000a18 in uartintr () at kernel/uart.c:145
[...]

Thread 3 (Thread 1.3 (CPU#2 [running])):
$4 = 0xffffffffffffff03

Thread 2 (Thread 1.2 (CPU#1 [running])):
$5 = 0xffffffffffffff03

Thread 1 (Thread 1.1 (CPU#0 [running])):
$6 = 0xffffffffffffff02

Thread 3 (Thread 1.3 (CPU#2 [running])):
$7 = 0xffffffffffffff04

Thread 2 (Thread 1.2 (CPU#1 [running])):
$8 = 0xffffffffffffff04

Thread 1 (Thread 1.1 (CPU#0 [running])):
$9 = 0xffffffffffffff03
[...]

Thread 3 (Thread 1.3 (CPU#2 [running])):
$13 = {next = 0xffffff05, serving = 0xffffff04, name = 0x80007028, cpu = 0x0}

Thread 2 (Thread 1.2 (CPU#1 [running])):
$14 = {next = 0xffffff05, serving = 0xffffff03, name = 0x80007180, cpu = 0x0}

Thread 1 (Thread 1.1 (CPU#0 [running])):
$15 = {next = 0xffffff05, serving = 0xffffff03, name = 0x80007180, cpu = 0x0}

2Waiting while serving < my

An ordering comparison instead of equality, on the same uints:

-  while (__atomic_load_n(&lk->serving, __ATOMIC_ACQUIRE) != my)
+  while (__atomic_load_n(&lk->serving, __ATOMIC_ACQUIRE) < my)
     ;

(bne became bltu at 0x80000de0.) Everything else as in the reference, including commit 4, which starts every lock at 0xffffff00.

What happened when we ran it

$ locktest
locktest: 3 processes, 300000 acquisitions of one lock
panic: release

(gdb from reset, a second boot, at the panic:)
Thread 3 hit Breakpoint 1, panic (s=s@entry=0x80007078 "release") at kernel/printk.c:139
[...]
#1  0x0000000080000e86 in release (lk=lk@entry=0x8000f9b0 <benchlock>) at kernel/spinlock.c:64
#2  0x0000000080000ca8 in sys_lockbench () at kernel/lockbench.c:114
[...]
$15 = {next = 0x3, serving = 0x0, name = 0x80007048, cpu = 0x0}

(another boot, gdb attached from reset; the panic came before the shell:)
xv6 kernel is booting

hart 2 starting
hart 1 starting
panic: release

3Turning interrupts off only once the lock is held

push_off() moved from the top of acquire to just after the wait:

 acquire(struct spinlock *lk)
 {
   uint my;

-  push_off(); // disable interrupts to avoid deadlock.
   if (holding(lk))
     panic("acquire");
   ...
   while (__atomic_load_n(&lk->serving, __ATOMIC_ACQUIRE) != my)
     ;
+  push_off(); // disable interrupts now that we hold it.

To compare, the same change was made to the original swap-loop lock (commit 2). The stress program was tour 52’s lockstress up 300000 (not on the branch): three processes, each calling uptime() 300,000 times, so tickslock is hammered from all three harts while clockintr takes it on hart 0 ten times a second.

What happened when we ran it

$ lockstress up 300000
[...]
u1.201000 u0.220000 u2.209000 u1.202000 u0.221000

(the end of the progress line: the console stopped there; 60 seconds later gdb attached)
  Id   Target Id                    Frame
* 1    Thread 1.1 (CPU#0 [running]) acquire (lk=lk@entry=0x80015818 <tickslock>) at kernel/spinlock.c:51
  2    Thread 1.2 (CPU#1 [running]) acquire (lk=0x80015818 <tickslock>) at kernel/spinlock.c:51
  3    Thread 1.3 (CPU#2 [running]) acquire (lk=0x80015818 <tickslock>) at kernel/spinlock.c:51

Thread 3 (Thread 1.3 (CPU#2 [running])):
#0  acquire (lk=0x80015818 <tickslock>) at kernel/spinlock.c:51
#1  0x0000000080002cc2 in sys_uptime () at kernel/sysproc.c:108
#2  0x0000000080002ac6 in syscall () at kernel/syscall.c:148
#3  0x000000008000284c in usertrap () at kernel/trap.c:68
#4  0x0000003ffffff09c in ?? ()
[...]

Thread 1 (Thread 1.1 (CPU#0 [running])):
#0  acquire (lk=lk@entry=0x80015818 <tickslock>) at kernel/spinlock.c:51
#1  0x000000008000270e in clockintr () at kernel/trap.c:170
#2  0x00000000800027a2 in devintr () at kernel/trap.c:215
#3  0x00000000800028dc in kerneltrap () at kernel/trap.c:149
#4  0x00000000800057b8 in kernelvec () at kernel/kernelvec.S:38
Backtrace stopped: frame did not save the PC
[...]

Thread 3 (Thread 1.3 (CPU#2 [running])):
$4 = 0x9aa91

Thread 2 (Thread 1.2 (CPU#1 [running])):
$5 = 0x9aa93

Thread 1 (Thread 1.1 (CPU#0 [running])):
$6 = 0x9aa92
[...]
$13 = {next = 0x9aa94, serving = 0x9aa90, name = 0x80007270, cpu = 0x0}
[...]
(the frame under kernelvec on hart 0, unwound by hand:)
kvbt: interrupted code at sepc=0x80000dce, sp=0x3fffff3f80
#0  acquire (lk=0x80015818 <tickslock>) at kernel/spinlock.c:51
#1  0x0000000080002cc2 in sys_uptime () at kernel/sysproc.c:108
#2  0x0000000080002ac6 in syscall () at kernel/syscall.c:148
#3  0x000000008000284c in usertrap () at kernel/trap.c:68
#4  0x0000003ffffff09c in ?? ()
kvbt: saved a4=0x9aa90 a5=0x9aa8f

4Letting the next ticket in before clearing lk->cpu

The two writes in release swapped:

-  lk->cpu = 0;
-
   __atomic_store_n(&lk->serving, lk->serving + 1, __ATOMIC_RELEASE);
+  lk->cpu = 0;

In this build the window is one instruction: sw a5,0(a4) (the release store) is followed directly by sd zero,16(s1).

What happened when we ran it

$ locktest
[...]
locktest: mutual exclusion (300000 counted, 0 overlaps): OK
(five locktest runs, then usertests -q:)
ALL TESTS PASSED
$ locktest
[...]
locktest: mutual exclusion (300000 counted, 0 overlaps): OK

(a copy with a 1000-iteration delay loop between the two writes, gdb from reset:)
xv6 kernel is booting

hart 1 starting
hart 2 starting
panic: release

#1  0x0000000080000eac in release (lk=lk@entry=0x80020be0 <disk+296>) at kernel/spinlock.c:64
#2  0x0000000080005d0c in virtio_disk_rw (b=b@entry=0x80015848 <bcache+24>, write=write@entry=0) at kernel/virtio_disk.c:297
[...]
$15 = {next = 0xffffff03, serving = 0xffffff02, name = 0x80007658, cpu = 0x0}

5Releasing with a plain increment

No atomic store, no ordering:

-  __atomic_store_n(&lk->serving, lk->serving + 1, __ATOMIC_RELEASE);
+  lk->serving++;

The compiled release loses its fence: sd zero,16(s1), lw a5,4(s1), addiw a5,a5,1, sw a5,4(s1), then the call to pop_off.

What happened when we ran it

$ locktest
[...]
locktest: mutual exclusion (300000 counted, 0 overlaps): OK
(three locktest runs, then:)
$ usertests -q
[...]
ALL TESTS PASSED
$ lockstress up 100000
[...] lockstress up 100000 done
$ locktest
[...]
locktest: mutual exclusion (300000 counted, 0 overlaps): OK

5. The reference solution

Take the guided tour through the reference solution, one commit at a time, with the machine state at every step:

Open the reveal tour →

Or read the commits

  1. 461104a Add lockbench, a system call that hammers one lock

    Makefile

    @@ -7,8 +7,9 @@ OBJS = \
    77 $K/console.o \
    88 $K/printk.o \
    99 $K/uart.o \
    1010 $K/kalloc.o \
    11 $K/lockbench.o \
    1112 $K/spinlock.o \
    1213 $K/string.o \
    1314 $K/main.o \
    1415 $K/vm.o \

    kernel/lockbench.c

    @@ -0,0 +1,146 @@
    1// lockbench: several processes acquire and release one
    2// spinlock in a loop, to measure how fairly it is shared.
    3
    4#include "types.h"
    5#include "riscv.h"
    6#include "defs.h"
    7#include "param.h"
    8#include "spinlock.h"
    9#include "proc.h"
    10#include "lockbench.h"
    11
    12static struct spinlock benchlock;
    13
    14// runlock protects running: callers between the start line
    15// and the end of their run. reset reinitializes benchlock,
    16// so it must find running == 0. (a zeroed spinlock is a
    17// valid free lock, so runlock needs no initlock.)
    18static struct spinlock runlock;
    19static int running;
    20
    21// written with benchlock held, except by reset; read
    22// without it only by the spin on arrived and after the run.
    23static struct {
    24 int total; // stop after this many acquisitions in all
    25 int count; // acquisitions so far
    26 int arrived; // callers at the start line
    27 int inside; // a hart is in the critical section
    28 int overlaps; // times a hart found another one inside
    29 int last; // hart of the previous acquisition
    30 int run; // acquisitions in a row by that hart
    31 int maxrun; // longest such run
    32} bench;
    33
    34// reset the benchmark, unless a run is in progress.
    35static int
    36benchreset(int total)
    37{
    38 acquire(&runlock);
    39 if (running > 0) {
    40 release(&runlock);
    41 return -1;
    42 }
    43 initlock(&benchlock, "bench");
    44 bench.total = total;
    45 bench.count = 0;
    46 bench.arrived = 0;
    47 bench.inside = 0;
    48 bench.overlaps = 0;
    49 bench.last = -1;
    50 bench.run = 0;
    51 bench.maxrun = 0;
    52 release(&runlock);
    53 return 0;
    54}
    55
    56// the critical section: check that nobody else is
    57// inside, and keep score.
    58static void
    59critical(struct lbstat *st, uint64 w)
    60{
    61 int me = cpuid();
    62
    63 if (__atomic_load_n(&bench.inside, __ATOMIC_RELAXED))
    64 bench.overlaps++;
    65 __atomic_store_n(&bench.inside, 1, __ATOMIC_RELAXED);
    66
    67 if (me == bench.last) {
    68 bench.run++;
    69 } else {
    70 bench.last = me;
    71 bench.run = 1;
    72 }
    73 if (bench.run > bench.maxrun)
    74 bench.maxrun = bench.run;
    75
    76 bench.count++;
    77 st->n++;
    78 st->hart[me]++;
    79 st->waited += w;
    80 if (w > st->maxwait)
    81 st->maxwait = w;
    82
    83 __atomic_store_n(&bench.inside, 0, __ATOMIC_RELAXED);
    84}
    85
    86// int lockbench(int nproc, int total, struct lbstat *st)
    87// nproc 0: reset, the run will make total acquisitions;
    88// fails (-1) while a run is in progress.
    89// otherwise: wait until nproc callers have arrived, then
    90// acquire and release benchlock until total is reached.
    91uint64
    92sys_lockbench(void)
    93{
    94 int nproc, total;
    95 uint64 addr, start, t0, w;
    96 struct lbstat st;
    97 struct proc *p = myproc();
    98
    99 argint(0, &nproc);
    100 argint(1, &total);
    101 argaddr(2, &addr);
    102 if (nproc <= 0)
    103 return benchreset(total);
    104
    105 memset(&st, 0, sizeof(st));
    106 acquire(&runlock);
    107 running++;
    108 release(&runlock);
    109
    110 // the start line. spin with interrupts on: a caller
    111 // that has not arrived yet may need this hart.
    112 acquire(&benchlock);
    113 bench.arrived++;
    114 release(&benchlock);
    115 while (__atomic_load_n(&bench.arrived, __ATOMIC_RELAXED) < nproc)
    116 ;
    117 start = r_time();
    118
    119 for (;;) {
    120 push_off(); // so that only the lock is timed
    121 t0 = r_time();
    122 acquire(&benchlock);
    123 w = r_time() - t0;
    124 if (bench.count >= bench.total) {
    125 release(&benchlock);
    126 pop_off();
    127 break;
    128 }
    129 critical(&st, w);
    130 release(&benchlock);
    131 pop_off();
    132 }
    133
    134 st.elapsed = r_time() - start;
    135
    136 // every acquisition is done; these are final.
    137 st.count = bench.count;
    138 st.overlaps = bench.overlaps;
    139 st.maxrun = bench.maxrun;
    140 acquire(&runlock);
    141 running--;
    142 release(&runlock);
    143 if (copyout(p->pagetable, p->sz, addr, (char *)&st, sizeof(st)) < 0)
    144 return -1;
    145 return 0;
    146}

    kernel/lockbench.h

    @@ -0,0 +1,12 @@
    1// What lockbench() reports to each caller.
    2struct lbstat {
    3 int n; // acquisitions by this caller
    4 int hart[NCPU]; // ... made on each hart
    5 uint64 maxwait; // longest wait, in ticks of the time CSR
    6 uint64 waited; // all waits added up
    7 uint64 elapsed; // start line to finish, in ticks
    8 // the same for every caller:
    9 int count; // acquisitions in all
    10 int overlaps; // times two harts were inside at once
    11 int maxrun; // most acquisitions in a row by one hart
    12};

    kernel/syscall.c

    @@ -102,8 +102,9 @@ extern uint64 sys_unlink(void);
    102102extern uint64 sys_link(void);
    103103extern uint64 sys_mkdir(void);
    104104extern uint64 sys_close(void);
    105105extern uint64 sys_sync(void);
    106extern uint64 sys_lockbench(void);
    106107
    107108// An array mapping syscall numbers from syscall.h
    108109// to the function that handles the system call.
    109110static uint64 (*syscalls[])(void) = {
    @@ -129,8 +130,9 @@ static uint64 (*syscalls[])(void) = {
    129130 [SYS_link] = sys_link,
    130131 [SYS_mkdir] = sys_mkdir,
    131132 [SYS_close] = sys_close,
    132133 [SYS_sync] = sys_sync,
    134 [SYS_lockbench] = sys_lockbench,
    133135 // clang-format on
    134136};
    135137
    136138void

    kernel/syscall.h

    @@ -20,4 +20,5 @@
    2020#define SYS_link 19
    2121#define SYS_mkdir 20
    2222#define SYS_close 21
    2323#define SYS_sync 22
    24#define SYS_lockbench 23

    user/user.h

    @@ -1,7 +1,8 @@
    11#define SBRK_ERROR ((char *)-1)
    22
    33struct stat;
    4struct lbstat;
    45
    56// system calls
    67int fork(void);
    78int exit(int) __attribute__((noreturn));
    @@ -24,8 +25,9 @@ int getpid(void);
    2425char *sys_sbrk(int, int);
    2526int pause(int);
    2627int uptime(void);
    2728int sync(void);
    29int lockbench(int, int, struct lbstat *);
    2830
    2931// ulib.c
    3032int stat(const char *, struct stat *);
    3133char *strcpy(char *, const char *);

    user/usys.pl

    @@ -42,4 +42,5 @@ entry("getpid");
    4242entry("sbrk");
    4343entry("pause");
    4444entry("uptime");
    4545entry("sync");
    46entry("lockbench");
  2. 143ae1a Add locktest, which runs lockbench in three processes

    Makefile

    @@ -150,8 +150,9 @@ UPROGS=\
    150150 $U/_logstress\
    151151 $U/_forphan\
    152152 $U/_dorphan\
    153153 $U/_sync\
    154 $U/_locktest\
    154155
    155156fs.img: mkfs/mkfs README $(UPROGS)
    156157 mkfs/mkfs fs.img README $(UPROGS)
    157158

    user/locktest.c

    @@ -0,0 +1,80 @@
    1// locktest [nproc [total]]: nproc processes share one kernel
    2// spinlock through lockbench(); report how evenly they got it.
    3
    4#include "kernel/types.h"
    5#include "kernel/param.h"
    6#include "kernel/lockbench.h"
    7#include "user/user.h"
    8
    9int
    10main(int argc, char *argv[])
    11{
    12 int nproc = 3, total = 300000;
    13 int fds[2], sum = 0, overlaps = 0, count = 0, maxrun = 0;
    14 int hart[NCPU];
    15 uint64 elapsed = 0;
    16 struct lbstat st;
    17
    18 if (argc > 1)
    19 nproc = atoi(argv[1]);
    20 if (argc > 2)
    21 total = atoi(argv[2]);
    22 if (nproc < 1 || nproc > 8 || total < 1) {
    23 printf("usage: locktest [nproc [total]]\n");
    24 exit(1);
    25 }
    26 printf("locktest: %d processes, %d acquisitions of one lock\n", nproc,
    27 total);
    28
    29 if (lockbench(0, total, 0) < 0) {
    30 printf("locktest: another lockbench run is in progress\n");
    31 exit(1);
    32 }
    33 pipe(fds);
    34 for (int i = 0; i < nproc; i++) {
    35 if (fork() == 0) {
    36 close(fds[0]);
    37 if (lockbench(nproc, total, &st) < 0)
    38 st.n = -1;
    39 write(fds[1], &st, sizeof(st));
    40 exit(0);
    41 }
    42 }
    43 close(fds[1]);
    44
    45 memset(hart, 0, sizeof(hart));
    46 for (int i = 0; i < nproc; i++) {
    47 if (read(fds[0], &st, sizeof(st)) != sizeof(st) || st.n < 0) {
    48 printf("locktest: lockbench failed: FAIL\n");
    49 exit(1);
    50 }
    51 printf("locktest: process %d: %d acquisitions, wait mean %lu ns, "
    52 "longest %lu us\n",
    53 i, st.n, st.n ? st.waited * 100 / st.n : 0, st.maxwait / 10);
    54 sum += st.n;
    55 for (int h = 0; h < NCPU; h++)
    56 hart[h] += st.hart[h];
    57 if (st.elapsed > elapsed)
    58 elapsed = st.elapsed;
    59 overlaps = st.overlaps;
    60 count = st.count;
    61 maxrun = st.maxrun;
    62 }
    63 for (int i = 0; i < nproc; i++)
    64 wait(0);
    65
    66 printf("locktest: per hart:");
    67 for (int h = 0; h < NCPU; h++)
    68 if (hart[h])
    69 printf(" %d", hart[h]);
    70 printf("\n");
    71 printf("locktest: longest run by one hart: %d\n", maxrun);
    72 printf("locktest: %lu ms, %lu acquisitions per ms\n", elapsed / 10000,
    73 elapsed >= 10000 ? (uint64)total / (elapsed / 10000) : 0);
    74
    75 int ok = sum == total && count == total && overlaps == 0;
    76 printf("locktest: mutual exclusion (%d counted, %d overlaps): %s\n", sum,
    77 overlaps, ok ? "OK" : "FAIL");
    78 printf("locktest: fairness is not checked: compare the numbers above\n");
    79 exit(ok ? 0 : 1);
    80}
  3. 413f3f5 Turn the spinlock into a ticket lock

    kernel/spinlock.c

    @@ -11,31 +11,40 @@
    1111void
    1212initlock(struct spinlock *lk, char *name)
    1313{
    1414 lk->name = name;
    15 lk->locked = 0;
    15 lk->next = 0;
    16 lk->serving = 0;
    1617 lk->cpu = 0;
    1718}
    1819
    1920// Acquire the lock.
    2021// Loops (spins) until the lock is acquired.
    2122void
    2223acquire(struct spinlock *lk)
    2324{
    25 uint my;
    26
    2427 push_off(); // disable interrupts to avoid deadlock.
    2528 if (holding(lk))
    2629 panic("acquire");
    2730
    28 // On RISC-V, __atomic_exchange_n turns into an atomic swap:
    31 // Take a ticket. On RISC-V, __atomic_fetch_add turns into
    32 // one atomic add that returns the old value:
    2933 // a5 = 1
    30 // s1 = &lk->locked
    31 // amoswap.w.aq a5, a5, (s1)
    32 //
    33 // Passing __ATOMIC_ACQUIRE to __atomic_exchange_n tells
    34 // the C compiler and the processor to not move loads or stores
    35 // past this point, to ensure that the critical section's memory
    36 // references happen strictly after the lock is acquired.
    37 while (__atomic_exchange_n(&lk->locked, 1, __ATOMIC_ACQUIRE) != 0)
    34 // s1 = &lk->next
    35 // amoadd.w a4, a5, (s1)
    36 // so every caller gets a different number, even if
    37 // several harts ask at the same moment.
    38 my = __atomic_fetch_add(&lk->next, 1, __ATOMIC_RELAXED);
    39
    40 // Wait for our turn. __ATOMIC_ACQUIRE keeps the critical
    41 // section's loads and stores after the load that sees our
    42 // number; on RISC-V it is a load and then a fence:
    43 // lw a5, 0(a5)
    44 // fence r,rw
    45 // Compare with ==, so that the counters may wrap.
    46 while (__atomic_load_n(&lk->serving, __ATOMIC_ACQUIRE) != my)
    3847 ;
    3948
    4049 // Record info about lock acquisition for holding() and debugging.
    4150 lk->cpu = mycpu();
    @@ -49,29 +58,16 @@ release(struct spinlock *lk)
    4958 panic("release");
    5059
    5160 lk->cpu = 0;
    5261
    53 // Release the lock, equivalent to lk->locked = 0.
    54 //
    55 // This code doesn't use a C assignment, since the C standard
    56 // implies that an assignment might be implemented with
    57 // multiple store instructions.
    58 //
    59 // On RISC-V, __atomic_store_n turns into a single atomic store:
    60 // s1 = &lk->locked
    61 // fence rw,w
    62 // sw zero,0(s1)
    63 //
    64 // The __ATOMIC_RELEASE argument to __atomic_store_n tells the
    65 // the C compiler and the CPU to not move loads or stores past
    66 // this point, to ensure that all the stores in the critical
    67 // section are visible to other CPUs before the lock is released,
    68 // and that loads in the critical section occur strictly before
    69 // the lock is released.
    70 //
    71 // On RISC-V, this generates a fence instruction before the store:
    62 // Serve the next ticket. Only the holder writes serving,
    63 // so a plain load and a single store are enough; the
    64 // __ATOMIC_RELEASE store makes the critical section's
    65 // loads and stores visible to other CPUs before it:
    66 // a4 = &lk->serving
    7267 // fence rw,w
    73 __atomic_store_n(&lk->locked, 0, __ATOMIC_RELEASE);
    68 // sw a5, 0(a4)
    69 __atomic_store_n(&lk->serving, lk->serving + 1, __ATOMIC_RELEASE);
    7470
    7571 pop_off();
    7672}
    7773
    @@ -80,9 +76,9 @@ release(struct spinlock *lk)
    8076int
    8177holding(struct spinlock *lk)
    8278{
    8379 int r;
    84 r = (lk->locked && lk->cpu == mycpu());
    80 r = (lk->next != lk->serving && lk->cpu == mycpu());
    8581 return r;
    8682}
    8783
    8884// push_off/pop_off are like intr_off()/intr_on() except that they are matched:

    kernel/spinlock.h

    @@ -1,7 +1,10 @@
    1// Mutual exclusion lock.
    1// Mutual exclusion lock: a ticket lock.
    2// A hart takes the next ticket and waits until its number is
    3// served, so harts get the lock in the order they asked.
    24struct spinlock {
    3 uint locked; // Is the lock held?
    5 uint next; // next ticket to hand out
    6 uint serving; // ticket now allowed to hold the lock
    47
    58 // For debugging:
    69 char *name; // Name of lock.
    710 struct cpu *cpu; // The cpu holding the lock.
  4. 9a2d26c Start every lock's tickets just below the wrap

    kernel/spinlock.c

    @@ -7,14 +7,20 @@
    77#include "riscv.h"
    88#include "proc.h"
    99#include "defs.h"
    1010
    11// The ticket counters start 256 below the point where they
    12// wrap around to 0, so every lock wraps soon after boot, and a
    13// comparison that cannot cope with the wrap fails at once,
    14// not after 2^32 acquisitions.
    15#define TICKET0 0xffffff00
    16
    1117void
    1218initlock(struct spinlock *lk, char *name)
    1319{
    1420 lk->name = name;
    15 lk->next = 0;
    16 lk->serving = 0;
    21 lk->next = TICKET0;
    22 lk->serving = TICKET0;
    1723 lk->cpu = 0;
    1824}
    1925
    2026// Acquire the lock.

6. Verify and measure

On the branch (ext/20-ticketlock, 4 commits), built with the project toolchain and run on three harts (-smp 3 -m 128M), in one boot:

$ locktest
locktest: 3 processes, 300000 acquisitions of one lock
locktest: process 0: 101214 acquisitions, wait mean 768 ns, longest 87 us
locktest: process 1: 98549 acquisitions, wait mean 786 ns, longest 75 us
locktest: process 2: 100237 acquisitions, wait mean 781 ns, longest 88 us
locktest: per hart: 103565 94352 102083
locktest: longest run by one hart: 24
locktest: 203 ms, 1477 acquisitions per ms
locktest: mutual exclusion (300000 counted, 0 overlaps): OK
locktest: fairness is not checked: compare the numbers above
$ usertests -q
usertests starting
[...]
test lazy_sbrk: OK
test partial_write: OK
test unlinkcwd: OK
ALL TESTS PASSED
$ locktest
locktest: 3 processes, 300000 acquisitions of one lock
locktest: process 0: 101462 acquisitions, wait mean 810 ns, longest 74 us
locktest: process 1: 102318 acquisitions, wait mean 801 ns, longest 43 us
locktest: process 2: 96220 acquisitions, wait mean 858 ns, longest 87 us
locktest: per hart: 96220 105315 98465
locktest: longest run by one hart: 32
locktest: 216 ms, 1388 acquisitions per ms
locktest: mutual exclusion (300000 counted, 0 overlaps): OK
locktest: fairness is not checked: compare the numbers above

A reset while a run is in progress is refused (another boot of the same kernel; the background run’s output arrives after the second prompt):

$ locktest 3 3000000 &
$ locktest: 3 processes, 3000000 acquisitions of one lock
locktest
locktest: 3 processes, 300000 acquisitions of one lock
locktest: another lockbench run is in progress
$ locktest: process 0: 1005673 acquisitions, wait mean 827 ns, longest 2014 us
[...]
locktest: mutual exclusion (3000000 counted, 0 overlaps): OK
locktest: fairness is not checked: compare the numbers above
locktest
locktest: 3 processes, 300000 acquisitions of one lock
[...]
locktest: mutual exclusion (300000 counted, 0 overlaps): OK

Without runlock, the same two commands print panic: release.

usertests -q passing on three harts means every lock in the kernel, including every p->lock handed across swtch, works as a ticket lock, and every busy one crossed the wrap during the run (a second boot, with gdb attached after usertests, found every lock it printed wrapped except cons.lock). locktest crosses the wrap of benchlock with three harts contending and found every acquisition counted once. Each commit builds on its own; commit 2 (the original lock with the instrument) also passes usertests -q.

Shares need not be equal: in another run of this lock, process 2 got 89,392 acquisitions and the others about 105,000. A FIFO lock serves whoever is in line; it does not guarantee equal shares to a process that is sometimes not in line (preempted between iterations, or its host thread delayed).

Aggregate numbers (three processes × 300,000 acquisitions, three boots of each kernel, alternating, three locktest runs per boot). These kernels lacked commit 1’s reset check, which adds only a check-in and check-out under runlock around each run and leaves the measured loop unchanged:

swap loop (commit 2) ticket lock (commit 4)
smallest / largest share of one process 97,150 / 102,533 97,123 / 102,545
mean wait of one process 739–902 ns 863–996 ns
longest single wait in a run 14 µs – 4,004 µs 19–144 µs
longest run by one hart (see below) 14–54 6–36
throughput (acquisitions per ms) 1,351–1,630 1,209–1,351

On QEMU both locks share the lock nearly evenly in aggregate. The swap loop does not starve anyone here. Run lengths do not separate the locks either: other runs of the ticket lock gave 68, 72 and 86, because a waiter that is not in line (preempted, or its host thread delayed) lets the other harts take turn after turn under either lock. The longest waits (milliseconds) and the throughput track what else the computer is doing more than the lock: other builds of the same lock gave 1,115 and 1,010 acquisitions per ms, builds differing by one line gave as little as 591 (measured while the computer was busy with other work; your times will differ), and in the instrumented builds below the ticket lock was the faster one (1,408–1,754 against 1,107–1,345). We do not claim a throughput difference.

Per acquisition. A scratch copy of each kernel (never the branch) counted, inside acquire, how many other acquisitions of the same lock happened between the moment a hart asked and the moment it got in, and how many times it went round the spin loop (a counter acqs in struct spinlock, incremented by each new holder, and two per-hart fields to hand the numbers to lockbench). For the ticket lock the count starts right after the amoadd, for the swap loop right before the first amoswap: in both cases, the moment the hart joins the contest. Nine runs of three processes per lock:

swap loop ticket lock
most acquisitions that passed one waiter (per run) 12, 4, 4, 126, 7, 4, 7, 14, 6 2 in every run
acquisitions passed by 2 or more (per run of 300,000) about 4,900–7,200 47–179
acquisitions passed by 3 or more (per run) 56–216 0
mean spin iterations per acquisition 19–22 40–50

The ticket lock’s bound is exact: with three harts and interrupts off while waiting, at most two tickets can be ahead of yours, and 2.7 million acquisitions never saw more. The swap loop usually behaves too (about 98% of acquisitions were passed by at most one other), but nothing stops the occasional waiter from losing 126 races in a row. With two processes the bound for tickets is 1, and it held; the swap loop’s worst was 3.

Tickets spin about twice as many iterations per acquisition, because a hart that releases and immediately asks again now goes to the back of the line and waits, where under the swap loop it often walked straight back in. But iterations are not time: a ticket iteration is a load and a fence, a swap-loop iteration an AMO, and in these same instrumented builds the mean wait of one process was not longer with tickets (709–905 ns) than with the swap loop (865–1,098 ns).

What QEMU cannot show. QEMU’s multi-threaded TCG runs each hart as a host thread and does not model RISC-V caches. On real hardware the swap loop’s unfairness comes largely from cache-line ownership, and the ticket lock’s cost from every waiter re-fetching the serving line after each release; neither is modelled. The FIFO property, which is a property of the code, shows exactly.

7. Go further