mirror of
https://github.com/nim-lang/Nim.git
synced 2026-08-01 13:09:14 +00:00
766 lines
36 KiB
Lean4
766 lines
36 KiB
Lean4
/-
|
||
YRC Safety Proof — lock-free SATB collector with parallel collections
|
||
=====================================================================
|
||
Self-contained, no Mathlib. Checked with Lean 4 (v4.32.0).
|
||
|
||
Formal model of the safety arguments behind lib/system/yrc.nim in its
|
||
current form: lock-free write barrier, optimistic capture / validate /
|
||
commit, and up to `MaxPar` concurrent collections over disjoint
|
||
CAS-claimed partitions.
|
||
|
||
## What the implementation does (the things we model)
|
||
|
||
Write barrier `nimAsgnYrc(dest, src)`:
|
||
1. direct ATOMIC incRef of src (rc word mutation, visible to all)
|
||
2. atomicExchange dest ← src (graph is immediately current;
|
||
old value read atomically)
|
||
3. buffer dec(old) in a striped queue (deferred — this queue IS the
|
||
snapshot-at-the-beginning log)
|
||
|
||
A collection (any mutator thread can become a collector):
|
||
1. merge queues into rc words, steal candidate roots (under gMergeLock)
|
||
2. CAPTURE: Tarjan SCC traversal; each visited cell is claimed by
|
||
CAS-ing a collection tag into its spare header word (claimCell);
|
||
cells claimed by another ACTIVE collection are not traversed
|
||
(claimCell → -1, deferred via crossPend)
|
||
3. compute deadness per SCC: ext(S) = sumRefs − internal − deadIn
|
||
4. VALIDATE at commit: an SCC is freed only if no queue entry mentions
|
||
a member (dirty check) and every member's rc word is unchanged
|
||
since capture (recheck) — validateDead
|
||
5. COMMIT: nil all slots of dead cells, trialDec edges to survivors,
|
||
wait out concurrent captures (grace period), then free — commitDead
|
||
|
||
## Proof structure
|
||
|
||
§1 Heap model, reachability, the core safety theorem.
|
||
§2 Write barrier: no lost objects.
|
||
§3 Mutator operational semantics and GARBAGE STABILITY: a closed
|
||
(externally unreferenced) set stays closed under every mutator step,
|
||
allocation, and foreign frees. This is why optimistic
|
||
capture/validate/commit is sound and why aborts cost nothing.
|
||
§4 Commit validation arithmetic: validated ext(D) = 0 implies D is
|
||
closed; corollary CROSS-TARGET LIVENESS — a cell referenced from
|
||
outside a collection's partition is never freed by that collection.
|
||
§5 Tag uniqueness and partition disjointness for parallel collections.
|
||
§6 Grace period: no capture ever dereferences a freed cell.
|
||
§7 The asymmetric seq/GC fence (seqs_v2.nim): Dekker-style mutual
|
||
exclusion between seq structure mutations and collections.
|
||
§8 Deadlock freedom for the remaining locks (gMergeLock + stripes) and
|
||
the spin-wait ordering argument.
|
||
-/
|
||
|
||
-- Objects and threads are just natural numbers for simplicity.
|
||
abbrev Obj := Nat
|
||
abbrev Thread := Nat
|
||
|
||
/-! ## §1 Heap model and reachability -/
|
||
|
||
/-- The state of the heap at a point in time. -/
|
||
structure State where
|
||
/-- Physical heap edges: `edges x y` means object `x` has a ref field
|
||
pointing to `y`. Always up-to-date (atomic stores/exchanges). -/
|
||
edges : Obj → Obj → Prop
|
||
/-- Stack roots per thread: local variables and the shared candidate
|
||
roots buffer (both are "external" to any captured subgraph). -/
|
||
roots : Thread → Obj → Prop
|
||
/-- Live allocations. The allocator hands out only unallocated objects;
|
||
captured cells stay allocated until their collection frees them. -/
|
||
allocated : Obj → Prop
|
||
|
||
/-- An object is *reachable* if some thread can reach it via stack roots
|
||
plus heap edges. -/
|
||
inductive Reachable (s : State) : Obj → Prop where
|
||
| root (t : Thread) (x : Obj) : s.roots t x → Reachable s x
|
||
| step (x y : Obj) : Reachable s x → s.edges x y → Reachable s y
|
||
|
||
/-- Directed reachability following physical heap edges only. -/
|
||
inductive HeapReachable (s : State) : Obj → Obj → Prop where
|
||
| refl (x : Obj) : HeapReachable s x x
|
||
| step (x y z : Obj) : HeapReachable s x y → s.edges y z → HeapReachable s x z
|
||
|
||
theorem heapReachable_of_reachable (s : State) (r x : Obj)
|
||
(hr : Reachable s r) (hp : HeapReachable s r x) :
|
||
Reachable s x := by
|
||
induction hp with
|
||
| refl => exact hr
|
||
| step _ _ _ hedge ih => exact Reachable.step _ _ ih hedge
|
||
|
||
/-- An object has an *external reference* if some thread points to it. -/
|
||
def hasExternalRef (s : State) (x : Obj) : Prop :=
|
||
∃ t, s.roots t x
|
||
|
||
/-- Externally anchored: heap-reachable from an externally referenced
|
||
object. This is what deadness computation + survivor rescue computes. -/
|
||
def anchored (s : State) (x : Obj) : Prop :=
|
||
∃ r, hasExternalRef s r ∧ HeapReachable s r x
|
||
|
||
/-- The collector frees `x` only if `x` is not anchored. -/
|
||
def collectorFrees (s : State) (x : Obj) : Prop :=
|
||
¬ anchored s x
|
||
|
||
/-- Every reachable object is anchored. -/
|
||
theorem reachable_is_anchored (s : State) (x : Obj)
|
||
(h : Reachable s x) : anchored s x := by
|
||
induction h with
|
||
| root t x hroot =>
|
||
exact ⟨x, ⟨t, hroot⟩, HeapReachable.refl x⟩
|
||
| step a b _ h_edge ih =>
|
||
obtain ⟨r, h_ext_r, h_path_r_a⟩ := ih
|
||
exact ⟨r, h_ext_r, HeapReachable.step r a b h_path_r_a h_edge⟩
|
||
|
||
/-- **Core Safety Theorem**: freed objects are unreachable. -/
|
||
theorem yrc_safety (s : State) (x : Obj)
|
||
(h_freed : collectorFrees s x) : ¬ Reachable s x := by
|
||
intro h_reach
|
||
exact h_freed (reachable_is_anchored s x h_reach)
|
||
|
||
/-! ## §2 The write barrier
|
||
|
||
`nimAsgnYrc` performs the atomic inc of `src` BEFORE the exchange, so
|
||
there is no instant at which the edge `a → src` exists without src's rc
|
||
accounting for it; and the exchange reads `old` atomically, so two
|
||
racing barriers on the same slot can never both dec the same old value.
|
||
The dec of `old` is deferred: until the next merge, old's rc is merely
|
||
inflated — always conservative. -/
|
||
|
||
/-- Model of `nimAsgnYrc(field a, src)`: `a`'s field pointed to `old`,
|
||
now points to `src`. The graph update is immediate (atomicExchange). -/
|
||
def writeBarrier (s : State) (a old src : Obj) : State :=
|
||
{ s with
|
||
edges := fun x y =>
|
||
if x = a ∧ y = src then True
|
||
else if x = a ∧ y = old then False
|
||
else s.edges x y }
|
||
|
||
/-- Overwriting a slot with nil: only removes an edge. -/
|
||
def storeNil (s : State) (a old : Obj) : State :=
|
||
{ s with
|
||
edges := fun x y =>
|
||
if x = a ∧ y = old then False else s.edges x y }
|
||
|
||
/-- **No Lost Object**: if thread `t` holds `a` and stores `a.f = b`,
|
||
then `b` is reachable afterwards — the exchange publishes the edge
|
||
atomically, so a concurrent collection's survivor rescue traces it. -/
|
||
theorem no_lost_object (s : State) (t : Thread) (a old b : Obj)
|
||
(h_root_a : s.roots t a) :
|
||
Reachable (writeBarrier s a old b) b := by
|
||
apply Reachable.step a b
|
||
· exact Reachable.root t a h_root_a
|
||
· simp [writeBarrier]
|
||
|
||
/-! ## §3 Mutator semantics and garbage stability
|
||
|
||
The heart of optimistic capture/validate/commit is the *garbage
|
||
stability theorem*: a set with no external references cannot acquire
|
||
one later, because mutators can only copy references they can reach.
|
||
Hence a dead set that VALIDATES at commit time stays dead through the
|
||
grace window and until the actual `free` calls — no re-validation is
|
||
needed, and an aborted (dirty) capture merely wasted its own work.
|
||
|
||
Every constructor's precondition encodes the fundamental capability
|
||
restriction: to use a reference you must hold it. `P` is the set of
|
||
cells protected from foreign frees (in yrc: cells stamped with an
|
||
active tag are never freed by another collection — §5). -/
|
||
|
||
def addRoot (s : State) (t : Thread) (x : Obj) : State :=
|
||
{ s with roots := fun t' y => (t' = t ∧ y = x) ∨ s.roots t' y }
|
||
|
||
def delRoot (s : State) (t : Thread) (x : Obj) : State :=
|
||
{ s with roots := fun t' y => if t' = t ∧ y = x then False else s.roots t' y }
|
||
|
||
def allocObj (s : State) (t : Thread) (x : Obj) : State :=
|
||
{ s with
|
||
roots := fun t' y => (t' = t ∧ y = x) ∨ s.roots t' y
|
||
allocated := fun y => y = x ∨ s.allocated y }
|
||
|
||
def freeObj (s : State) (x : Obj) : State :=
|
||
{ s with
|
||
edges := fun u v => if u = x ∨ v = x then False else s.edges u v
|
||
allocated := fun y => if y = x then False else s.allocated y }
|
||
|
||
/-- One step of the concurrent system, as seen by a fixed observer
|
||
protecting the cell set `P`. -/
|
||
inductive MutStep (P : Obj → Prop) (s : State) : State → Prop where
|
||
/-- `a.f = src`: the mutator must hold refs to `a` and `src`. -/
|
||
| write (a old src : Obj)
|
||
(ha : Reachable s a) (hsrc : Reachable s src) :
|
||
MutStep P s (writeBarrier s a old src)
|
||
/-- `a.f = nil`. -/
|
||
| writeNil (a old : Obj) (ha : Reachable s a) :
|
||
MutStep P s (storeNil s a old)
|
||
/-- Copy a reachable ref into a local / the roots buffer. -/
|
||
| rootCopy (t : Thread) (x : Obj) (hx : Reachable s x) :
|
||
MutStep P s (addRoot s t x)
|
||
/-- Drop a local ref (scope exit, roots-buffer unregistration). -/
|
||
| rootDrop (t : Thread) (x : Obj) :
|
||
MutStep P s (delRoot s t x)
|
||
/-- Allocate: the allocator returns only unallocated addresses. -/
|
||
| alloc (t : Thread) (x : Obj) (hfresh : ¬ s.allocated x) :
|
||
MutStep P s (allocObj s t x)
|
||
/-- A DIFFERENT collection frees one of its own dead cells: it is
|
||
unreachable (its own §1 safety) and not protected (§5 partition
|
||
disjointness: it carries the other collection's tag, not ours). -/
|
||
| foreignFree (x : Obj) (hunreach : ¬ Reachable s x) (hprot : ¬ P x) :
|
||
MutStep P s (freeObj s x)
|
||
|
||
/-- Reflexive-transitive closure: an arbitrary interleaving of steps by
|
||
all mutators and all other collections. -/
|
||
inductive MutSteps (P : Obj → Prop) (s : State) : State → Prop where
|
||
| refl : MutSteps P s s
|
||
| tail {s' s'' : State} :
|
||
MutSteps P s s' → MutStep P s' s'' → MutSteps P s s''
|
||
|
||
/-- `S` is *closed*: no thread points into it and no heap edge enters it
|
||
from outside. This is exactly "validated dead set" (§4). -/
|
||
def closed (s : State) (S : Obj → Prop) : Prop :=
|
||
(∀ t x, S x → ¬ s.roots t x) ∧
|
||
(∀ u v, S v → s.edges u v → S u)
|
||
|
||
/-- Members of a closed set are unreachable. -/
|
||
theorem closed_unreachable (s : State) (S : Obj → Prop)
|
||
(h : closed s S) : ∀ x, Reachable s x → ¬ S x := by
|
||
intro x hr
|
||
induction hr with
|
||
| root t x hroot => exact fun hS => h.1 t x hS hroot
|
||
| step a b _ hedge ih => exact fun hS => ih (h.2 a b hS hedge)
|
||
|
||
/-- The invariant carried through the grace window: `S` closed and all
|
||
members still allocated (their memory has not been reused). -/
|
||
def DeadInv (s : State) (S : Obj → Prop) : Prop :=
|
||
closed s S ∧ ∀ x, S x → s.allocated x
|
||
|
||
/-- **One-step stability**: no single action of any mutator, allocator or
|
||
other collection can break the invariant of a closed set. -/
|
||
theorem step_preserves_deadInv (s s' : State) (S : Obj → Prop)
|
||
(hinv : DeadInv s S) (hstep : MutStep S s s') : DeadInv s' S := by
|
||
obtain ⟨hcl, halloc⟩ := hinv
|
||
cases hstep with
|
||
| write a old src ha hsrc =>
|
||
refine ⟨⟨fun t x hS hroot => hcl.1 t x hS hroot, ?_⟩, fun x hS => halloc x hS⟩
|
||
intro u v hSv hedge
|
||
simp only [writeBarrier] at hedge
|
||
by_cases h1 : u = a ∧ v = src
|
||
· exact absurd (h1.2 ▸ hSv) (closed_unreachable s S hcl src hsrc)
|
||
· by_cases h2 : u = a ∧ v = old
|
||
· -- corner case old = src: the "remove old" branch is overridden
|
||
-- by the "add src" branch, so the edge survives — but then
|
||
-- v = old = src is reachable, hence not in S
|
||
simp [h2] at hedge
|
||
have hSsrc : S src := by rw [← hedge, ← h2.2]; exact hSv
|
||
exact absurd hSsrc (closed_unreachable s S hcl src hsrc)
|
||
· simp [h1, h2] at hedge
|
||
exact hcl.2 u v hSv hedge
|
||
| writeNil a old ha =>
|
||
refine ⟨⟨fun t x hS hroot => hcl.1 t x hS hroot, ?_⟩, fun x hS => halloc x hS⟩
|
||
intro u v hSv hedge
|
||
simp only [storeNil] at hedge
|
||
by_cases h2 : u = a ∧ v = old
|
||
· simp [h2] at hedge
|
||
· simp [h2] at hedge
|
||
exact hcl.2 u v hSv hedge
|
||
| rootCopy t x hx =>
|
||
refine ⟨⟨?_, fun u v hSv hedge => hcl.2 u v hSv hedge⟩, fun y hS => halloc y hS⟩
|
||
intro t' y hSy hroot
|
||
simp only [addRoot] at hroot
|
||
cases hroot with
|
||
| inl h => exact absurd (h.2 ▸ hSy) (closed_unreachable s S hcl x hx)
|
||
| inr h => exact hcl.1 t' y hSy h
|
||
| rootDrop t x =>
|
||
refine ⟨⟨?_, fun u v hSv hedge => hcl.2 u v hSv hedge⟩, fun y hS => halloc y hS⟩
|
||
intro t' y hSy hroot
|
||
simp only [delRoot] at hroot
|
||
by_cases h : t' = t ∧ y = x
|
||
· simp [h] at hroot
|
||
· simp [h] at hroot
|
||
exact hcl.1 t' y hSy hroot
|
||
| alloc t x hfresh =>
|
||
refine ⟨⟨?_, fun u v hSv hedge => hcl.2 u v hSv hedge⟩, ?_⟩
|
||
· intro t' y hSy hroot
|
||
simp only [allocObj] at hroot
|
||
cases hroot with
|
||
| inl h => exact hfresh (h.2 ▸ halloc y hSy)
|
||
| inr h => exact hcl.1 t' y hSy h
|
||
· intro y hS
|
||
simp only [allocObj]
|
||
exact Or.inr (halloc y hS)
|
||
| foreignFree x hunreach hprot =>
|
||
refine ⟨⟨fun t y hSy hroot => hcl.1 t y hSy hroot, ?_⟩, ?_⟩
|
||
· intro u v hSv hedge
|
||
simp only [freeObj] at hedge
|
||
by_cases h : u = x ∨ v = x
|
||
· simp [h] at hedge
|
||
· simp [h] at hedge
|
||
exact hcl.2 u v hSv hedge
|
||
· intro y hSy
|
||
simp only [freeObj]
|
||
have hyx : ¬ y = x := fun he => hprot (he ▸ hSy)
|
||
simp [hyx]
|
||
exact halloc y hSy
|
||
|
||
/-- **Garbage Stability Theorem**: once a set is closed, it stays closed
|
||
(and unreusable) under any interleaving of concurrent activity. -/
|
||
theorem deadInv_stable (s s' : State) (S : Obj → Prop)
|
||
(hinv : DeadInv s S) (hsteps : MutSteps S s s') : DeadInv s' S := by
|
||
induction hsteps with
|
||
| refl => exact hinv
|
||
| tail _ hstep ih => exact step_preserves_deadInv _ _ S ih hstep
|
||
|
||
/-- Snapshot garbage cannot be resurrected: members of a set that was
|
||
closed at commit time are unreachable at every later point. -/
|
||
theorem garbage_stability (s s' : State) (S : Obj → Prop)
|
||
(hinv : DeadInv s S) (hsteps : MutSteps S s s') :
|
||
∀ x, S x → ¬ Reachable s' x := by
|
||
intro x hS hr
|
||
exact closed_unreachable s' S (deadInv_stable s s' S hinv hsteps).1 x hr hS
|
||
|
||
/-- **Commit-then-free safety**: if the dead set validated (was closed)
|
||
at commit time, then freeing its members after ANY amount of further
|
||
concurrent activity (the grace window, other collections' frees,
|
||
destructor-driven mutations) satisfies the §1 free condition. -/
|
||
theorem commit_free_safe (s s' : State) (S : Obj → Prop)
|
||
(hinv : DeadInv s S) (hsteps : MutSteps S s s') :
|
||
∀ x, S x → collectorFrees s' x := by
|
||
intro x hS hanch
|
||
obtain ⟨r, ⟨t, hroot⟩, hpath⟩ := hanch
|
||
have hr : Reachable s' x :=
|
||
heapReachable_of_reachable s' r x (Reachable.root t r hroot) hpath
|
||
exact closed_unreachable s' S (deadInv_stable s s' S hinv hsteps).1 x hr hS
|
||
|
||
/-! ## §4 Commit validation arithmetic
|
||
|
||
`computeDeadness` marks an SCC dead when
|
||
ext(S) = sumRefs(S) − internal(S) − deadIn(S) = 0,
|
||
i.e. summed over the whole dead set D (union of dead SCCs):
|
||
Σ_{c∈D} rc(c) = #(edges within D).
|
||
`validateDead` then establishes that the captured rc words are the
|
||
COMMIT-TIME rc values (rc recheck) and that no unmerged queue entry
|
||
mentions a member (dirty check via markDirtyFromQueues — the deferred
|
||
dec queues double as the SATB log; direct incs are atomic rc mutations
|
||
caught by the recheck). Under yrc's invariant "rc counts every
|
||
reference: heap slots, stack refs, and the roots-buffer flag" (the
|
||
roots-buffer refs are excluded by clearing inRootsFlag on the slice
|
||
BEFORE computeDeadness — collectCyclesImpl), we get: every member's rc
|
||
splits into internal references (from D) and external ones, and the
|
||
totals matching forces every external count to zero. -/
|
||
|
||
theorem sum_map_split (l : List Obj) (f g h : Obj → Nat)
|
||
(hp : ∀ c, c ∈ l → f c = g c + h c) :
|
||
(l.map f).sum = (l.map g).sum + (l.map h).sum := by
|
||
induction l with
|
||
| nil => simp
|
||
| cons a l ih =>
|
||
have ha : f a = g a + h a := hp a (by simp)
|
||
have ih' := ih (fun c hc => hp c (List.mem_cons_of_mem a hc))
|
||
simp only [List.map_cons, List.sum_cons]
|
||
omega
|
||
|
||
theorem sum_zero_all (l : List Nat) (h : l.sum = 0) :
|
||
∀ x, x ∈ l → x = 0 := by
|
||
induction l with
|
||
| nil => intro x hx; cases hx
|
||
| cons a l ih =>
|
||
simp only [List.sum_cons] at h
|
||
intro x hx
|
||
cases List.mem_cons.mp hx with
|
||
| inl he => subst he; omega
|
||
| inr hm => exact ih (by omega) x hm
|
||
|
||
/-- **Validation soundness (arithmetic)**: if every member's commit-time
|
||
rc splits as internal + external, and the collector's check
|
||
Σ rc = Σ internal passed, then no member has any external ref. -/
|
||
theorem validated_no_external
|
||
(members : List Obj) (rc inD extIn : Obj → Nat)
|
||
(h_exact : ∀ c, c ∈ members → rc c = inD c + extIn c)
|
||
(h_check : (members.map rc).sum = (members.map inD).sum) :
|
||
∀ c, c ∈ members → extIn c = 0 := by
|
||
have hsplit := sum_map_split members rc inD extIn h_exact
|
||
have hzero : (members.map extIn).sum = 0 := by omega
|
||
intro c hc
|
||
exact sum_zero_all _ hzero (extIn c) (List.mem_map_of_mem hc)
|
||
|
||
/-- **Validated implies closed**: bridging the counts to the graph. The
|
||
two counting premises say what `extIn` MEANS: any stack/root ref and
|
||
any heap edge from a non-member contributes at least one external
|
||
count (this is the rc-exactness established by merge + validate). -/
|
||
theorem validated_closed (s : State) (D : Obj → Prop)
|
||
(members : List Obj) (extIn : Obj → Nat)
|
||
(hmem : ∀ x, D x → x ∈ members)
|
||
(h_roots_counted : ∀ t c, D c → s.roots t c → 1 ≤ extIn c)
|
||
(h_edges_counted : ∀ u c, D c → ¬ D u → s.edges u c → 1 ≤ extIn c)
|
||
(h_zero : ∀ c, c ∈ members → extIn c = 0) :
|
||
closed s D := by
|
||
constructor
|
||
· intro t x hD hroot
|
||
have h1 := h_roots_counted t x hD hroot
|
||
have h2 := h_zero x (hmem x hD)
|
||
omega
|
||
· intro u v hD hedge
|
||
by_cases hu : D u
|
||
· exact hu
|
||
· have h1 := h_edges_counted u v hD hu hedge
|
||
have h2 := h_zero v (hmem v hD)
|
||
omega
|
||
|
||
/-! ## §5 Parallel collections: tags, partitions, cross-target liveness -/
|
||
|
||
/-- Tags are issued from a monotonic counter under gMergeLock
|
||
(startCollection). Distinct issue times give distinct tags, so a
|
||
stale stamp from a finished collection can never be mistaken for a
|
||
different active collection's tag. (The implementation wraps the
|
||
counter at 2³¹; the model assumes no wrap-around while a tag is
|
||
active — an ABA that would need 2³¹ collections to complete during
|
||
one collection's lifetime.) -/
|
||
theorem tags_distinct (issue : Nat → Nat)
|
||
(hmono : ∀ i j, i < j → issue i < issue j) :
|
||
∀ i j, issue i = issue j → i = j := by
|
||
intro i j heq
|
||
cases Nat.lt_trichotomy i j with
|
||
| inl h => have := hmono i j h; omega
|
||
| inr h =>
|
||
cases h with
|
||
| inl h => exact h
|
||
| inr h => have := hmono j i h; omega
|
||
|
||
/-- Each cell's header stores ONE stamp (claimCell CASes the whole
|
||
word), so two active collections with distinct tags claim disjoint
|
||
partitions. -/
|
||
theorem partitions_disjoint (stamp : Obj → Nat) (tagA tagB : Nat)
|
||
(hne : tagA ≠ tagB) :
|
||
∀ x, stamp x = tagA → stamp x = tagB → False := by
|
||
intro x hA hB
|
||
exact hne (hA ▸ hB)
|
||
|
||
/-- **Cross-target liveness**: a cell claimed by collection B but
|
||
referenced from OUTSIDE B's partition is never in B's dead set.
|
||
B's internal count for the cell only includes edges from B's dead
|
||
members; the foreign edge contributes an external count, and
|
||
validation forces external counts to zero — so the cell's SCC fails
|
||
the deadness check (equivalently: it is demoted). This is why
|
||
claimCell may simply refuse foreign-claimed cells (return -1) and
|
||
crossPend defer them: their owner provably keeps them alive this
|
||
round, and re-registration makes them candidates for the next. -/
|
||
theorem cross_target_live (D : Obj → Prop) (claimedB : Obj → Prop)
|
||
(members : List Obj) (extIn : Obj → Nat)
|
||
(s : State)
|
||
(hDsub : ∀ x, D x → claimedB x)
|
||
(hmem : ∀ x, D x → x ∈ members)
|
||
(h_edges_counted : ∀ u c, D c → ¬ D u → s.edges u c → 1 ≤ extIn c)
|
||
(h_zero : ∀ c, c ∈ members → extIn c = 0)
|
||
(u c : Obj) (hedge : s.edges u c) (hu : ¬ claimedB u) :
|
||
¬ D c := by
|
||
intro hDc
|
||
have hDu : ¬ D u := fun h => hu (hDsub u h)
|
||
have h1 := h_edges_counted u c hDc hDu hedge
|
||
have h2 := h_zero c (hmem c hDc)
|
||
omega
|
||
|
||
/-! ## §6 The grace period
|
||
|
||
A concurrent capture holds raw `(slot, value)` snapshots (TraceEntry);
|
||
the value pointer is dereferenced later (header read in claimCell). A
|
||
capture that overlapped our validation may have snapshotted a slot
|
||
that USED to point into our dead set. commitDead therefore waits, for
|
||
every other slot that is in capture phase (gSlotPhase == 1), until
|
||
that capture ends — captures never wait on anyone, so this is bounded.
|
||
|
||
Two obligations:
|
||
(a) captures that started BEFORE our commit are waited out — temporal
|
||
argument below (`grace_no_use_after_free`);
|
||
(b) captures that start AT/AFTER our commit never snapshot a dead
|
||
cell in the first place (`post_commit_snap_misses_dead`): they
|
||
only read slots of cells they claim; our dead cells carry our
|
||
still-active tag, so claimCell refuses them (never traversed),
|
||
and no slot OUTSIDE the dead set points into it (closedness, held
|
||
through the window by §3 stability). -/
|
||
|
||
/-- Any snapshot value read by a post-commit capture comes from a slot
|
||
of a cell that capture claimed; claimed cells are never dead cells
|
||
of another active collection (§5), and the dead set is closed. -/
|
||
theorem post_commit_snap_misses_dead (s : State)
|
||
(D claimedC snap : Obj → Prop)
|
||
(hdisj : ∀ x, claimedC x → ¬ D x)
|
||
(hclosed : closed s D)
|
||
(hsnap : ∀ v, snap v → ∃ u, claimedC u ∧ s.edges u v) :
|
||
∀ v, D v → ¬ snap v := by
|
||
intro v hD hs
|
||
obtain ⟨u, hu, he⟩ := hsnap v hs
|
||
exact hdisj u hu (hclosed.2 u v hD he)
|
||
|
||
/-- One concurrent capture, with its interval in a global time order and
|
||
the set of values it ever snapshots. `derefs x t` = the capture
|
||
reads x's header at time t (always within its interval, always on a
|
||
snapshotted value). -/
|
||
structure CaptureWindow where
|
||
start : Nat
|
||
finish : Nat
|
||
snap : Obj → Prop
|
||
derefs : Obj → Nat → Prop
|
||
|
||
/-- **Grace safety**: no capture dereferences a dead cell at or after
|
||
its free time. `commitT` is when the dead set validated; `freeT` is
|
||
when commitDead's free loop runs. The premises are exactly the
|
||
protocol: (grace) commitDead's spin means any capture that started
|
||
before commit has finished before we free; (miss) §6(b) above. -/
|
||
theorem grace_no_use_after_free
|
||
(C : CaptureWindow) (D : Obj → Prop) (commitT freeT : Nat)
|
||
(h_deref : ∀ x t, C.derefs x t → C.start ≤ t ∧ t ≤ C.finish ∧ C.snap x)
|
||
(h_grace : C.start < commitT → C.finish < freeT)
|
||
(h_miss : commitT ≤ C.start → ∀ x, D x → ¬ C.snap x) :
|
||
∀ x t, D x → C.derefs x t → t < freeT := by
|
||
intro x t hD hd
|
||
obtain ⟨h1, h2, h3⟩ := h_deref x t hd
|
||
cases Nat.lt_or_ge C.start commitT with
|
||
| inl h => have := h_grace h; omega
|
||
| inr h => exact absurd h3 (h_miss h x hD)
|
||
|
||
/-! ## §7 The asymmetric seq/GC fence (seqs_v2.nim)
|
||
|
||
Seq structure mutations (which may FREE the old buffer on realloc)
|
||
must not overlap a collection, but seq-vs-seq and collection-vs-
|
||
collection may run concurrently. The committed fence:
|
||
|
||
mutator (acquireMutatorLock): collector (yrcGcFenceEnter):
|
||
1. FetchAdd gSeqActive[s] SC 1. FetchAdd gGcActive SC
|
||
2. Load gGcActive SC 2. Load gSeqActive[s] SC (each s)
|
||
proceed iff it read 0 proceed when all read 0
|
||
(else back off: FetchSub, spin, retry)
|
||
|
||
Under sequential consistency all four operations occupy positions in
|
||
one total order. Suppose both sides are in their critical sections
|
||
simultaneously (neither has executed its matching FetchSub). The
|
||
mutator read gGcActive = 0 AFTER its own inc: since the collector's
|
||
inc precedes its critical section and no dec intervened, the
|
||
collector's inc must be ordered after the mutator's read — and
|
||
symmetrically for the collector's read. That yields a cycle in the
|
||
total order: -/
|
||
|
||
theorem fence_mutual_exclusion
|
||
(mutInc mutChk gcInc gcChk : Nat) -- positions in the SC total order
|
||
(h_mut_po : mutInc < mutChk) -- program order, mutator
|
||
(h_gc_po : gcInc < gcChk) -- program order, collector
|
||
(h_mut_read0 : mutChk < gcInc) -- mutator read gGcActive = 0
|
||
(h_gc_read0 : gcChk < mutInc) : -- collector read counter = 0
|
||
False := by omega
|
||
|
||
/-! ## §8 Deadlock freedom
|
||
|
||
### Locks
|
||
|
||
The queue producers went lock-free (reserve a slot by fetch-add, then
|
||
publish it: incs with an RMW exchange — the validation peek must see
|
||
every completed inc barrier — decs with a release store of the desc,
|
||
self-protecting via the unexplained rc surplus). The validation peek
|
||
is lock-free too. What remains:
|
||
• gMergeLock (level 0: tag-slot claim + orphan roots)
|
||
• stripes[i].consumerLock (level i + 1, i in 0..N-1: excludes
|
||
DRAINS of the same stripe against each
|
||
other — drains wait out the two-store
|
||
publication window and close each batch
|
||
with a CAS, so no producer coordination
|
||
is needed)
|
||
• gWaitLock (leaf: pairs gWaitCond's wait/broadcast;
|
||
only ever held around a predicate check,
|
||
a wait(), or a broadcast() — never while
|
||
acquiring any other lock, and no other
|
||
lock is held when it is taken)
|
||
|
||
Total order: gMergeLock < consumerLock[0] < consumerLock[1] < ...
|
||
|
||
In fact the current paths never HOLD two of these at once — strictly
|
||
stronger than the ascending-order requirement the theorem needs:
|
||
|
||
**nimIncRefCyclic / nimAsgnYrc / nimSinkYrc / enqueueDec /
|
||
registerLocal / markDirtyFromQueues**: lock-free, no locks at all;
|
||
enqueueDec's overflow calls collectCycles with nothing held. ✓
|
||
**drainStripe**: consumerLock[i] alone; the processing inside
|
||
(trialDec, registerLocal into the thread-local buffer) takes no
|
||
lock. drainAllStripes: consumerLock[i] ascending, released between
|
||
stripes. ✓
|
||
**startCollection / nimYrcThreadTeardown**: drain first (consumerLock,
|
||
released), THEN gMergeLock for the slot claim / orphan spill —
|
||
sequential, never nested. adoptOrphans: gMergeLock alone. ✓
|
||
**validateDead / commitDead**: no locks (candidate re-registration is
|
||
the lock-free registerLocal). ✓
|
||
|
||
### Blocking waits (parked on gWaitCond after a bounded spin)
|
||
|
||
W1 backpressure (startCollection): waits for a free tag slot —
|
||
gMergeLock is RELEASED first; slots free when collections finish
|
||
(finishCollection broadcasts).
|
||
W2 solo gate (runCollection): a non-solo collection waits for
|
||
gSoloCapture = 0 — cleared when the solo collection's CAPTURE
|
||
ends (collectCyclesImpl broadcasts), before its commit.
|
||
W3 grace (commitDead): waits for other slots to leave capture phase
|
||
(broadcast at the phase 1→2 transition and at finish).
|
||
W4 fence (yrcGcFenceEnter): spins for in-flight seq ops — each is a
|
||
short critical section that never blocks (releaseMutatorLock is
|
||
a plain FetchSub).
|
||
|
||
W1–W3 park on gWaitCond: the waiter re-checks its predicate under
|
||
gWaitLock before sleeping, and every state transition that can make a
|
||
predicate true (capture-end, collection-finish) broadcasts under the
|
||
same lock — so a transition either happens before the re-check (the
|
||
waiter never sleeps) or after it (the waiter is inside wait() and is
|
||
woken). No missed wakeups, and the wait-for structure is unchanged.
|
||
|
||
No wait cycle exists: order the blocking conditions by what they wait
|
||
FOR. A capture phase terminates unconditionally (finite traversal, no
|
||
waits inside — claimCell returns -1 immediately on contention). W2
|
||
waits only on a capture; W3 waits only on captures; a collection
|
||
executes W2 BEFORE its own capture and W3 AFTER it, so "X waits (W2)
|
||
on S's capture" and "S waits (W3) on X's capture" cannot hold
|
||
simultaneously: S clears gSoloCapture before entering commit, so by
|
||
the time S is in W3, X has passed W2. W1 waits on full collections,
|
||
which terminate because W2/W3/W4 do. Formally, the lock part is the
|
||
same ascending-order argument as before: -/
|
||
|
||
/-- Lock levels in YRC. -/
|
||
inductive LockId (n : Nat) where
|
||
| mergeLock : LockId n
|
||
| consumerLock (i : Nat) (h : i < n) : LockId n
|
||
|
||
def lockLevel {n : Nat} : LockId n → Nat
|
||
| .mergeLock => 0
|
||
| .consumerLock i _ => i + 1
|
||
|
||
/-- All lock levels are distinct (the order is total and well-defined). -/
|
||
theorem lockLevel_injective {n : Nat} (a b : LockId n)
|
||
(h : lockLevel a = lockLevel b) : a = b := by
|
||
cases a with
|
||
| mergeLock =>
|
||
cases b with
|
||
| mergeLock => rfl
|
||
| consumerLock j hj => simp [lockLevel] at h
|
||
| consumerLock i hi =>
|
||
cases b with
|
||
| mergeLock => simp [lockLevel] at h
|
||
| consumerLock j hj =>
|
||
have : i = j := by simp [lockLevel] at h; exact h
|
||
subst this; rfl
|
||
|
||
/-- gMergeLock has the lowest level. -/
|
||
theorem mergeLock_level_min {n : Nat} (l : LockId n) (h : l ≠ .mergeLock) :
|
||
lockLevel (.mergeLock : LockId n) < lockLevel l := by
|
||
cases l with
|
||
| mergeLock => exact absurd rfl h
|
||
| consumerLock i hi => simp [lockLevel]
|
||
|
||
/-- **Deadlock Freedom** (2-thread wait cycle; N-thread follows by the
|
||
same transitivity on the wait-for chain): impossible when every
|
||
thread acquires locks in strictly ascending level order. -/
|
||
theorem no_deadlock_from_total_order {n : Nat}
|
||
(held₁ waited₁ held₂ waited₂ : LockId n)
|
||
(h1 : lockLevel held₁ < lockLevel waited₁)
|
||
(h2 : lockLevel held₂ < lockLevel waited₂)
|
||
(h_wait1 : waited₁ = held₂)
|
||
(h_wait2 : waited₂ = held₁) :
|
||
False := by
|
||
subst h_wait1; subst h_wait2
|
||
omega
|
||
|
||
/-! ## Summary of verified properties (all QED, no sorry)
|
||
|
||
§1 `yrc_safety` — the collector frees only unanchored objects, which
|
||
no thread can reach. No use-after-free at the graph level.
|
||
§2 `no_lost_object` — the atomically published edge is traced.
|
||
§3 `step_preserves_deadInv`, `deadInv_stable`, `garbage_stability` —
|
||
a closed set stays closed under every mutator write, root
|
||
copy/drop, allocation, and foreign free: snapshot garbage cannot
|
||
be resurrected. `commit_free_safe` — freeing a commit-validated
|
||
dead set after ANY further concurrent activity is safe.
|
||
§4 `validated_no_external`, `validated_closed` — the Σrc = Σinternal
|
||
check plus rc-exactness forces zero external references, i.e. the
|
||
dead set is closed at commit time (feeding §3).
|
||
§5 `tags_distinct`, `partitions_disjoint`, `cross_target_live` —
|
||
concurrent collections own disjoint partitions, and a cell
|
||
referenced across a partition boundary is never freed by its
|
||
owner this round (soundness of claimCell's -1 + crossPend).
|
||
§6 `post_commit_snap_misses_dead`, `grace_no_use_after_free` — with
|
||
commitDead's grace spin, no capture ever dereferences freed
|
||
memory.
|
||
§7 `fence_mutual_exclusion` — the SEQ_CST Dekker pairing in
|
||
seqs_v2.nim excludes seq structure mutation during collection.
|
||
§8 `lockLevel_injective`, `mergeLock_level_min`,
|
||
`no_deadlock_from_total_order` — the remaining locks form a total
|
||
order acquired ascending; spin-waits form an acyclic wait-for
|
||
structure (prose above).
|
||
|
||
## What is NOT proved
|
||
|
||
• Tarjan/SCC implementation correctness: that `capture` computes the
|
||
actual SCCs and that computeDeadness's per-SCC sums equal the model's
|
||
Σrc/Σinternal for the emitted dead set (condensation, sinks-first
|
||
order, deadIn accounting). §4 takes the counts as given.
|
||
• rc-exactness mechanics: that merge + the dirty check + the rc-word
|
||
recheck really imply "commit-time rc = internal + external" (§4's
|
||
h_exact). The argument: rc is only mutated by atomic direct incs
|
||
(caught by the recheck), merged queue entries (queues drained at
|
||
merge; later entries caught by the dirty peek), and the collector's
|
||
own inRootsFlag toggles (excluded from the compared word — see the
|
||
comment above claimCell).
|
||
• The C11 memory model: §7 assumes sequential consistency for the
|
||
SEQ_CST operations (sound: SEQ_CST ops do form a total order) and
|
||
the acquire/release reasoning elsewhere is informal.
|
||
• Liveness/completeness: every dead cycle is EVENTUALLY freed.
|
||
Aborted (dirty) SCCs and crossPend targets are re-registered as
|
||
candidates, so they are re-examined; termination of that loop under
|
||
adversarial mutators is not formalized. Also unproved: termination
|
||
bounds for the four spin-waits (prose in §8).
|
||
• Tag wrap-around: 2³¹ collections completing during one collection's
|
||
lifetime could forge a stale stamp (noted at `tags_distinct`).
|
||
|
||
## Epoch stamps (generational pruning)
|
||
|
||
Commit re-stamps proven-live cells with (epochBase|epoch, survivalAge)
|
||
in the claim word; a capture treats a current-epoch stamp of age ≥
|
||
YrcPromoteAge on a DESCENDANT as an opaque live external and does not
|
||
descend. Soundness needs no new lemmas: a pruned cell is simply an
|
||
uncaptured cell, so the captured set shrinks and every §3–§6 statement
|
||
quantifies over a smaller S. Pruning can only ADD unexplained external
|
||
refs to captured SCCs (a pruned predecessor's refs are never explained
|
||
by internal/deadIn), so it can force a false "live", never a false
|
||
"dead" — the conservative direction. Completeness (bounded float,
|
||
≤ ~2 epochs) rests on four hooks, each keeping a dec-witness
|
||
registered:
|
||
E1 roots never prune: a registered candidate is always fully
|
||
root-scanned, stamps notwithstanding;
|
||
E2 when a collection prunes ANY out-edge, it keeps one member of
|
||
EVERY surviving SCC registered — not just the SCCs that pruned an
|
||
edge themselves. This is broader than it first looks and the
|
||
breadth is load-bearing: a pruned cell is not traced, yet its
|
||
out-edges still count toward its targets' rc, so a pruned cell
|
||
that is ITSELF dead (promoted while live, died later this epoch)
|
||
contributes phantom refs that inflate an UNRELATED SCC's external
|
||
count and misclassify that genuinely-dead SCC as a plain (non-
|
||
prune-source) survivor. Registering only prune-source SCCs
|
||
(the original hook) let such a survivor be re-stamped, dropped
|
||
from the retry set, and orphaned permanently once its last
|
||
prune-source neighbour resolved — a real leak the dumpster `fuzz`
|
||
port surfaced (tests/yrc/tyrc_fuzz_graph.nim: ~1% of allocations
|
||
lost, ORC-clean, un-recoverable even by repeated GC_fullCollect).
|
||
Registering every survivor of a pruning collection closes it; the
|
||
cost is bounded because pruning keeps the captured set small, so
|
||
"every survivor" is only the handful actually traced;
|
||
E3 a commit-time dec into a stamped cell re-registers the target —
|
||
the dec may be the death blow to a cell no collection analyzed;
|
||
E4 explicit full collects advance the epoch first, so all stamps
|
||
are stale and nothing is pruned.
|
||
Not formalized (and E2's original narrow form was empirically wrong —
|
||
see above; the broadened form is validated by the fuzz port across seeds
|
||
and sizes, not machine-checked). The epoch clock counts collections
|
||
(YrcEpochLen=64);
|
||
short epochs (≲ 4) resonate with the adaptive threshold — pruned
|
||
collections are cheap, so collections and hence epoch turns speed up,
|
||
re-tracing MORE than with no stamps — but 64 sits clear of that. A
|
||
work-based clock (advance per N cells traced) was measured and lost on
|
||
long-lived structures: pruning shrinks trace work, so the clock stalls
|
||
exactly when a stale web should be re-examined, trading float for a
|
||
resonance the default length already avoids.
|
||
|
||
Reference: D.F. Bacon and V.T. Rajan, "Concurrent Cycle Collection in
|
||
Reference Counted Systems", ECOOP 2001 — the deadness arithmetic is
|
||
the condensation form of their trial deletion; the capture/validate/
|
||
commit structure and the SATB use of the deferred-dec queues are
|
||
yrc-specific.
|
||
-/
|