Topic 03

Stacks, Queues and Ring Buffers

Data Structures

A stack and a queue are arrays or linked lists with most of their interface taken away. A stack lets you touch only the most recent item, a queue only the oldest. The restriction is what makes each operation O(1), and it is why these two shapes run undo histories, parsers, graph searches and every producer-consumer system ever built.

A ring buffer is a queue of fixed size. Its one real design decision, what happens when it is full, is visible in every log, audio stream and network card, and it is the same decision every service makes, knowingly or not, about its request queue.

The Stack: Last In, First Out

A stack adds and removes at one end only: push puts an item on top, pop takes the top item off. Undo histories are stacks, and so are backtracking search (Chapter 9), depth-first search (Chapter 8), bracket matching in a parser (Chapter 13) and the call stack itself (Chapter 3). In each case the most recent unfinished thing is the one to deal with next.

A Python list is a correct stack. Appending to its end and popping from its end are both O(1), because neither moves any other element (see Arrays and Dynamic Arrays). Nothing else is needed.

An explicit stack is also how deep recursion is made safe. The call stack is a stack the language manages, and CPython 3.15 stops it at 1,000 frames by default. A depth-first walk that keeps its own list of nodes still to visit has no such limit, and holds a million pending nodes in about 8 megabytes of pointers.

The Queue: First In, First Out

A queue adds at the back and takes from the front. Breadth-first search (Chapter 8), job queues and request buffers are queues, and they are fair by construction: the oldest waiter always goes next.

A Python list is the wrong queue. Taking from its front shifts every remaining element one slot left (see Arrays and Dynamic Arrays), so draining 100,000 items from the front moves about five billion pointers in total.

Draining 100,000 items from the front
items = list(range(100_000))
while items:
    items.pop(0)          # 0.86 s: every pop shifts the rest
q = deque(range(100_000))
while q:
    q.popleft()           # 0.0036 s: nothing shifts

The listing drains the same 100,000 numbers from the front twice, once from a list and once from the standard library's deque. On CPython 3.15 the list took 0.86 seconds and the deque 3.6 milliseconds, more than two hundred times faster. The code differs by one method name.

The Deque

A deque, a double-ended queue, is O(1) at both ends. CPython's deque is a doubly linked list of fixed blocks, each holding 64 slots (see Linked Lists). Adding at either end writes into the end block, and allocates a new block only when that one is full. That keeps both ends cheap and most of the memory contiguous.

CPython's deque: a chain of fixed blocks with an end at each side
a deque: a doubly linked chain of blocks of 64 slotsblock 1: 64 slotsblock 2: 64 slotsblock 3: 64 slotsleft endright enddrawn with 8 slots per block

The price is the middle. Reaching an element by index means walking blocks from the nearer end, O(n) in the worst case, and a deque cannot be sliced at all: asking for a slice raises a type error. A deque is a queue that happens to allow indexing, not a general sequence.

The block design is also a memory decision. A deque of a million items needs about 16,000 blocks, each allocated once and reused as the ends move, instead of one huge array that would have to be copied as it grows. The ends are fast because the work at an end never depends on how many items sit in the middle.

The Ring Buffer

A ring buffer is a fixed array with two indexes, a head where the next item is read and a tail where the next item is written. When either index reaches the end of the array, it wraps around to the start. Once allocated, the buffer never allocates again, and its memory is bounded by construction.

A ring buffer: the tail chases the head around a fixed array
01234567head: next to readtail: next to writeeight slots in one fixed arrayafter slot 7 comes slot 0slot = count AND 7shaded: written, not yet read

With a capacity that is a power of two, the wrap is a single bitwise AND with the capacity minus one, instead of a division. And one producer and one consumer can share a ring without a lock, because each writes only its own index: the producer moves the tail, the consumer moves the head. That works only if each side sees the other's writes in the right order, which is the memory ordering that Chapter 11 explains.

When the Ring Is Full

A fixed capacity is a promise that something will give way, and choosing what gives way is the design. One choice is to overwrite the oldest entry. A kernel's log buffer does this, as do a flight recorder and a feature that keeps "the last 30 seconds" of audio or metrics. The other choice is to refuse the newest entry. A network card whose receive ring is full drops the arriving packet, and a sound recorder whose buffer is full loses the incoming audio, leaving a gap in the recording.

In both cases the loss is deliberate. The only open question is whether anyone counts it. An overwrite that is counted is a policy; an overwrite that is not counted looks like a bug.

The Cost in a System You Run

An unbounded queue between a fast producer and a slow consumer never drops anything. It turns overload into memory growth and waiting time instead. Requests pile up, the oldest ones time out while still in the queue, the process runs out of memory, and the work is lost anyway, later and more expensively than if it had been refused at the door.

A bounded queue with an explicit policy for when it is full turns the same overload into something the producer can see: it blocks the producer, drops the item, or rejects it with an error. That signal is backpressure (Chapter 12), and it is what lets the system in front slow down, retry later or send the work elsewhere.

Misconceptions
  • "A list works fine as a queue." Each take from the front is O(n). Draining 100,000 items moves about five billion pointers, where a deque does 100,000 constant-time pops; on CPython 3.15 that was 0.86 seconds against 3.6 milliseconds.
  • "An unbounded queue is the safe choice because it never loses work." It converts overload into latency and then into an out-of-memory kill, and the requests at its back time out before they are served. The work is lost anyway, later and more expensively.
  • "A deque is a list with a fast front." Indexing into its middle is O(n), and it cannot be sliced. It is a queue, not a general sequence.
  • "Dropped packets mean the network lost them." Often the receiving host's ring filled because the software reading it fell behind, and the network delivered every packet.
  • "A ring buffer that overwrites old entries is losing data by mistake." Overwriting is its policy. The mistake is not counting what it overwrote.
Why It Matters
  • Use a deque for first-in, first-out and a list for last-in, first-out. Each is O(1) exactly where the other is not.
  • Bound every queue between a producer and a consumer, and choose its full policy explicitly. An unbounded queue is a memory leak with a delay.
  • Count every overwrite and every drop, and alert on the count. A silent loss is indistinguishable from a bug.
  • Size a buffer for the largest burst, not the average rate. Traffic arrives in spikes, and an average-sized buffer overflows on every one.
RelatedPriority queues "next" means smallest rather than oldest (Chapter 6)Message brokers queues between machines, taught in Backend Deep DiveFlow control the network's bounded buffer and its full policy (Chapter 12)

Knowledge Check

A job runner keeps pending jobs in a Python list, appends new ones and takes the oldest with pop(0). With 100,000 jobs queued, how does it compare with a deque?

  • Far slower: each front pop shifts the rest, where a deque shifts nothing
  • About the same: both are arrays underneath, so both pay one shift per pop
  • Faster: a list's contiguous memory beats the deque's chain of linked blocks
  • Slower only while the list grows, and the same once it stops growing

A service puts incoming requests on an unbounded queue in front of a slower worker. Traffic stays above capacity for an hour. What happens?

  • Nothing is lost, because every request eventually reaches the worker
  • The queue drops the oldest requests automatically once it is large
  • Waiting grows, requests time out in the queue, and memory runs out
  • The producer slows down, because the queue signals that it is full

Why can one producer thread and one consumer thread share a ring buffer without a lock?

  • Ring buffers are read-only, so there is nothing to protect
  • Each side writes only its own index, given correct memory ordering
  • The operating system serializes the two threads on the buffer
  • A power-of-two capacity makes every write atomic automatically

A host reports dropped packets, yet the network shows no loss. Where did the drops most likely come from?

  • A faulty cable that corrupts packets without anyone noticing
  • A router that discards packets whose checksum is too large
  • The sender, which drops packets on purpose when it runs fast
  • The receiving card's ring filled because the software fell behind

A metrics agent keeps recent samples in a fixed ring. When should it overwrite the oldest sample, and when should it refuse the newest?

  • Always refuse the newest entry, because losing old data is never acceptable
  • Always overwrite the oldest, because the newest data always matters most
  • Overwrite for a recent-history view; refuse when every item must be handled
  • Neither: a well-sized ring never fills, so the question does not arise

You got correct