skip to content

How does an optimistic read with validation (the sequence-lock or seqlock idea) work, and what constraints does it place on the reader's code?

level: seniorimportance: should knowfreq 36%

answer

  1. version counter: odd means writing
  2. read seq, read data, re-read seq
  3. readers never write, so no cache ping-pong
  4. readers never block writers
  5. speculative: no side effects, bound retries, fall back

basics

~20 s

The reader takes no lock: it records a version counter, reads the data, then re-reads the counter. If it changed, or was odd (a write in progress), the read is discarded and retried. Readers must therefore tolerate momentarily inconsistent data — no side effects, no dereferencing possibly-freed pointers, no unbounded work before validation.

solid answer

~60 s

A **sequence lock** pairs the data with a version counter. A writer takes the write lock, increments the counter to an odd value, mutates, then increments again to an even value. A reader loops: read the counter (retry if odd), read the data, read the counter again; if it is unchanged the snapshot was consistent, otherwise retry. The payoff is that readers perform **no writes at all** — no reader counter to increment — so there is no shared cache line bouncing between cores and readers never block writers. That inverts the usual readers-writer trade: it scales with read concurrency and prioritises writers. The cost is that the reader may observe a torn, mid-update state before validation tells it so. So the read body must be *speculative-safe*: pure, no side effects, no external calls, no acting on the values read, no following a pointer that a concurrent writer may have invalidated, and no long or unbounded loops. Typically you copy primitives or a small struct out, validate, then use them. Under heavy writes readers can retry repeatedly, so bound retries and fall back to a real read lock.

code

text · 9 lines
text
attempts = 0
loop:
  s1 = seq
  if (s1 is even):
     snapshot = copyFields()        // no side effects, no pointer chasing
     if (seq == s1): return snapshot
  attempts += 1
  if (attempts > MAX):
     acquireShared(); try { return copyFields() } finally { releaseShared() }

go deeper

for a junior

Describe the version-counter read-check-retry loop and that a failed check means read again.

for a middle

Add the odd/even in-progress encoding and why readers must copy values out rather than act on them.

for a senior

Explain the scalability motive — readers issue no writes, so no cache-line ping-pong — plus the speculative-safety constraints, memory-ordering requirement, and bounded-retry fallback.

for a principal

Position it as inverting the starvation trade in favour of writers and read scalability, and constrain its use to small self-contained data where a reclamation hazard cannot arise.

## The mechanism A sequence lock (seqlock) protects data with a monotonically increasing **sequence counter** used as a version stamp. ``` WRITER: writeLock.acquire() // writers still exclude each other seq += 1 // now ODD = update in progress <memory barrier> ...mutate the data... <memory barrier> seq += 1 // now EVEN = stable again writeLock.release() READER: loop: s1 = seq if (s1 is odd): continue // a writer is mid-update; retry <memory barrier> copy = read the data <memory barrier> if (seq == s1): return copy // no writer ran: snapshot is consistent // else a writer intervened; discard and retry ``` Odd/even encodes 'update in progress'. Equality of the two counter reads proves no write started and finished (or started at all) during the read window. ## Why this is attractive With an ordinary shared/exclusive lock, every reader must *write* to the lock's reader counter twice. On a many-core machine, that single cache line is written by every core, so it ping-pongs constantly and read scalability collapses even though the readers logically never conflict. A seqlock reader only *loads*: the counter line stays shared in every core's cache, and reads scale nearly linearly. Second, readers never block writers — a writer never waits for readers to drain, so the writer-starvation problem of shared/exclusive locks disappears entirely. The trade is reversed: readers can starve under a heavy write rate, since every write invalidates in-flight reads. ## The constraints on the reader — the hard part Because the reader may be looking at a half-written state, everything it does before validation is speculative: 1. **No side effects.** No I/O, no logging, no mutation of other structures, no sending a message. You cannot un-send a message when validation fails. 2. **Do not act on the values.** Copy them out; decide afterwards. In particular, never use an unvalidated value as an array index or loop bound — a torn value can send you far out of range. 3. **No dereferencing potentially freed memory.** If the protected data contains pointers and the writer can free what they point to, following one during a torn read can touch reclaimed memory. This is why seqlocks are usually applied to fixed-size, self-contained data (timestamps, counters, small structs, configuration snapshots) rather than to pointer-rich graphs, or are paired with a deferred-reclamation scheme. 4. **Bounded work.** The read body should be short. A long read window has a high chance of overlapping a write and being invalidated, wasting all the work; and it magnifies the exposure to torn values. 5. **Memory ordering matters.** The counter reads must not be reordered across the data reads, or validation proves nothing. In a language with a defined memory model this means acquire/release-style ordering; in pseudocode, explicit barriers. 6. **Retry policy.** Under a sustained write rate a reader can spin indefinitely. Bound the retries and fall back to acquiring a real read lock (or blocking behind the writer) so that reads have a worst-case bound. ## Optimistic reads as a fast path The most practical deployment is a hybrid: try the optimistic read first, and if validation fails more than a few times, take a conventional shared lock and read normally. That gives the cache-friendly fast path in the common read-mostly case and a guaranteed-progress slow path under write bursts. It also caps the retry cost so the p99 read latency stays bounded. ## Where it fits Good fits: a clock or timestamp read by everything; frequently read configuration or routing snapshots; small statistics structs; kernel-style data where the read path is extremely hot and writes are rare. Poor fits: large or pointer-heavy structures, read bodies that must call out to other components, workloads with frequent writes, and anything where a retry loop's variable latency is unacceptable. It is worth naming the invariant clearly: an optimistic read gives you a *validated snapshot*, not a lock. You learn afterwards whether what you read was real. Every design decision on the reader side follows from that single fact.

  • Why must the reader avoid using a value it has read before validation succeeds?
    Before validation, the reader may be looking at a partially applied update, so fields that should be consistent with one another may not be — a length may not match a buffer, or a pointer may already have been freed. Acting on such a value can crash or corrupt state in a way that no later retry can undo. The safe shape is to copy raw values out, validate the sequence number, and only then interpret them.
  • How does this differ from a shared/exclusive lock in terms of who can starve whom?
    With a shared/exclusive lock, readers hold state that writers must wait to drain, so a stream of readers can starve a writer. With a sequence lock, readers never block anyone, so writers always proceed — but every write invalidates in-flight reads, so a heavy write rate can make readers retry indefinitely instead. The starvation risk is inverted, which is why a bounded retry count with a locking fallback is the usual production shape.

Copying a page while someone may be editing it: note the revision number at the top, copy, then check the number again. Same number, your copy is good; different, throw it away and start over — which is why you must not act on the copy until you have checked.

saying these in an interview costs you the question

  • Thinks the reader takes a lock, just a cheaper one
  • Performs logging, I/O, or mutation inside the optimistic read body
  • Uses an unvalidated value as an index, size, or pointer
  • Ignores memory ordering, so validation proves nothing
  • Loops on retry without a bound or fallback

context