Buffer Sequences
Every I/O operation comes down to moving bytes between your program and the outside world. The question is how you describe where those bytes live.
A pointer and a size answers it for one contiguous region. Real I/O is rarely that tidy. A message may be a protocol header, a payload, and a checksum, each produced by different code and each in its own memory.
Capy describes any such arrangement without copying it, using buffer sequences.
Everything here lives in <boost/capy/buffers.hpp>.
Buffer Types
Buffers Are Handles
A const_buffer or mutable_buffer is a handle: a non-owning (pointer, size) view of memory it does not own. Constructing one copies no bytes, and destroying one frees nothing.
This splits lifetime responsibility cleanly:
-
You own the bytes. The memory a buffer refers to—a stack array, a
std::string, a slab from your allocator—is yours to keep alive. It must stay valid for the whole duration of any operation you hand the buffer to. That includes the suspension points of aco_await-ed I/O operation. -
The library owns the handles. Capy creates and manages buffer handles on your behalf. Those are the sub-range a
buffer_sliceproduces, and the descriptors a type-erased stream passes to the OS. Each is valid only for the window its API documents.
const_buffer
const_buffer represents a contiguous region of read-only memory.
// From pointer and size
char data[] = "hello";
const_buffer buf(data, 5);
// From mutable_buffer (implicit)
mutable_buffer mbuf(data, 5);
const_buffer cbuf = mbuf; // OK: mutable -> const
Accessors:
const_buffer buf(data, 5);
void const* ptr = buf.data(); // Pointer to first byte
std::size_t len = buf.size(); // Number of bytes
The += operator removes bytes from the front, which is useful when processing a buffer incrementally:
const_buffer buf(data, 10);
buf += 3; // Remove first 3 bytes
// buf.data() now points 3 bytes later
// buf.size() is now 7
mutable_buffer
mutable_buffer represents a contiguous region of writable memory. The interface mirrors const_buffer, but data() returns non-const void*.
A mutable_buffer converts implicitly to a const_buffer:
void process(const_buffer buf);
mutable_buffer mbuf(data, size);
process(mbuf); // OK: implicit conversion
The reverse is not allowed.
make_buffer
make_buffer creates buffers from various sources:
#include <boost/capy/buffers/make_buffer.hpp>
// From pointer and size
auto buf = make_buffer(ptr, size);
// From C array
char arr[10];
auto arr_buf = make_buffer(arr);
// From std::array
std::array<char, 10> std_arr;
auto std_arr_buf = make_buffer(std_arr);
// From std::vector
std::vector<char> vec(100);
auto vec_buf = make_buffer(vec);
// From std::string
std::string str = "hello";
auto str_buf = make_buffer(str);
// From std::string_view
std::string_view sv = "hello";
auto sv_buf = make_buffer(sv);
// From a span (std::span or boost::span)
std::span<char> sp(arr);
auto sp_buf = make_buffer(sp);
It accepts any sized, contiguous range of trivially-copyable elements, including std::span and boost::span. The returned type follows the element constness:
-
Ranges of mutable elements →
mutable_buffer -
Ranges of const elements,
string_view, string literals →const_buffer
The buffer’s size in bytes is count * sizeof(element).
|
Why Two concrete forces decide it:
This is an argument from layout and from caller convenience, not from semantics. "Raw memory" describes |
Buffer Sequences
A buffer sequence is any type that can produce an iteration of buffers:
-
A single buffer is a sequence of one element
-
A range of buffers, such as
vector<const_buffer>, is a multi-element sequence -
Any bidirectional range with buffer-convertible values qualifies
Treating a single buffer as a one-element sequence is deliberate. It lets one concept-constrained signature serve both the common single-buffer call and scatter/gather composition. There is no overload and no explicit wrap at the call site.
The Concepts
template<typename T>
concept ConstBufferSequence =
std::is_convertible_v<T, const_buffer> || (
std::ranges::bidirectional_range<T> &&
std::is_convertible_v<std::ranges::range_value_t<T>, const_buffer>);
A type satisfies ConstBufferSequence if it converts to const_buffer directly, or if it is a bidirectional range whose elements convert to const_buffer.
template<typename T>
concept MutableBufferSequence =
std::is_convertible_v<T, mutable_buffer> || (
std::ranges::bidirectional_range<T> &&
std::is_convertible_v<std::ranges::range_value_t<T>, mutable_buffer>);
MutableBufferSequence follows the same pattern for mutable_buffer.
Many common types satisfy these concepts:
// Single buffers
const_buffer cb; // ConstBufferSequence
mutable_buffer mb; // MutableBufferSequence (and ConstBufferSequence)
// Standard containers of buffers
std::vector<const_buffer> v; // ConstBufferSequence
std::array<mutable_buffer, 3> a; // MutableBufferSequence
// String types (wrap with make_buffer to get a single buffer)
std::string str; // make_buffer(str) -> mutable_buffer
std::string_view sv; // make_buffer(sv) -> const_buffer
std::string and std::string_view are ranges of characters, not of buffers, so they do not satisfy the concepts themselves. Wrap them with make_buffer.
Heterogeneous Composition
Because the concept accepts anything convertible to a buffer, you can mix types freely:
template<ConstBufferSequence Buffers>
void send(Buffers const& bufs);
// All of these work:
send(make_buffer("Hello")); // string literal
send(make_buffer(std::string_view{"Hello"})); // string_view
send(std::array{buf1, buf2}); // array of buffers
send(my_custom_buffer_sequence); // custom type
A single buffer works in the same signature, with no wrapping:
template<ConstBufferSequence Buffers>
void write_data(Buffers const& buffers);
// All of these work:
write_data(make_buffer("hello")); // Single buffer
write_data(std::array{buf1, buf2, buf3}); // Multiple buffers
write_data(my_composite); // Custom sequence
|
Why concepts rather than one span type?
A concept accepts the composite directly, with no allocation and no overload per shape. At type-erasure boundaries the tradeoff reverses. Virtual functions need concrete types, so Capy converts to concrete descriptors internally and keeps concepts at the user-facing edge. |
Iterating Buffer Sequences
Use begin() and end() from <boost/capy/buffers.hpp>:
template<ConstBufferSequence Buffers>
void process(Buffers const& bufs)
{
for (auto it = begin(bufs); it != end(bufs); ++it)
{
const_buffer buf = *it;
// Process buf.data(), buf.size()
}
}
These handle both single buffers, returning pointer-to-self, and ranges, returning standard iterators:
const_buffer single;
auto it = begin(single); // Returns pointer to single
auto e = end(single); // Returns pointer past single
std::array<const_buffer, 3> multi;
auto it2 = begin(multi); // Returns multi.begin()
auto e2 = end(multi); // Returns multi.end()
buffer_slice
buffer_slice returns a byte sub-range of a buffer sequence, as a value:
#include <boost/capy/buffers/buffer_slice.hpp>
co_await write(stream, buffer_slice(bufs, 0, 16384)); // send only the first 16 KB
auto rest = buffer_slice(bufs, 16384); // everything after the first 16 KB
co_await write(stream, rest);
buffer_slice(seq, offset, length) returns a value that is itself a buffer sequence, so you can pass it to any operation expecting one. Both offset and length are optional, which makes it a general byte sub-range primitive. Except in the single-buffer case the result borrows seq, so the sequence must outlive the slice.
consuming_buffers
When transferring data incrementally, consuming_buffers is a cursor that tracks progress:
#include <boost/capy/buffers/consuming_buffers.hpp>
template<MutableBufferSequence Buffers>
task<std::size_t> read_all(Stream& stream, Buffers buffers)
{
consuming_buffers consuming(buffers);
std::size_t const total_size = buffer_size(buffers);
std::size_t total = 0;
while (total < total_size)
{
auto [ec, n] = co_await stream.read_some(consuming.data());
consuming.consume(n);
total += n;
if (ec)
break;
}
co_return total;
}
The cursor borrows the underlying sequence and provides:
|
A coroutine reads its parameters when its body runs, not when the call is written. A task that is stored and awaited later outlives its call expression, so a reference parameter can dangle by then. That is why the fan-out example on Concurrent Composition takes its item by value: it collects its tasks first, then awaits them together. |
Why Bidirectional?
The concepts require bidirectional ranges, not merely forward ranges, for two reasons:
-
Some algorithms traverse buffers backwards
-
The slice views from
buffer_sliceandconsuming_buffers::data()must adjust the first and last buffers' bounds
If your custom sequence offers only forward iteration, wrap it in a type that provides bidirectional access.
System I/O Integration
Platform Buffer Structures
struct iovec {
void* iov_base; // Pointer to data
size_t iov_len; // Length of data
};
POSIX uses iovec with readv(), writev(), recvmsg(), and sendmsg(). Capy’s buffer types place the pointer first and the size second, matching iovec in both order and width. Filling an iovec is therefore a field-for-field copy, with no conversion.
|
Matching layout does not license a cast. Do not Copy field by field into a real platform array, which is what Capy does internally. |
typedef struct _WSABUF {
ULONG len; // Length (note: first!)
CHAR* buf; // Pointer
} WSABUF;
Windows uses WSABUF with WSARecv() and WSASend(). The field order is reversed and the length is 32-bit, so Capy copies descriptors into a WSABUF array rather than casting.
Translation Process
When you call an I/O function with a buffer sequence:
template<ConstBufferSequence Buffers>
io_task<std::size_t> write_some(Buffers buffers);
Capy counts the buffers, fills an array of platform structures with the descriptors, calls the OS function, and returns the result.
Conversion always happens on the stack; the implementation never allocates. A fixed on-frame window of 16 descriptors is filled from the sequence and passed to the OS call. If the sequence holds more buffers than fit, the window is refilled and the call repeated:
template<ConstBufferSequence Buffers>
auto platform_write(Buffers const& buffers)
{
iovec iovecs[16]; // fixed on-frame window, never heap-allocated
auto it = begin(buffers);
auto last = end(buffers);
while (it != last)
{
std::size_t count = fill_iovecs(iovecs, it, last, 16); // up to 16
auto result = writev(fd, iovecs, count);
// ... advance the window past the buffers just written
}
}
The window size is implementation-defined.
Why Vectored I/O
Consider sending an HTTP message whose headers and body sit in separate buffers. With a single-buffer API you have two options, and each costs something:
-
Copy. Allocate a buffer large enough for both, copy the headers in, copy the body after them, then send once. That is an allocation plus two copies of data you already have.
-
Call twice. Send the headers, then send the body. No copy, but two system calls rather than one. On a datagram socket it also changes the result: two datagrams instead of one.
Vectored I/O avoids both. One call transfers several non-contiguous buffers as a single logical operation:
write(fd, header, header_len); // syscall 1
write(fd, body, body_len); // syscall 2
iovec iov[2] = {{header, header_len}, {body, body_len}};
writev(fd, iov, 2); // single syscall
The data is never copied into a contiguous staging buffer; the OS reads directly from each region. The write is also atomic at the file offset level, so other processes see all of the data or none of it.
Registered Buffers
Some platforms allow buffers to be pre-registered with the kernel, removing per-operation address translation. On Linux 5.1+, io_uring supports this:
// Registration (done once)
io_uring_register_buffers(ring, buffers, count);
// Use (fast path - no translation)
io_uring_prep_write_fixed(sqe, fd, buf, len, offset, buf_index);
Windows IOCP offers a comparable optimization with pre-registered memory regions.
Corosio does not currently expose either. Every operation goes through the per-call translation described above.
Writing Efficient Code
Fewer buffers means less translation overhead:
// Prefer: single buffer when possible
auto buf = assemble_message(); // Build in one buffer
co_await write(stream, buf);
// Avoid: many tiny buffers
std::array<const_buffer, 100> tiny_bufs;
co_await write(stream, tiny_bufs); // 100-element translation
For repeated I/O with the same structure, consider caching the platform array:
// Build once, use many times
struct message_buffers
{
std::array<iovec, 3> iovecs;
void set_header(void const* p, std::size_t n);
void set_body(void const* p, std::size_t n);
void set_footer(void const* p, std::size_t n);
};
Buffer translation is rarely the bottleneck. Profile network latency, disk time, and your own processing before optimizing descriptor copying.
Buffer Algorithms
Measuring
buffer_size returns the total number of bytes across every buffer in a sequence:
auto buf1 = make_buffer("hello"sv); // 5 bytes
auto buf2 = make_buffer("world"sv); // 5 bytes
auto combined = std::array{buf1, buf2};
std::size_t total = buffer_size(combined); // 10
buffer_length returns the number of buffers in the sequence:
auto single = make_buffer("hello"sv);
buffer_length(single); // 1
auto arr = std::array{buf1, buf2, buf3};
buffer_length(arr); // 3
|
These two names are easy to confuse, so read them as answering different questions:
A three-buffer sequence of 100 bytes each has a |
buffer_empty reports whether a sequence carries no data, either because it holds no buffers or because every buffer has size zero:
const_buffer empty_buf;
buffer_empty(empty_buf); // true
const_buffer non_empty("data", 4);
buffer_empty(non_empty); // false
Copying
buffer_copy copies data from one buffer sequence to another and returns the number of bytes copied:
char source_data[] = "hello world";
char dest_data[20];
const_buffer src(source_data, 11);
mutable_buffer dst(dest_data, 20);
std::size_t copied = buffer_copy(dst, src); // 11
Its at_most parameter caps the transfer, which is useful for protocols with size limits:
std::size_t copied = buffer_copy(dst, src, 5); // Copy at most 5 bytes
Source and target need not have the same shape:
// Source: 3 buffers
std::array<const_buffer, 3> src = {buf1, buf2, buf3};
// Target: 2 buffers with different sizes
std::array<mutable_buffer, 2> dst = {large_buf, small_buf};
// Copies across buffer boundaries as needed
std::size_t copied = buffer_copy(dst, src);
The algorithm fills target buffers in order, reading from source buffers as needed. It handles a source buffer spanning several targets, and the reverse.
Partial Transfer Loops
Real transfers move some of the bytes, not all of them. Drive the loop with a consuming_buffers cursor, described under consuming_buffers above.
template<ReadStream Stream, MutableBufferSequence Buffers>
task<std::size_t> read_full(Stream& stream, Buffers buffers)
{
consuming_buffers remaining(buffers);
std::size_t const total_size = buffer_size(buffers);
std::size_t total = 0;
while (total < total_size)
{
auto [ec, n] = co_await stream.read_some(remaining.data());
remaining.consume(n);
total += n;
if (ec)
co_return total;
}
co_return total;
}
template<WriteStream Stream, ConstBufferSequence Buffers>
task<std::size_t> write_full(Stream& stream, Buffers buffers)
{
consuming_buffers remaining(buffers);
std::size_t const total_size = buffer_size(buffers);
std::size_t total = 0;
while (total < total_size)
{
auto [ec, n] = co_await stream.write_some(remaining.data());
remaining.consume(n);
total += n;
if (ec)
co_return total;
}
co_return total;
}
Custom Buffer Types
Any memory region can be a buffer, including a memory-mapped one:
// Memory-mapped file
void* mapped = mmap(nullptr, file_size, PROT_READ, MAP_PRIVATE, fd, 0);
const_buffer file_buf(mapped, file_size);
co_await write(socket, file_buf); // Zero-copy network transmission
You can also define your own type satisfying the concepts:
class chunked_buffer_sequence
{
std::vector<std::vector<char>> chunks_;
public:
auto begin() const { return chunk_iterator(chunks_.begin()); }
auto end() const { return chunk_iterator(chunks_.end()); }
};
// Satisfies ConstBufferSequence—works with all algorithms
Here chunk_iterator is a small bidirectional iterator whose operator* returns each chunk as a const_buffer.