Spin Locks
With atomic operations and invariants, we can build many useful abstractions for concurrency programming. In this chapter, we’ll look at how to build a spin lock for mutual exclusion.
Representing a Lock
The main idea of the implementation is to represent a lock using a
mutable machine word, where the value 0ul
signifies that the lock
is currently released; and 1ul
signifies that the lock is currently
acquired. To acquire a lock, we’ll try to atomically compare-and-swap,
repeating until we succeed in setting a 1ul
and acquiring the
lock. Releasing the lock is simpler: we’ll just set it to 0ul
(though we’ll explore a subtlety on how to handle double releases).
From a specification perspective, a lock is lot like an invariant: the
predicate type lock_alive l p
states that the lock protects some
property p
. Acquiring the lock provides p
to the caller; while
releasing the lock requires the caller to give up ownership of
p
. The runtime mutual exclusion is enforced by the acquire
spinning, or looping, until the lock is available.
We’ll represent a lock as a pair of reference to a U32.t
and an
invariant:
let maybe (b:bool) (p:vprop) =
if b then p else emp
let lock_inv (r:B.box U32.t) (p:vprop) : v:vprop { is_big p ==> is_big v } =
exists* v. B.pts_to r v ** maybe (v = 0ul) p
noeq
type lock = {
r:B.box U32.t;
i:iref;
}
let lock_alive (l:lock) (p:vprop) =
inv l.i (lock_inv l.r p)
The predicate lock_inv r p
states:
We hold full permission to the
r:box U32.t
; andIf
r
contains0ul
, then we also havep
.
The lock itself pairs the concrete mutable state box U32.t
with an
invariant reference i:iref
, where the lock_alive l p
predicate
states that l.i
names an invariant for lock_alive l.r p
.
Creating a lock
To create a lock, we implement new_lock
below. It requires the
caller to provide p
, ceding ownership of p
to the newly
allocated l:lock
fn new_lock (p:vprop { is_big p })
requires p
returns l:lock
ensures lock_alive l p
{
let r = B.alloc 0ul;
fold (maybe (0ul = 0ul) p);
fold (lock_inv r p);
let i = new_invariant (lock_inv r p);
let l = {r;i};
rewrite (inv i (lock_inv r p)) as
(inv l.i (lock_inv l.r p));
fold lock_alive;
l
}
Importantly, since allocating a lock involves allocating an invariant
that protects the predicate p
, we need p
to be boxable
.
Some notes on the implementation:
We heap allocate a reference using
Box.alloc
, since clearly, the lock has to live beyond the scope of this function’s activation.We use
new_invariant
to create aninv i (lock_inv r p)
, and package it up with the newly allocated reference.
Duplicating permission to a lock
Locks are useful only if they can be shared between multiple
threads. The lock_alive l p
expresses ownership of a lock—but,
since lock_alive
is just an invariant, we can use dup_inv
to
duplicate lock_alive
.
ghost
fn dup_lock_alive (l:lock) (p:vprop)
requires lock_alive l p
ensures lock_alive l p ** lock_alive l p
{
unfold lock_alive;
dup_inv l.i (lock_inv l.r p);
fold lock_alive;
fold lock_alive
}
Acquiring a lock
The signature of acquire
is shown below: it says that with
lock_alive l p
, we can get back p
without proving anything,
i.e., the precondition is emp
.
fn rec acquire (#p:vprop) (l:lock)
requires lock_alive l p
ensures lock_alive l p ** p
This may be seem surprising at first. But, recall that we’ve stashed
p
inside the invariant stored in the lock, and acquire
is
going to keep looping until such time as a CAS on the reference in the
lock succeeds, allowing us to pull out p
and return it to the
caller.
The type of a compare-and-swap is shown below, from Pulse.Lib.Reference:
let cond b (p q:vprop) = if b then p else q
atomic
fn cas_box (r:Box.box U32.t) (u v:U32.t) (#i:erased U32.t)
requires Box.pts_to r i
returns b:bool
ensures cond b (Box.pts_to r v ** pure (reveal i == u))
(Box.pts_to r i))
The specification of cas_box r u v
says that we can try to atomically
update r
from u
to v
, and if the operation succeeds, we
learn that the initial value (i
) of r
was equal to u
.
Using cas_box
, we can implement acquire
using a tail-recursive
function:
{
unfold lock_alive;
let b =
with_invariants l.i
returns b:bool
ensures inv l.i (lock_inv l.r p) ** maybe b p
opens (add_inv emp_inames l.i) {
unfold lock_inv;
let b = cas_box l.r 0ul 1ul;
if b
{
elim_cond_true _ _ _;
with _b. rewrite (maybe _b p) as p;
fold (maybe false p);
rewrite (maybe false p) as (maybe (1ul = 0ul) p);
fold (lock_inv l.r p);
fold (maybe true p);
true
}
else
{
elim_cond_false _ _ _;
fold (lock_inv l.r p);
fold (maybe false p);
false
}
};
fold lock_alive;
if b { rewrite (maybe b p) as p; }
else { rewrite (maybe b p) as emp; acquire l }
}
The main part of the implementation is the with_invariants
block.
Its return type
b:bool
and postcondition isinv l.i (lock_inv l.r p) ** maybe b p
, signifying that after a singlecas
, we may havep
if thecas
succeeded, while maintaining the invariant.We open
l.i
to getlock_inv
, and then try acas_box l.r 0ul 1ul
If the
cas_box
succeeds, we know that the lock was initially in the0ul
state. So, fromlock_inv
we havep
, and we can “take it out” of the lock and return it out of block asmaybe true p
. And, importantly, we can trivially restore thelock_inv
, since we know its currently value is1ul
, i.e.,maybe (1ul = 0ul) _ == emp
.If the
cas_box
fails, we just restorelock_inv
and return false.
Outside the with_invariants
block, if the CAS succeeded, then
we’re done: we have p
to return to the caller. Otherwise, we
recurse and try again.
Exercise
Rewrite the tail-recursive acquire
using a while loop.
Releasing a lock
Releasing a lock is somewhat easier, at least for a simple version.
The signature is the dual of acquire
: the caller has to give up
p
to the lock.
fn release (#p:vprop) (l:lock)
requires lock_alive l p ** p
ensures lock_alive l p
{
unfold lock_alive;
with_invariants l.i
returns _:unit
ensures inv l.i (lock_inv l.r p)
opens (add_inv emp_inames l.i) {
unfold lock_inv;
write_atomic_box l.r 0ul;
drop_ (maybe _ _); //double release
fold (maybe (0ul = 0ul) p);
fold (lock_inv l.r p);
};
fold lock_alive
}
In this implementation, release
unconditionally sets the reference
to 0ul
and reproves the lock_inv
, since we have p
in
context.
However, if the lock was already in the released state, it may already
hold p
—releasing an already released lock can allow the caller
to leak resources.
Exercise
Rewrite release
to spin until the lock is acquired, before
releasing it. This is not a particularly realistic design for avoiding
a double release, but it’s a useful exercise.
Exercise
Redesign the lock API to prevent double releases. One way to do this
is when acquiring to lock to give out a permission to release it, and
for release
to require and consume that permission.
Exercise
Add a liveness predicate, with fractional permissions, to allow a lock to be allocated, then shared among several threads, then gathered, and eventually free’d.