skip to content

The Michael-Scott lock-free FIFO queue keeps a permanent dummy node plus separate head and tail pointers, and a thread enqueuing may find the tail pointer lagging one node behind the real last node. Why is the design shaped that way, and what does a thread do when it observes a stale tail?

level: seniorimportance: nice to knowfreq 28%

answer

  1. Two ends → two pointers; dummy node stops them aliasing when empty
  2. Enqueue = link next, then advance tail (two CASes)
  3. Legal invariant: tail points at last node or the one before
  4. See tail.next != null → help advance, then retry
  5. Read the value before swinging head; old node becomes the new dummy

basics

~20 s

A FIFO mutates both ends, so it needs two pointers; the dummy node keeps them from aliasing when the queue is empty, so enqueue and dequeue never fight over one word. Appending takes two steps that cannot be one atomic action, so the tail can lag; any thread that sees a lagging tail advances it for the other thread before proceeding.

solid answer

~60 s

A queue is modified at both ends, so head and tail are separate pointers and the two operations mostly do not contend. The **dummy node** guarantees the queue is never structurally empty: head and tail always reference a real node, so an empty queue does not force enqueue and dequeue to update the same pointer, and no operation needs a null special case in the middle of its logic. Enqueue is inherently two updates — link the new node into `last.next`, then swing `tail` forward — and no single-word atomic operation can do both. The algorithm therefore accepts a legal intermediate state in which the node is already in the list but the tail has not caught up. The rule is **helping**: any thread, enqueuer or dequeuer, that observes `tail.next != null` first performs the missing update, then restarts its own attempt. That way an operation half-completed by a preempted thread is finished by whoever comes next, which is what keeps the queue lock-free rather than merely non-locking. Dequeue moves head forward, consuming the old dummy and making the next node the new dummy.

code

text · 7 lines
text
before:   head -> [dummy] -> [A]        tail -> [A]
T1 links: head -> [dummy] -> [A] -> [B] tail -> [A]   // tail lags: legal
T1 stalls (preempted) before advancing tail
T2 enqueues: sees tail.next != null
             CAS(tail, A, B)                            // helps T1
             retries its own append after [B]
T1 resumes: CAS(tail, A, B) fails harmlessly -- already done

go deeper

for a junior

Know that a lock-free queue uses separate head and tail pointers and a placeholder node, and that appending takes more than one step.

for a middle

Explain why the dummy node prevents the ends from interfering and identify the linking step as the point the item is really in the queue.

for a senior

Describe the helping protocol, the invariant that bounds how far the tail may lag, and the practical costs — two contended lines, per-item allocation, reclamation.

for a principal

Judge whether an unbounded linked lock-free queue is the right structure at all versus a bounded ring buffer or a partitioned design, and articulate the legal-intermediate-state-plus-helping pattern as a general technique.

## Why a queue is harder than a stack A stack mutates one pointer, so a single compare-and-set is enough. A FIFO queue is modified at two places: items enter at the tail and leave at the head. Two separate pointers let enqueuers and dequeuers work independently — the whole point, since a shared single point would serialise both — but they introduce two new problems: the pointers can be inconsistent with each other, and near-empty queues make the two ends interact. ## The dummy node The queue always contains at least one node, a **sentinel** or dummy whose value is never returned. An empty queue is `head == tail == dummy`, `dummy.next == null`. This buys two things. First, **the ends never alias in a way that forces cooperation**: enqueue always appends after the last node and dequeue always consumes the node *after* head, so in the common case one writes `tail`-side state and the other writes `head`, with no overlap. Without a sentinel, inserting into an empty queue would have to update both pointers atomically, which single-word compare-and-set cannot do. Second, it removes null special cases from the middle of each operation, which is where concurrent algorithms usually go wrong. ## Enqueue is two steps, deliberately ``` enqueue(v): node = new Node(v, next=null) loop: t = read(tail) next = read(t.next) if t != read(tail): continue // snapshot went stale if next != null: // tail is lagging compare_and_set(tail, t, next) // help: advance it continue if compare_and_set(t.next, null, node): // step 1: link compare_and_set(tail, t, node) // step 2: swing tail (may fail) return ``` Step 1 is the **linearization point**: the moment `t.next` changes from null to the new node, the item is in the queue and every subsequent operation will see it. Step 2 is bookkeeping. Because the two are separate, a thread can be preempted, page-faulted or killed between them, leaving the tail one node behind. That state is *legal*, not corrupt — the invariant the algorithm maintains is only "tail points to the last node or the one before it". ## Helping Every operation begins by checking whether the tail is lagging (`tail.next != null`) and, if so, performing the missing compare-and-set on behalf of whoever left it that way before retrying its own work. Dequeue does the same check when it finds `head == tail` with a non-null next, since otherwise it could wrongly report the queue empty while an item is present. Helping is what makes the structure **lock-free**. Without it, a thread stalled between its two steps would leave the tail permanently stale and could block others — a blocking algorithm with no lock in sight. With it, no thread's stall prevents anyone from completing: any interrupted operation can be finished by any other thread. Note also that the helping compare-and-set is allowed to fail and is simply ignored — failure means someone else already advanced the tail, which is exactly the outcome wanted. ## Dequeue ``` dequeue(): loop: h = read(head); t = read(tail); next = read(h.next) if h != read(head): continue if next == null: return EMPTY // truly empty if h == t: compare_and_set(tail, t, next); continue // help, then retry value = next.value // read before unlinking if compare_and_set(head, h, next): return value ``` The successful compare-and-set on head is the linearization point of a dequeue. The old dummy is discarded and the node just consumed becomes the new dummy — which is why the value must be read out *before* the swing, since afterwards that node is the sentinel and may be recycled. ## Costs and caveats - **Two hot lines.** Head and tail are each written frequently, so both become coherence hot spots. Placing them on separate cache lines is essential; otherwise enqueuers and dequeuers false-share and the separation of ends buys nothing. - **Allocation per item.** Every enqueue allocates a node, which puts memory management on the hot path; bounded array-based ring buffers avoid this and usually outperform the linked design when a fixed capacity is acceptable. - **Reclamation.** Threads dereference nodes that others may be removing concurrently, so freeing them safely needs a deliberate scheme; that is a separate body of technique and a prerequisite for any real implementation. - **Progress.** Lock-free, not wait-free: a thread can lose its compare-and-set races indefinitely. ## The transferable idea The general lesson is broader than the queue: when an update needs more than one atomic step, publish the *essential* step first, make every intermediate state legal and detectable, and require every participant to complete any half-finished operation it encounters. That pattern — legal intermediate states plus helping — is how multi-step lock-free algorithms are built in general.

  • Why is the tail allowed to lag rather than being kept exactly correct?
    Appending requires two distinct memory updates — linking the node and moving the tail — and no single-word atomic primitive can perform both indivisibly. Rather than paying for a multi-word primitive or a lock, the algorithm defines the lagging state as legal and detectable, and makes every operation repair it. Correctness comes from the invariant that the tail is at most one node behind, plus the requirement that anyone who notices fixes it.
  • What goes wrong if a dequeuer ignores the possibility of a lagging tail?
    When the queue holds exactly one real item that has just been linked but not yet had the tail advanced, head equals tail while head.next is non-null. A dequeuer that only compares head and tail would report the queue empty even though an item is present, and could also move head past the tail, leaving tail pointing at a node no longer in the queue. Checking head.next and helping to advance the tail before proceeding prevents both.

saying these in an interview costs you the question

  • Claiming a lagging tail means the queue is corrupt
  • Believing both enqueue steps can be made one atomic operation with an ordinary compare-and-set
  • Omitting helping and assuming the original thread will always come back to finish
  • Reading the dequeued value after swinging head, when the node has already become the sentinel
  • Treating the dummy node as a wasteful artefact rather than the thing that keeps the two ends independent

context