Lesson 07 / 15
Ring Buffers and Queues
A fixed-capacity ring buffer and a linked queue in which a value passes exactly once between a producer and a consumer; measuring, through equality, that a read value no longer stays in the buffer.
Published Updated
In this lesson
- Implements a fixed-capacity ring buffer in C with write and read pointers
- Measures that a value does not stay in the buffer after being read, that is, that it reaches its destination exactly once
- Repeats the same handoff principle at unbounded capacity with a linked queue
Contents
The previous lesson measured the shared class, where a node was seen by two pointers at once: a change was there no matter when it was read, because the shared object was permanent. This lesson measures a different class through a different structure: a producer writes a value into a buffer, a consumer reads it — but a read here is a take operation, not a peek. Once a value is read, it does not stay in the buffer; a second read returns empty. This behavior defines handoff, one of the four classes: the value passes through an intermediary and reaches a reader, but has no lasting visibility after that reader.
Ring Buffer: Fixed-Capacity Write and Read
The buffer holds a fixed array of four elements; head carries the index of the next
read, tail the index of the next write, count the number of occupied elements.
ring_write does not write if the buffer is full and returns 0; ring_read does not
read if the buffer is empty and returns 0. Both return 1 if successful.
// ring.h — fixed-capacity ring buffer
#ifndef RING_H
#define RING_H
#define RING_CAPACITY 4
struct Ring {
int data[RING_CAPACITY];
int head;
int tail;
int count;
};
void ring_init(struct Ring *ring);
int ring_write(struct Ring *ring, int value);
int ring_read(struct Ring *ring, int *out);
#endif
// ring.c — write and read at fixed capacity
#include "ring.h"
void ring_init(struct Ring *ring) {
ring->head = 0;
ring->tail = 0;
ring->count = 0;
}
int ring_write(struct Ring *ring, int value) {
if (ring->count == RING_CAPACITY) {
return 0;
}
ring->data[ring->tail] = value;
ring->tail = (ring->tail + 1) % RING_CAPACITY;
ring->count = ring->count + 1;
return 1;
}
int ring_read(struct Ring *ring, int *out) {
if (ring->count == 0) {
return 0;
}
*out = ring->data[ring->head];
ring->head = (ring->head + 1) % RING_CAPACITY;
ring->count = ring->count - 1;
return 1;
}
A Value Passing Through the Buffer Exactly Once
Three values are written in order, then read in order. The order is preserved (the first value written is the first read), but the real measurement is in the last two lines: trying to read the same value a second time returns empty, because the value was taken out of the buffer on the first read, not left there.
// one_time_pass.c — three values written and read, then the buffer stays empty
#include <stdio.h>
#include "ring.h"
int main(void) {
struct Ring ring;
ring_init(&ring);
ring_write(&ring, 10);
ring_write(&ring, 20);
ring_write(&ring, 30);
int value = 0;
ring_read(&ring, &value);
printf("%s %d\n", "first value read", value);
ring_read(&ring, &value);
printf("%s %d\n", "second value read", value);
ring_read(&ring, &value);
printf("%s %d\n", "third value read", value);
int ok = ring_read(&ring, &value);
printf("%s %d\n", "fourth read succeeded", ok);
printf("%s %d\n", "did value change after the failed fourth read", (int)(value == 30));
return 0;
}
first value read 10 second value read 20 third value read 30 fourth read succeeded 0 did value change after the failed fourth read 1
DS4. The signature of ring_read (int ring_read(struct Ring *ring, int *out))
does not say who is responsible for releasing a pointer, and in the same way it
doesn’t say whether the value it receives stays in the buffer — what decides that is the
line ring->count = ring->count - 1, inside the body itself. The three values come out
in order (10, 20, 30), because head advances by one on every read and the buffer
preserves first-in, first-out order. The fourth call returns 0 because count has
dropped to zero; since nothing is written to out, value keeps carrying 30 left
over from the previous call — this is proof that value itself did not change, not
that the buffer offered a new value. The class is handoff: every value reached
exactly one reader, and left no trace in the buffer after that reader; this is where it
differs from the shared class — there the shared object kept standing even after
being read, here the slot that was read empties out.
Producer and Consumer Interleaving
Writes and reads can interleave: two writes, one read, one more write, two reads. This mimics the real situation where a producer and a consumer use the same buffer at different speeds. Because the buffer can hold at most four elements, the read before the third write makes room.
// interleaved_order.c — write and read calls interleaved
#include <stdio.h>
#include "ring.h"
int main(void) {
struct Ring ring;
ring_init(&ring);
int value = 0;
ring_write(&ring, 1);
ring_write(&ring, 2);
ring_read(&ring, &value);
printf("%s %d\n", "read after two writes", value);
ring_write(&ring, 3);
ring_read(&ring, &value);
printf("%s %d\n", "second read after three writes", value);
ring_read(&ring, &value);
printf("%s %d\n", "third read after three writes", value);
int ok = ring_read(&ring, &value);
printf("%s %d\n", "fourth read succeeded", ok);
return 0;
}
read after two writes 1 second read after three writes 2 third read after three writes 3 fourth read succeeded 0
DS5. No matter what order the write and read calls interleave in, each read
returns the oldest not-yet-read value — the result of head advancing only on a
read and tail advancing only on a write. This second run produces the same three
values as the first (this time 1, 2, 3) in the same order, but the extra write placed
between them does not change the outcome: the buffer can hold at most four elements at
any moment, and because the read before the third write makes room, no overflow ever
happens. This flexibility between when a value is written and when it is read is a
second property of handoff: the producer and the consumer do not have to run at the
same speed, the buffer between them acts as a cushion — but what happens when a write
is attempted beyond capacity (overwriting an unread value) is not this lesson’s
subject; that question belongs to the next lesson, which measures the unobserved
class.
Queue: The Same Principle at Unbounded Capacity
The ring buffer has a fixed capacity; the queue applies the same first-in first-out
order with linked nodes, without a capacity limit. queue_enqueue adds a node at the
tail, queue_dequeue removes the node at the head, returns its value, and releases the
node — here too, the value that was read does not stay in the queue.
// queue.h — unbounded FIFO queue
#ifndef QUEUE_H
#define QUEUE_H
struct QueueNode {
int value;
struct QueueNode *next;
};
struct Queue {
struct QueueNode *head;
struct QueueNode *tail;
};
void queue_init(struct Queue *queue);
void queue_enqueue(struct Queue *queue, int value);
int queue_dequeue(struct Queue *queue, int *out);
#endif
// queue.c — enqueue at the tail, dequeue from the head
#include <stdlib.h>
#include "queue.h"
void queue_init(struct Queue *queue) {
queue->head = NULL;
queue->tail = NULL;
}
void queue_enqueue(struct Queue *queue, int value) {
struct QueueNode *node = malloc(sizeof *node);
if (node == NULL) {
return;
}
node->value = value;
node->next = NULL;
if (queue->tail == NULL) {
queue->head = node;
} else {
queue->tail->next = node;
}
queue->tail = node;
}
int queue_dequeue(struct Queue *queue, int *out) {
if (queue->head == NULL) {
return 0;
}
struct QueueNode *node = queue->head;
*out = node->value;
queue->head = node->next;
if (queue->head == NULL) {
queue->tail = NULL;
}
free(node);
return 1;
}
// queue_passage.c — a value dequeued also reaches the reader exactly once
#include <stdio.h>
#include "queue.h"
int main(void) {
struct Queue queue;
queue_init(&queue);
queue_enqueue(&queue, 100);
queue_enqueue(&queue, 200);
int value = 0;
queue_dequeue(&queue, &value);
printf("%s %d\n", "first value dequeued", value);
queue_dequeue(&queue, &value);
printf("%s %d\n", "second value dequeued", value);
int ok = queue_dequeue(&queue, &value);
printf("%s %d\n", "dequeue succeeded on an empty queue", ok);
return 0;
}
first value dequeued 100 second value dequeued 200 dequeue succeeded on an empty queue 0
DS6. The queue uses a different memory model than the ring buffer — instead of a
fixed array, each element sits in its own allocated node, and queue_dequeue releases
the node after reading it (free(node)). But the class being measured is the same:
every value reaches exactly one call, and after that call it leaves no trace in the
queue. The absence of a capacity limit (queue_enqueue always succeeds, unless
malloc fails) does not change the handoff class; it only removes the ring buffer’s
notion of “full.”
Reject-on-Full or Overwrite Policy
ring_write rejects writing to a full buffer. But in some producer/consumer setups
the newest value matters more than the oldest — in that case, instead of rejecting on
full, overwriting the oldest element is preferred. Both policies can operate on the
same struct Ring; they differ only in what they do when the buffer is full.
// ring_overwrite.h — declaration of a write policy that overwrites the oldest value when full #ifndef RING_OVERWRITE_H #define RING_OVERWRITE_H #include "ring.h" int ring_write_overwrite(struct Ring *ring, int value); #endif
// ring_overwrite.c — implementation of a write policy that overwrites the oldest value when full
#include "ring_overwrite.h"
int ring_write_overwrite(struct Ring *ring, int value) {
if (ring->count == RING_CAPACITY) {
ring->head = (ring->head + 1) % RING_CAPACITY;
} else {
ring->count = ring->count + 1;
}
ring->data[ring->tail] = value;
ring->tail = (ring->tail + 1) % RING_CAPACITY;
return 1;
}
// fullness_policy.c — comparing the reject and overwrite policies
#include <stdio.h>
#include "ring_overwrite.h"
int main(void) {
struct Ring ring;
ring_init(&ring);
ring_write(&ring, 10);
ring_write(&ring, 20);
ring_write(&ring, 30);
ring_write(&ring, 40);
int rejected = ring_write(&ring, 50);
printf("%s %d\n", "result of writing to a full buffer under the reject policy", rejected);
int overwrite_result = ring_write_overwrite(&ring, 99);
printf("%s %d\n", "result of writing to a full buffer under the overwrite policy", overwrite_result);
int out;
for (int i = 0; i < RING_CAPACITY; i++) {
ring_read(&ring, &out);
printf("%s %d\n", "value read in order after the overwrite", out);
}
return 0;
}
result of writing to a full buffer under the reject policy 0 result of writing to a full buffer under the overwrite policy 1 value read in order after the overwrite 20 value read in order after the overwrite 30 value read in order after the overwrite 40 value read in order after the overwrite 99
DS15. Both policies make a different choice on a full buffer, but both are
deterministic: ring_write returns 0 on full without changing anything — the value
50 never enters the structure at all. ring_write_overwrite instead advances head
by one and writes over the index where the oldest element (10) was sitting; 10
disappears without ever reaching a reader. This defines unobserved, one of the four
classes: a value entered the structure but was overwritten before it reached any
reader. The difference between the rejected 50 and the overwritten 10
matters — 50 was never written at all, 10 was written and then disappeared;
neither is ever readable in the end, but for different reasons.
The Bounding Measurement
A read function’s signature does not say whether the value it reads stays at the
source. int ring_read(struct Ring *ring, int *out) and int list_sum(const struct Node *head) (from the previous lesson) belong to the same family: both read a
structure. But one leaves the value it read inside the structure (the list is
permanent), the other removes the value it read from the structure (the buffer empties
out). This difference does not show up in the parameter types, the return type, or
even the function name; it can only be measured by looking at whether the body
decrements count, or by making two calls in a row and comparing their results —
exactly as this lesson did.
Summary
- The ring buffer applies the same first-in-first-out order in a fixed-capacity array that the queue applies in linked nodes without a capacity limit.
- A value does not stay in the buffer or the queue after being read; a second read or dequeue returns empty — the class is handoff.
- Handoff’s difference from shared: a shared object stays in place after being read, but a handoff’s read slot empties and the value can never be read again.
- A producer’s and a consumer’s write/read order can interleave; the buffer acts as a cushion between them.
- Reject-on-full and overwrite-oldest are two separate policies: the rejected value never enters the structure at all, the overwritten value enters and then disappears without ever reaching a reader — this second case defines the unobserved class.
- Whether a read function leaves the value it read behind at the source does not show up in its signature; it can only be measured by reading the body or by comparing two consecutive calls.
Next Step
In this lesson we measured that a value is read exactly once and then leaves the buffer. The next lesson, Hash Tables, moves on with a different question: does a value stored under a key always give the same result when read back repeatedly under that key, or is a copy produced at read time? The class measured there is copy: the value read from the table is not the table’s own copy, but an independent copy the caller receives.
To keep your progress and take notes, Log in
My notes
Log in to take notes.