2.4 Consumer Protocol

A consumer typically dedicates one thread to each buffer. Each thread independently drains its buffer, sleeps when the buffer is empty, and re-attaches when the buffer is replaced. The protocol uses no locks and, while events are available, no system calls.

Each consumer keeps its own read_pos in its own memory. On first attaching to a buffer, a consumer SHOULD set read_pos to the buffer's current tail_pos, which starts it at the oldest surviving event.

2.4.1 Draining #

  1. Load write_pos with acquire ordering. If write_pos == read_pos, no events are available: go to the notification wait.
  2. Load tail_pos with acquire ordering. If read_pos < tail_pos, the events at the read position have been overwritten and the consumer has been lapped: set read_pos = tail_pos. The skipped span is lost, and will show up as a sequence gap.
  3. Save the current tail_pos as saved_tail.
  4. Read the event at data region offset read_pos & (capacity - 1).
  5. Re-read tail_pos. If it has advanced past saved_tail and read_pos < tail_pos, the event was overwritten while it was being read. The bytes just read MUST be discarded: go to step 2.
  6. Check that event_size > 0 and event_size >= header_size. If either fails, the bytes are not a valid event; the consumer SHOULD set read_pos = tail_pos and go to step 2. A consumer MUST perform this check: an event_size of zero would otherwise make the drain loop spin forever.
  7. Process the event. Advance read_pos by the event's event_size. Go to step 1.

A consumer MUST NOT read beyond an event's event_size boundary.

Steps 3 and 5 are what make a lock-free read safe against a producer that is overwriting the region being read. A consumer that omits the re-read can process a torn event assembled from two different events' bytes.

2.4.2 Detecting loss #

A consumer SHOULD track the last sequence number it processed for each CPU. A gap means events were lost, whether because they were overwritten before being read or because the kernel dropped them before they reached the buffer. The size of the gap is the number of events lost.

Loss is a normal condition under load, not an error: the buffer preserves recent events at the cost of old ones. A consumer SHOULD report loss rather than treating it as fatal.

2.4.3 Notification wait #

When a buffer is empty:

  1. Store 1 to need_wake with release ordering.
  2. Re-load write_pos with acquire ordering. If events arrived between the drain loop finding the buffer empty and this store, clear need_wake to 0 and return to the drain loop. This re-check is REQUIRED: without it, an event written in that window would find need_wake still clear, and the consumer would sleep with events waiting.
  3. Read futex_counter.
  4. Optionally spin, re-checking write_pos. If events arrive during the spin, clear need_wake to 0 and return to the drain loop. The spin duration is the consumer's choice, and a consumer MAY omit this step entirely.
  5. Call futex_wait(&futex_counter, last_seen_value), where last_seen_value is the value read in step 3. The kernel puts the thread to sleep only if futex_counter still holds that value, so a wake that arrived in the meantime is not missed.
  6. On waking, clear need_wake to 0 and return to the drain loop.

The futex address is the futex_counter field in the mapped producer metadata page. This is a shared futex, keyed by the page's backing inode, and a consumer MUST wait on it as such: a wait issued with FUTEX_PRIVATE_FLAG will never be woken.

Clearing need_wake to 0 in steps 2, 4, and 6 MAY be a relaxed store. If KMES reads a stale set value after the consumer has cleared it, it performs a wake on a thread that is already awake, which is harmless.

Under sustained load a consumer never reaches the notification wait: it stays in the drain loop, need_wake stays 0, and the producer's notification cost is a single byte read per event.

2.4.4 Buffer replacement #

KMES replaces every buffer when its configured capacity changes. Replacement preserves as many surviving events as the new capacity allows and keeps sequence numbering continuous, but it invalidates positions: the events are re-compacted from position 0 in the new buffer, so the consumer's read_pos means nothing there.

After each drain cycle — the buffer emptied, or a batch limit reached — a consumer SHOULD read generation. If it differs from the value last seen:

  1. Record the sequence number of the last event successfully processed from this buffer.
  2. Finish draining the old buffer up to its write_pos, which is now frozen: KMES has stopped writing to it. A consumer MUST complete this drain before switching, or it loses every event written between its read position and the switchover.
  3. Call kmes_attach for the same CPU to obtain a descriptor for the replacement buffer, and map it.
  4. Read the new buffer's capacity, write_pos, and tail_pos.
  5. Scan the new buffer for the first event whose sequence number is greater than the recorded one, and set read_pos to that event's position. A consumer MUST locate its position by sequence number and MUST NOT carry read_pos across.
  6. Close the old descriptor and unmap the old buffer.
  7. Resume draining from the new buffer.

The old buffer's pages remain valid for as long as any consumer keeps them mapped, so a consumer is never racing to finish before the memory disappears.

If the new capacity is large enough to hold everything that survived in the old buffer, no events are lost across the replacement. If it is smaller, the oldest surviving events are discarded until the remainder fits — the same overwrite semantics applied against the smaller capacity — so loss is bounded to the oldest part of the buffer and appears as a sequence gap.

A consumer sleeping on the old buffer is woken when the replacement happens, provided its need_wake was set, so it observes the generation change rather than sleeping indefinitely on a buffer that will never receive another event.

2.4.5 Memory ordering #

OperationOrderingPurpose
Producer stores tail_posreleaseThe advanced tail is visible before the data that replaces the events it skipped past.
Producer stores write_posreleaseComplete event data is visible before the position that makes it reachable.
Producer stores futex_counterreleaseA consumer waking from the futex observes all prior writes.
Consumer stores need_wake = 1releaseThe producer observes the flag before the consumer waits.
Consumer stores need_wake = 0relaxedA stale read causes only a spurious wake.
Consumer loads write_pos in the drain loopacquirePairs with the producer's release.
Consumer loads write_pos after setting need_wakeacquireCloses the window between finding the buffer empty and announcing the sleep.
Consumer loads tail_posacquirePairs with the producer's release.

For a given buffer there are exactly two kinds of party: one producer, which is the kernel on the owning CPU, and any number of consumers. There is no multi-producer contention to account for.

On x86-64 the producer's release stores compile to plain stores, because the architecture does not reorder stores with other stores. Consumers MUST NOT rely on that: the ordering above is required for correctness on weaker architectures, and a consumer written without it is incorrect on those machines whether or not it is observed to fail on x86-64.

Edit this page