I/O Buffers

Memory is the bridge between your program and the outside world. When data arrives from a network socket, it must land somewhere. When you send a message, it must come from somewhere. Buffers are that somewhere—contiguous regions of memory that hold bytes in transit.

This library provides a complete vocabulary for describing, manipulating, and composing memory regions. Understanding buffers unlocks efficient I/O: fewer copies, less allocation, and precise control over where data lives.

The Fundamental Insight

A buffer is simply a pointer and a size. Nothing more. It doesn’t own memory—it describes memory owned elsewhere. This distinction is crucial: ownership and description are separate concerns.

char storage[1024];
mutable_buffer buf(storage, sizeof(storage));  // describes storage, doesn't own it

The storage exists on the stack. The buffer merely points at it. When the buffer is copied, only the pointer and size are copied—the underlying bytes remain untouched. This makes buffers cheap to pass around and compose.

Two fundamental types express this:

  • mutable_buffer — describes writable memory

  • const_buffer — describes read-only memory

Every mutable_buffer can become a const_buffer (you can always promise not to write), but not vice versa.

void send_data(const_buffer data);      // accepts either type
void receive_data(mutable_buffer dest); // requires writable memory

char buf[100];
send_data(mutable_buffer(buf, 100));  // OK: mutable converts to const
receive_data(const_buffer("hi", 2));  // ERROR: const can't become mutable

Creating Buffers

The make_buffer function creates buffer descriptors from various sources. It examines the source and returns the appropriate type—mutable_buffer for writable storage, const_buffer for read-only data.

From raw memory:

char data[256];
auto buf = make_buffer(data, 256);           // mutable_buffer
auto buf2 = make_buffer(data);               // mutable_buffer, size deduced from array

From standard containers:

std::vector<char> v(1024);
auto buf = make_buffer(v);                   // mutable_buffer

std::string s = "hello";
auto buf = make_buffer(s);                   // mutable_buffer (string is mutable)

const std::string cs = "constant";
auto buf = make_buffer(cs);                  // const_buffer (const string)

From string views:

std::string_view sv = "immutable";
auto buf = make_buffer(sv);                  // const_buffer (views are read-only)

With size limits:

char storage[1024];
auto buf = make_buffer(storage, sizeof(storage), 256);  // only first 256 bytes

The overloads handle std::array, std::span, contiguous ranges, and more. The pattern is consistent: pass your storage, get back a lightweight descriptor.

Buffer Sequences: Many Become One

A single buffer describes one contiguous region. But I/O operations often involve scattered data—a header here, a payload there, a trailer elsewhere. Copying these into one region wastes time and memory.

Buffer sequences solve this. A sequence is any iterable collection of buffers. The I/O system treats the sequence as logically contiguous while the underlying memory remains scattered.

std::array<const_buffer, 3> message = {
    make_buffer("GET / HTTP/1.1\r\n"),
    make_buffer("Host: example.com\r\n"),
    make_buffer("\r\n")
};
// Three separate string literals, sent as one logical message
co_await socket.write(message);

Two concepts formalize this:

  • ConstBufferSequence — iterable, elements convert to const_buffer

  • MutableBufferSequence — iterable, elements convert to mutable_buffer

A single buffer satisfies both concepts (a one-element sequence). Arrays, vectors, and custom types work if their iterators yield buffers.

void process(ConstBufferSequence auto const& buffers) {
    for (auto it = begin(buffers); it != end(buffers); ++it) {
        const_buffer b = *it;
        // process b.data(), b.size()
    }
}

The free functions begin() and end() work uniformly on single buffers and sequences, letting generic code handle both.

Measuring and Querying

buffer_size(bs) — total bytes across all buffers:

std::array<const_buffer, 2> bufs = {
    const_buffer("hello", 5),
    const_buffer("world", 5)
};
std::size_t total = buffer_size(bufs);  // 10

buffer_length(bs) — number of buffer elements:

std::size_t count = buffer_length(bufs);  // 2

buffer_empty(bs) — true if no data:

bool empty = buffer_empty(bufs);  // false

front(bs) — first buffer in a sequence:

const_buffer first = front(bufs);  // "hello"

Copying Between Buffers

The buffer_copy function transfers bytes from a source sequence to a destination sequence. It handles sequences of different lengths and shapes, stopping when either is exhausted or an optional limit is reached.

char src_data[] = "hello world";
char dst_data[20];

const_buffer src = make_buffer(src_data);
mutable_buffer dst = make_buffer(dst_data);

std::size_t copied = buffer_copy(dst, src);  // copies 11 bytes

With scattered buffers:

std::array<const_buffer, 2> src = {
    const_buffer("hello ", 6),
    const_buffer("world", 5)
};
char dst[20];
std::size_t copied = buffer_copy(make_buffer(dst), src);  // 11 bytes into contiguous dest

With a limit:

std::size_t copied = buffer_copy(dst, src, 5);  // copy at most 5 bytes

The function iterates through both sequences in lockstep, handling partial buffers correctly. It returns the actual number of bytes copied.

Slicing Buffer Sequences

Often you need only part of a sequence—the first N bytes, everything after a header, or the last N bytes. Slicing operations provide this without copying data.

In-place modification:

mutable_buffer buf(data, 100);
remove_prefix(buf, 10);  // now describes bytes 10-99
keep_prefix(buf, 50);    // now describes first 50 bytes

Creating new views:

const_buffer original(data, 100);
auto first_half = prefix(original, 50);    // bytes 0-49
auto last_half = suffix(original, 50);     // bytes 50-99
auto without_header = sans_prefix(original, 10);  // bytes 10-99
auto without_trailer = sans_suffix(original, 10); // bytes 0-89

These operations work on sequences too. The slice_of<T> wrapper tracks prefix and suffix adjustments without modifying the underlying sequence:

std::array<const_buffer, 3> parts = { buf1, buf2, buf3 };
auto slice = prefix(parts, 100);  // first 100 bytes across all buffers

For spans of buffers, specialized functions modify the span in place:

std::span<const_buffer> bufs = ...;
remove_span_prefix(bufs, 50);  // adjust span and first buffer
keep_span_suffix(bufs, 100);   // keep only last 100 bytes

Dynamic Buffers: Growable Storage

Static buffers have fixed capacity. Dynamic buffers can grow to accommodate incoming data of unknown size. They provide a two-phase protocol:

  1. prepare(n) — request N bytes of writable space

  2. commit(n) — make N written bytes readable

  3. data() — access readable bytes

  4. consume(n) — discard N bytes from the front

char storage[4096];
flat_dynamic_buffer db(storage, sizeof(storage));

// Receive data
auto dest = db.prepare(1024);        // get writable region
std::size_t n = read(socket, dest);  // read into it
db.commit(n);                        // mark n bytes as readable

// Process data
auto readable = db.data();           // access committed bytes
process(readable);
db.consume(db.size());               // discard after processing

This model decouples receiving from processing. You can accumulate data across multiple reads, then consume it incrementally as parsing succeeds.

Buffer Types

flat_dynamic_buffer — linear, contiguous:

char storage[8192];
flat_dynamic_buffer fb(storage, sizeof(storage));
// data() always returns a single buffer

circular_dynamic_buffer — ring buffer, efficient FIFO:

char ring[4096];
circular_dynamic_buffer cb(ring, sizeof(ring));
// data() may return up to 2 buffers (wrapped region)

string_dynamic_buffer — wraps std::string:

std::string s;
auto sb = dynamic_buffer(s);
// grows by resizing the string

vector_dynamic_buffer — wraps std::vector<char>:

std::vector<unsigned char> v;
auto vb = dynamic_buffer(v);
// grows by resizing the vector

The circular buffer excels when data flows through continuously—old data is consumed as new data arrives, and the buffer never needs to shift contents.

The DynamicBuffer Concept

template<class T>
concept DynamicBuffer = requires(T& t, T const& ct, std::size_t n) {
    typename T::const_buffers_type;
    typename T::mutable_buffers_type;
    { ct.size() } -> std::convertible_to<std::size_t>;
    { ct.max_size() } -> std::convertible_to<std::size_t>;
    { ct.capacity() } -> std::convertible_to<std::size_t>;
    { ct.data() } -> std::same_as<typename T::const_buffers_type>;
    { t.prepare(n) } -> std::same_as<typename T::mutable_buffers_type>;
    t.commit(n);
    t.consume(n);
};

Any type satisfying this concept works with generic algorithms that accumulate or stream data.

Value Types vs. Adapters

Some dynamic buffers own their bookkeeping (like flat_dynamic_buffer), while others wrap external storage (like string_dynamic_buffer). This matters for coroutines.

When a coroutine suspends, parameters passed by value live in the coroutine frame. But internal bookkeeping in a value-type buffer would be lost if passed as an rvalue:

// DANGEROUS with value types:
co_await read(socket, flat_dynamic_buffer(storage, size));  // bookkeeping lost on suspend!

// SAFE with adapters:
std::string s;
co_await read(socket, dynamic_buffer(s));  // string persists externally

The DynamicBufferParam concept enforces safe passing:

task<> read(socket_type& sock, DynamicBufferParam auto&& buffers);
// Accepts lvalues of any DynamicBuffer
// Accepts rvalues only for adapter types

Incremental Processing with buffer_param

When processing large buffer sequences, iterating one buffer at a time incurs overhead. The buffer_param wrapper provides windowed access—batches of buffers for efficient processing.

task<> send(ConstBufferSequence auto buffers) {
    buffer_param bp(buffers);
    while (true) {
        auto bufs = bp.data();  // up to max_iovec buffers
        if (bufs.empty())
            break;
        auto n = co_await socket.write(bufs);
        bp.consume(n);
    }
}

The wrapper maintains an internal array of buffer descriptors, refilling from the source sequence as needed. This amortizes iteration overhead and works efficiently with scatter/gather I/O.

Fixed-Size Buffer Holders

When you need to capture a buffer sequence snapshot without dynamic allocation:

void process(ConstBufferSequence auto const& buffers) {
    some_const_buffers snapshot(buffers);  // copies descriptors, not data
    // snapshot is now independent of the original sequence
}

some_const_buffers and some_mutable_buffers store up to a fixed number of buffer descriptors internally. They’re useful for caching a sequence’s structure or passing across API boundaries.

Buffer Pairs

Some operations naturally produce two buffers—like a circular buffer’s wrapped data region. The const_buffer_pair and mutable_buffer_pair aliases provide convenient two-element sequences:

using const_buffer_pair = std::array<const_buffer, 2>;
using mutable_buffer_pair = std::array<mutable_buffer, 2>;

const_buffer_pair wrapped_data = circular_buffer.data();  // may span wrap point

Consuming Buffers

The consuming_buffers wrapper tracks position through a sequence as bytes are processed:

void process_incrementally(ConstBufferSequence auto const& buffers) {
    consuming_buffers cb(buffers);
    while (/* more work */) {
        // iterate cb.begin() to cb.end() for remaining buffers
        // first buffer is adjusted for previously consumed bytes
        std::size_t processed = do_work(cb);
        cb.consume(processed);
    }
}

The wrapper doesn’t copy the underlying sequence—it references it and tracks offsets. Perfect for protocols that process data in chunks.

Practical Patterns

Accumulating Response Data

std::string response;
auto db = dynamic_buffer(response);

while (!done) {
    auto dest = db.prepare(4096);
    auto n = co_await socket.read(dest);
    if (n == 0) break;
    db.commit(n);
}
// response now contains all received data

Zero-Copy Message Framing

std::array<const_buffer, 3> frame = {
    make_buffer(&header, sizeof(header)),    // stack header
    make_buffer(payload),                     // external payload
    make_buffer(&checksum, sizeof(checksum)) // stack trailer
};
co_await socket.write(frame);  // single syscall, no copying

Protocol Parsing

flat_dynamic_buffer db(storage, sizeof(storage));

while (true) {
    // Accumulate data
    auto dest = db.prepare(1024);
    auto n = co_await socket.read(dest);
    db.commit(n);

    // Try to parse
    auto readable = db.data();
    auto [parsed, consumed] = try_parse(readable);
    if (parsed) {
        process(parsed);
        db.consume(consumed);
    }
    // Loop until complete message or connection closes
}

Scatter/Gather I/O

struct message {
    header hdr;
    std::vector<char> body;
    footer ftr;
};

std::array<mutable_buffer, 3> receive_buffers(message& m) {
    return {
        make_buffer(&m.hdr, sizeof(m.hdr)),
        make_buffer(m.body),
        make_buffer(&m.ftr, sizeof(m.ftr))
    };
}
// Single read syscall fills all three regions

Summary

Buffers are the vocabulary of I/O. Master them and you control exactly where bytes live, how they flow, and when (if ever) they’re copied.

Type Purpose

const_buffer

Describes read-only memory

mutable_buffer

Describes writable memory

make_buffer

Creates buffers from containers

buffer_copy

Copies between sequences

buffer_size

Total bytes in a sequence

flat_dynamic_buffer

Linear growable buffer

circular_dynamic_buffer

Ring buffer for streaming

string_dynamic_buffer

Adapter for std::string

vector_dynamic_buffer

Adapter for std::vector

buffer_param

Windowed sequence access

consuming_buffers

Position-tracking wrapper

prefix, suffix, sans_prefix, sans_suffix

Slice operations

The types are lightweight. The concepts are precise. The operations compose. With buffers, you describe memory without owning it, combine regions without copying them, and process data without intermediate allocation.