Slot Ownership Typestates¶
Coming from lockfreequeues v5?
See From lockfreequeues v5 for the package rename, import path changes, and the static-thread-affinity endpoint API.
Overview¶
Slot ownership typestates track slot ownership through the type system, so the most common slot-misuse footguns (use-after-commit, double-commit, commit without write) are caught by the compile-time CFG verifier. Some combinations (notably cardinality-axis cross-products) still rely on runtime invariants — see the source comments in bqueue.nim for the precise envelope.
Producer-Side Flow¶
flowchart LR
start([tryClaimSlot]) -->|ckClaimed| Claimed[SlotClaimed]
start -->|ckSegmentFull| Full([SegmentFull: allocate new segment])
start -->|ckRetry| Retry([retry CAS])
Claimed -->|writeItem| Written[SlotWritten]
Written -->|commitSlot| Committed[SlotCommitted: data visible to consumers]
States¶
- SlotClaimed: Exclusive write access after winning CAS
- Guarantees: This thread owns the slot index
-
Linear type: Can only use once (via
sinkparameter) -
SlotWritten: Data written but not visible yet
-
Guarantees: Data in slot, but consumers blocked
-
SlotCommitted: Data visible to consumers
- Guarantees: Consumers can now read
Transitions¶
# Try to claim a slot
let result = tryClaimSlot(segment)
case result.kind:
of ckClaimed:
# Won CAS - have exclusive access
let claimed = result.claimed
# Write data (consumes claimed token)
let written = writeItem(claimed, myData)
# Commit (make visible to consumers)
let committed = commitSlot(written)
of ckSegmentFull:
# Allocate new segment and retry
discard
of ckRetry:
# Lost CAS race, try again
discard
Consumer-Side Flow¶
flowchart LR
start([tryClaimForRead]) -->|committed| Avail[SlotAvailable]
start -->|MPSC: not yet committed| Pending[SlotPending]
Pending -->|waitForCommit| Avail
Avail -->|readItem| Data([T: data])
States¶
- SlotAvailable: Slot ready to read
-
Guarantees: Producer has committed, data is valid
-
SlotPending: Claimed but not committed (MPSC only)
- Need to wait for producer to commit
Transitions¶
# Try to claim a slot for reading
let result = tryClaimForRead(segment)
case result.kind:
of pkAvailable:
# Data ready - can read immediately
let data = readItem(result.available)
of pkPending:
# Producer hasn't committed yet - wait
let available = waitForCommit(result.pending)
let data = readItem(available)
of pkExhausted:
# Segment empty, advance to next
discard
of pkEmpty:
# Queue empty
discard
Safety Guarantees¶
| Guarantee | How Enforced |
|---|---|
| No write without CAS | writeItem requires SlotClaimed, only from successful CAS |
| No double write | sink parameter consumes SlotClaimed token |
| No commit without write | commitSlot requires SlotWritten |
| No read without CAS | readItem requires SlotAvailable, only from successful CAS |
| No read uncommitted data | SlotAvailable only when slot is published — sequence-counter match for bounded variants, committed-flag set for unbounded variants |
Publication Protocol: Bounded vs Unbounded¶
The slot-ownership typestates above describe the shape of producer and consumer ownership transitions; the underlying mechanism that decides when a slot is "claimable" or "available" differs between bounded and unbounded queues.
Bounded multi-cardinality shapes (SPMC, MPSC, MPMC)¶
Bounded queues use the Vyukov per-slot sequence counter protocol. Each
slot carries an Atomic[uint64] whose value encodes the slot's generation
and phase:
- For slot
iwith capacityN, the producer-ready value isgen * N + i; after a producer commits, the value advances togen * N + i + 1(consumer-ready). After a consumer reads, it advances to(gen + 1) * N + i(producer-ready again, next generation). - Producers CAS the tail cursor only when the target slot's sequence equals
tail(this generation's producer-ready value); consumers CAS the head cursor only when the target slot's sequence equalshead + 1(this generation's consumer-ready value). - Generation rollover races are structurally impossible: a stale consumer
from generation
gthat wakes up after the slot has been recycled into generationg + 1will see a sequence value from generationg + 1and refuse to claim. This eliminates the silent-duplicate-delivery race that the old single-bit committed-flag protocol suffered under wraparound. - Implementation lives in
src/lockfreequeues/typestates/slot_seq_n.nimand is paired withMPMCCellArrayNfor slot storage.
Unbounded multi-cardinality shapes (SPMC, MPSC, MPMC)¶
Unbounded queues use a per-slot single-bit committed flag inside each segment. The segment-local view is unchanged from the 3.x layout because segments are single-use linked nodes — once a segment is exhausted it is retired via DEBRA and never recycled, so there is no generation-rollover hazard for the committed flag to defend against.
- A producer writes to slot
i, then setscommitted[i] = truewith a release store. - A consumer that observes the head/tail cursors pointing past slot
iperforms an acquire load oncommitted[i]and only reads the slot once the flag is set. If the flag is unset (the producer has claimed but not yet published), the consumer waits or retries. - Cross-segment hazards (segment retirement, head pointer advance, etc.) are handled by DEBRA epoch reclamation, not by the committed flag.
Type System Enforcement¶
The type system makes these errors impossible:
# ERROR: Cannot write without claiming
let written = writeItem(segment, data) # No SlotClaimed token!
# ERROR: Cannot reuse claimed token
let claimed = tryClaimSlot(segment).claimed
let written1 = writeItem(claimed, data1)
let written2 = writeItem(claimed, data2) # claimed already consumed!
# ERROR: Cannot commit without writing
let committed = commitSlot(claimed) # Need SlotWritten, not SlotClaimed!
# ERROR: Cannot read without claiming
let data = readItem(segment, 0) # No SlotAvailable token!
Integration with DEBRA epoch states¶
The slot-ownership typestates are an internal implementation detail; they
are not part of the user-facing v5.0.0 surface. They live under
src/lockfreequeues/typestates/ and are composed by the unified Queue
generic's push / pop implementations, not invoked directly by
application code.
For the multi-cardinality unbounded shapes, the push-side typestate progression is woven together with DEBRA's pin / unpin epoch states: a push context carries the pinned thread handle and epoch alongside the slot-ownership token, so the type system enforces all of these at once:
- Must be pinned to push — the context only exists after the calling
thread has registered (via
attach()/bindConsumer()) and pinned an epoch. - Must claim a slot to write —
writeItemrequires the slot-claimed token produced by a successful CAS. - Invariants are enforced together — the epoch pin and the slot ownership are threaded through the same linear typestate chain, so neither can be dropped without the other.
MPSC implementation progression¶
The MPSC push typestate is defined in
src/lockfreequeues/typestates/mpsc_push.nim and is shared by both
bounded BQueue[T, ccMulti, ccSingle, ...] and unbounded
Queue[T, ccMulti, ccSingle, ...]. The state machine has three states:
- MPSCPushStart: entry point; no slot held yet.
- MPSCPushSlotClaimed: slot won via CAS; ready to write and commit.
- MPSCPushFull: terminal; generation full (bounded) or segment full (unbounded) — caller advances or allocates.
The tryClaim transition takes MPSCPushStart and returns an
MPSCPushClaimResult that resolves to either MPSCPushSlotClaimed
(slot won), MPSCPushFull (terminal), or back to MPSCPushStart (CAS
race — retry). Each transition consumes its predecessor via a sink
parameter, so the compiler rejects any code path that skips a state or
double-uses a claim. Application code never touches these states
directly; they are the compile-time scaffolding behind a single
producer.push(item) call.
Performance¶
Slot ownership typestates are zero-cost abstractions:
- All types are compile-time only
- No runtime overhead
- Same assembly as hand-written CAS code
- Just adds compile-time safety
Future Extensions¶
Potential enhancements:
- Batch operations: Claim multiple slots at once
- Backpressure: SlotClaimFailed -> wait/retry logic
- Priority: Different claim strategies based on priority
- Monitoring: Track claim success rates