47 Proofs about locks — where heavy tools are really needed
What to know first
E-LOCK-NOTYET)spsc is a lock-free single-producer single-consumer ring bufferrelease and acquire synchronise message passingLooking back
Where did chapter 45 say heavy concurrency logic is needed, and why only there?
A. At level 3, and in particular for locks. The discipline of levels 1 and 2 removes two flows touching the same place altogether, so set theory was enough. But a spinlock is a device where two flows really do hammer the same word at the same time. Here discipline cannot give safety; an invariant must. This chapter covers that reasoning — concurrent separation logic.
The need for this chapter, and its context
spsc. Locks are also the topic with the most boundaries in this part.By the end of this chapter
∗ meaning “separate pieces”, Hoare triples for writing specifications, invariants shared by several flows, and ghost tokens that exist only in the proof, not at run time. Starting from the theorem that there cannot be two tokens, you will pick up what the specifications for creating, acquiring and releasing a lock stop. You will also see the two proofs for rwlocks, deadlock freedom with ascending lock order and no starvation under round robin, and the borrowing of a weak-memory program logic to confirm spsc, together with its price.The questions this chapter answers
- If there is a proven lock, why is there no
lockin this edition?
47.1 The vocabulary of separation logic#
In ordinary logic, A ∧ B is “both are true”. Separation logic adds one more connective. A ∗ B reads “A and B are true, and they are about separate pieces of memory”. Claims about memory must imply ownership. x ↦ 3 does not mean “x holds 3” but “I own x and its value is 3”. So x ↦ 3 ∗ y ↦ 4 says x and y are different places, and writing x does not spoil the claim about y. That is local reasoning.
It is the same idea as chapter 12′s exclusivity rule. Actually the order is the reverse — Rust’s borrow checker moved the idea of separation logic into a type system, and this language’s exclusivity rule is in that lineage. chapter 42 is this chapter’s idea brought down to translation time.
| Term | Meaning |
|---|---|
Hoare triple {{{ P }}} e {{{ Q }}} | Running e from a state satisfying P makes Q hold when it ends |
Invariant lock_inv γ lk R | (lk ↦ false ∗ R) ∨ (lk ↦ true) — if open, resource R is inside the invariant; if locked, it is in the locking flow’s hands |
Ghost token locked γ | own γ (Excl ()) — an accounting device absent at run time, circulating only in the proof |
Table 47.1 — The vocabulary of lock proofs
The one-line invariant is the whole lock. R cannot be in two places at once, so only one flow holds R.
acquire — the CAS turns false into true
┌────────────────────────────────────────────┐
│ ▼
open lk ↦ false ∗ R locked lk ↦ true
R sits inside the invariant R and the token locked γ are in the holder's hands
▲ │
└────────────────────────────────────────────┘
release — hand back both the token and R47.2 There cannot be two tokens#
The mathematics. Theorems in LowentLock.v
locked_exclusive : locked γ -∗ locked γ -∗ False. Assuming two tokens is a contradiction. This is the mechanical heart of mutual exclusion.
newlock_spec : {{{ R }}} newlock #() {{{ lk γ, RET lk; is_lock γ lk R }}}. Hand over the resource to protect and you get a lock. Making the lock costs giving up the resource — now the only way to reach it is to acquire the lock.
acquire_spec : {{{ is_lock γ lk R }}} acquire lk {{{ RET #(); locked γ ∗ R }}}. Acquiring gives both the token and the resource. ∗ does the work — the resource received is mine and overlaps with no one’s, so afterwards you reason as if there were one flow.
release_spec : {{{ is_lock γ lk R ∗ locked γ ∗ R }}} release lk {{{ RET #(); True }}}. To release you must return both token and resource, and nothing remains afterwards.
If two flows acquired at once there would be two tokens, which is false. One release specification stops two defects. Releasing without acquiring lacks locked γ and cannot meet the precondition; using the resource after releasing fails because R was already returned.
The proofs share a three-step shape: open the invariant, run the CAS (splitting into success and failure), close the invariant. That the invariant may be opened only at an atomic moment is Iris’s rule, and thanks to it “no other flow can see the invariant briefly broken” is guaranteed.
The mathematics. A practical example — two flows incrementing (incr_spec, two_threads_spec)
acquire ; c ← !c + 1 ; release at the same time. Even incrementing concurrently they finish without races and the program yields a value. A lock-free c ← !c + 1 — the classic lost update — cannot be proven, because c ↦ n can be used only after receiving it from the lock. Honestly: this theorem does not say the result is exactly 2. Saying the exact total needs more ghost counting. The goal of this file was that the lock protects the resource.47.3 Tools only as needed#
| Files | Tool | Why |
|---|---|---|
| Borrowing · loops · races · parallelism · RC11 · blocks · places · laundering · numbers | Pure Coq | Discipline, set theory, lattices and graph decisions suffice |
| Locks · rwlock read side | Iris | Two flows really hammer the same place |
| Weak memory programs · SPSC | iRC11 · gpfsl (borrowed) | Programs must be verified over weak memory |
Table 47.2 — Proof files and tools
This is also why Coq was chosen. Iris is Coq-only and Lean has no mature counterpart. Where the tools that would one day be needed lived decided the proof language.
47.4 rwlock — two proofs of the same sentence#
For the classic rwlock packing three meanings into one word — 0 empty, n > 0 n readers, −1 write-locked — exclusivity of the write lock was proven directly (wlocked_exclusive, wlock_spec, wunlock_spec in LowentRWLock.v). Because CAS succeeds only from 0, “a writer enters while readers are present” is stopped. Most real rwlock defects are exactly that.
Fractional ownership on the read side was already in Iris. Its rwlock interface is written with a fractional predicate Φ : Qp → iProp, and rw_spin_lock is a verified instance. Readers get a fraction Φ q (several at once), writing and reading cannot coexist, and a writer gets all of Φ 1. What is valuable is that “writing and reading are exclusive” has two proofs of the same sentence. Ours shows it by “CAS succeeds only from 0” (close to the implementation); the borrowed one by ghost state (general, and covering fractions). The principle of building two ways and cross-checking (chapter 31) holds once more at the proof level.
47.5 Deadlock freedom and no starvation#
The mathematics. Deadlock freedom (no_deadlock in LowentDeadlock.v)
violating_the_order_deadlocks). ascending order kept (A < B) discipline broken
flow 1 holds A → wants B flow 1 holds A → wants B
flow 2 wants A (waits) flow 2 holds B → wants A
→ flow 1 takes B and goes on → each waits for the other (deadlock)Honestly — the tool does not enforce this discipline. So it is “written this way it is safe”, not “the compiler stops it”. Making it a check would require static orders on locks. And the lock’s own specification (acquiring gives the resource) does not prevent deadlock, because it says “acquiring gives” and not “it will be acquired”. Acquire a lock twice and release once, and the second acquire never arrives.
The mathematics. No starvation (no_starvation in LowentFair.v)
The limits are clear. Cooperative scheduling assumes yielding. Before a task looping forever without yield, the scheduler can do nothing. Priorities and blocking are outside this model too.
A common misconception. Using locks also protects you from deadlock
no_deadlock) holds only under the discipline that every flow takes locks in ascending order only, and this edition’s tool does not enforce that discipline. Take a lock twice and release it once, and the second acquisition never comes. What the proof gives is a design rule, “used this way it is safe”; keeping that rule is still a person’s job.47.6 A borrowed proof in weak memory — spsc#
Iris’s language (heap_lang) is a sequentially consistent model. Proving code that writes weak memory orderings needs iRC11 and gpfsl, the Iris logics over RC11. LowentIRC11.v built the first theorem on them — in a message-passing program written with a release write and an acquire read, the reader always sees the data under RC11. It does not assume sequential consistency. chapter 46′s E4 rose from one execution to a whole program.
With that tool one standard library module was built.
examples/ch47/ring.low
module ring .
rem run: main
use spsc .
proc main input k cap atomic . input al cap allocator . output u8 . effects atomic alloc .
do
let gc option mut slice u8 be alloc_bytes al capacity 16 .
let gb option mut slice u8 be alloc_bytes al capacity 32 .
let go option mut slice u8 be alloc_bytes al capacity 8 .
guard is_some gc . else return 255 .
guard is_some gb . else return 254 .
guard is_some go . else return 253 .
var ctl mut slice u64 be view_array u64 (some_value gc) .
var buf mut slice u64 be view_array u64 (some_value gb) .
var out mut slice u64 be view_array u64 (some_value go) .
let a u64 be spsc.spsc_push k ctl buf 10 .
let b u64 be spsc.spsc_push k ctl buf 32 .
let got u64 be spsc.spsc_pop k ctl buf out .
guard eq got 1 . else return 252 .
let first u64 be index out 0 .
let again u64 be spsc.spsc_pop k ctl buf out .
return narrow u8 (add first (index out 0)) .
end
Output
$ lowentc --run main ring.low
main() = 42
The producer first fills the slot and then publishes the index with release. The consumer reads that index with acquire before reading the slot. lowent_spsc_push_is_correct and lowent_spsc_pop_is_correct say this pair is correct under RC11 (including full and empty checks).
producer consumer
buf[i] ← x (fill the slot first)
tail ← i+1 release ─────────────────▶ tail acquire sees i+1, then
reads buf[i] → it must see x (under RC11)In practice. What “borrowed” means
circ_buff, and mp_instance_gen_inv is gpfsl’s too. What this repository did was state and connect “the memory orderings we emit are exactly what that specification requires”. So gpfsl and the Iris development version join the trusted base. There is one reason not to rebuild it — keep two copies of the same thing and they diverge. And that is why only one was added. MPSC, MPMC and seqlocks have no grounds to borrow, and adding them without grounds would turn the standard library into a warehouse of “written as verified but verified by no one”. This work, once noted as “heavy”, turned out on measuring to be mostly assembling tools with no real risk. Before measuring, you do not know the cost.Q. If there is a proven lock, why is there no lock in this edition?
A. What was proven is a CAS spinlock written in Iris’s language, and that the real runtime’s lock matches it is not verified — the largest gap between this file and the implementation. And the shared lock state type has not been built yet (E-LOCK-NOTYET, chapter 26). Here proof comes first and implementation later. Instead of accepting what does not exist and building it later, it says it does not exist.
47.7 What is not proven#
- The lock model is sequentially consistent. Weak memory orderings are not in the
heap_langlock proofs. But since the default ordering isseq_cst(chapter 46), level 3 code written with defaults is inside that model, and only code writing weak orderings is left to iRC11. Containment holds here too. - There is no guarantee the implementation is this spinlock.
two_threads_specdoes not state the exact total.- Deadlock freedom relies on a discipline people keep. No starvation assumes yielding; priorities and blocking are outside.
- There is no guarantee our compiler emits that
spscprogram. One-to-one emission of memory orderings and not reordering them are confirmed by tests. - Borrowed proofs enter the trusted base. Two checkers (Coq 8.20.1 · Rocq 9.2) giving the same answer reduces the chance of checker defects, but the two files needing gpfsl are confirmed only under Rocq 9.2.
Recap
∗ means separate pieces and gives local reasoning, and a lock is proven with one invariant — “if open the resource is inside, if locked it is in the locking flow’s hands” — plus a ghost token. There cannot be two tokens, acquiring gives token and resource, and releasing returns both. The rwlock’s exclusivity was confirmed by a direct proof and a borrowed one, ascending lock order does not deadlock, and round robin runs tasks within a bound. spsc was confirmed by borrowing a weak-memory program logic, at the price of a larger trusted base.