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 toconst_buffer -
MutableBufferSequence— iterable, elements convert tomutable_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:
-
prepare(n) — request N bytes of writable space
-
commit(n) — make N written bytes readable
-
data() — access readable bytes
-
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
}
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 |
|---|---|
|
Describes read-only memory |
|
Describes writable memory |
|
Creates buffers from containers |
|
Copies between sequences |
|
Total bytes in a sequence |
|
Linear growable buffer |
|
Ring buffer for streaming |
|
Adapter for |
|
Adapter for |
|
Windowed sequence access |
|
Position-tracking wrapper |
|
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.