any_buffer_sink Design
Overview
This document describes the design of any_buffer_sink, a type-erased
wrapper that satisfies both BufferSink and WriteSink. The central
design goal is to serve two fundamentally different data-production
patterns through a single runtime interface, with no performance
compromise for either.
Data producers fall into two categories:
-
Generators produce data on demand. They do not hold the data in advance; they compute or serialize it into memory that someone else provides. An HTTP header serializer, a JSON encoder, and a compression engine are generators.
-
Buffered sources already have data sitting in buffers. A memory-mapped file, a ring buffer that received data from a socket, and a pre-serialized response body are buffered sources.
These two patterns require different buffer ownership models.
Generators need writable memory from the sink (the BufferSink
pattern). Buffered sources need to hand their existing buffers to
the sink (the WriteSink pattern). Forcing either pattern through
the other’s interface introduces an unnecessary copy.
any_buffer_sink exposes both interfaces. The caller chooses the
one that matches how its data is produced. The wrapper dispatches
to the underlying concrete sink through the optimal path, achieving
zero-copy when the concrete type supports it and falling back to a
synthesized path when it does not.
The Two Interfaces
BufferSink: Callee-Owned Buffers
The BufferSink interface (prepare, commit, commit_eof) is
designed for generators. The sink owns the memory. The generator
asks for writable space, fills it, and commits:
any_buffer_sink abs(concrete_sink{});
mutable_buffer arr[16];
auto bufs = abs.prepare(arr);
// serialize directly into bufs
auto [ec] = co_await abs.commit(bytes_written);
The data lands in the sink’s internal storage with no intermediate copy. If the concrete sink is backed by a kernel page, a DMA descriptor, or a ring buffer, the bytes go directly to their final destination.
WriteSink: Caller-Owned Buffers
The WriteSink interface (write_some, write, write_eof) is
designed for buffered sources. The caller already has the data in
buffers and passes them to the sink:
any_buffer_sink abs(concrete_sink{});
// Data already in buffers -- pass them directly
auto [ec, n] = co_await abs.write(existing_buffers);
// Or atomically write and signal EOF
auto [ec2, n2] = co_await abs.write_eof(final_buffers);
When the concrete sink natively supports WriteSink, the caller’s
buffers propagate directly through the type-erased boundary. The
sink receives the original buffer descriptors pointing to the
caller’s memory. No data is copied into an intermediate staging
area.
Dispatch Strategy
The vtable records whether the wrapped concrete type satisfies
WriteSink in addition to BufferSink. This determination is made
at compile time when the vtable is constructed. At runtime, each
WriteSink operation checks a single nullable function pointer to
select its path.
Native Forwarding (BufferSink + WriteSink)
When the concrete type satisfies both concepts, the WriteSink
vtable slots are populated with functions that construct the
concrete type’s own write_some, write, write_eof(buffers),
and write_eof() awaitables in the cached storage. The caller’s
buffer descriptors pass straight through:
caller buffers → vtable → concrete write(buffers) → I/O
No prepare, no buffer_copy, no commit. The concrete type
receives the caller’s buffers and can submit them directly to the
operating system, the compression library, or the next pipeline
stage.
This is the zero-copy path for buffered sources writing to a sink that natively accepts caller-owned buffers.
Synthesized Path (BufferSink Only)
When the concrete type satisfies only BufferSink, the WriteSink
vtable slots are null. The wrapper synthesizes the WriteSink
operations from the BufferSink primitives:
caller buffers → prepare → buffer_copy → commit → I/O
For write_some:
-
Call
prepareto get writable space from the sink. -
Copy data from the caller’s buffers into the prepared space with
buffer_copy. -
Call
committo finalize.
For write and write_eof: the same loop, repeated until all
data is consumed. write_eof finishes with commit_eof to signal
end-of-stream.
This path incurs one buffer copy, which is unavoidable: the
concrete sink only knows how to accept data through its own
prepare/commit protocol, so the caller’s buffers must be copied
into the sink’s internal storage.
Why This Matters
No Compromise
A naive design would pick one interface and synthesize the other
unconditionally. If the wrapper only exposed BufferSink, every
buffered source would pay a copy to move its data into the sink’s
prepared buffers. If the wrapper only exposed WriteSink, every
generator would need to allocate its own intermediate buffer, fill
it, then hand it to the sink — paying a copy that the BufferSink
path avoids.
any_buffer_sink avoids both penalties. Each data-production
pattern uses the interface designed for it. The only copy that
occurs is the one that is structurally unavoidable: when a
WriteSink operation targets a concrete type that only speaks
BufferSink.
True Zero-Copy for Buffered Sources
Consider an HTTP server where the response body is a memory-mapped
file. The file’s pages are already in memory. Through the
WriteSink interface, those pages can propagate directly to the
underlying transport:
// body_source is a BufferSource backed by mmap pages
// response_sink wraps a concrete type satisfying both concepts
any_buffer_sink response_sink(&concrete);
const_buffer arr[16];
for(;;)
{
auto [ec, bufs] = co_await body_source.pull(arr);
if(ec == cond::eof)
{
auto [ec2] = co_await response_sink.write_eof();
break;
}
if(ec)
break;
// bufs point directly into mmap pages
// write() propagates them through the vtable to the concrete sink
auto [ec2, n] = co_await response_sink.write(bufs);
if(ec2)
break;
body_source.consume(n);
}
The mapped pages flow from body_source.pull through
response_sink.write to the concrete transport with no
intermediate copy. If the concrete sink can scatter-gather those
buffers into a writev system call, the data moves from the
page cache to the network card without touching user-space memory
a second time.
Generators Write In-Place
An HTTP header serializer generates bytes on the fly. It does not
hold the output in advance. Through the BufferSink interface, it
writes directly into whatever memory the concrete sink provides:
task<> serialize_headers(
any_buffer_sink& sink,
response const& resp)
{
mutable_buffer arr[16];
for(auto const& field : resp.fields())
{
auto bufs = sink.prepare(arr);
// serialize field directly into bufs
std::size_t n = format_field(bufs, field);
auto [ec] = co_await sink.commit(n);
if(ec)
co_return;
}
// headers done; body follows through the same sink
}
The serializer never allocates a scratch buffer for the formatted output. The bytes land directly in the sink’s internal storage, which might be a chunked-encoding buffer, a TLS record buffer, or a circular buffer feeding a socket.
Awaitable Caching
any_buffer_sink uses the split vtable pattern described in
Type-Erasing Awaitables. Multiple
async operations (commit, commit_eof, plus the four WriteSink
operations when the concrete type supports them) share a single
cached awaitable storage region.
The constructor computes the maximum size and alignment across all awaitable types that the concrete type can produce and allocates that storage once. This reserves all virtual address space at construction time, so memory usage is measurable at server startup rather than growing piecemeal as requests arrive.
Two separate awaitable_ops structs are used:
-
awaitable_opsfor operations yieldingio_result<>(commit,commit_eof,write_eof()) -
write_awaitable_opsfor operations yieldingio_result<std::size_t>(write_some,write,write_eof(buffers))
Each construct_* function in the vtable creates the concrete
awaitable in the cached storage and returns a pointer to the
matching static constexpr ops table. The wrapper stores this
pointer as active_ops_ or active_write_ops_ and uses it for
await_ready, await_suspend, await_resume, and destruction.
Ownership Modes
Owning
any_buffer_sink abs(my_concrete_sink{args...});
The wrapper allocates storage for the concrete sink and moves it in. The wrapper owns the sink and destroys it in its destructor. The awaitable cache is allocated separately.
If either allocation fails, the constructor cleans up via an internal guard and propagates the exception.
Non-Owning (Reference)
my_concrete_sink sink;
any_buffer_sink abs(&sink);
The wrapper stores a pointer without allocating storage for the sink. The concrete sink must outlive the wrapper. Only the awaitable cache is allocated.
This mode is useful when the concrete sink is managed by a higher-level object (e.g., an HTTP connection that owns the transport) and the wrapper is a short-lived handle passed to a body-production function.
Relationship to any_buffer_source
any_buffer_source is the read-side counterpart, satisfying both
BufferSource and ReadSource. The same dual-interface principle
applies in mirror image:
| Direction | Primary concept | Secondary concept |
|---|---|---|
Writing (any_buffer_sink) |
|
|
Reading (any_buffer_source) |
|
|
Both wrappers enable the same design philosophy: the caller chooses the interface that matches its data-production or data-consumption pattern, and the wrapper dispatches optimally.
Alternatives Considered
WriteSink-Only Wrapper
A design where the type-erased wrapper satisfied only WriteSink
was considered. Generators would allocate their own scratch buffer,
serialize into it, and call write. This was rejected because:
-
Every generator pays a buffer copy that the
BufferSinkpath avoids. For high-throughput paths (HTTP header serialization, compression output), this copy is measurable. -
Generators must manage scratch buffer lifetime and sizing. The
prepare/commitprotocol pushes this responsibility to the sink, which knows its own buffer topology. -
The
commit_eof(n)optimization (coalescing final data with stream termination) is lost. A generator callingwritecannot signal that its last write is the final one without a separatewrite_eof()call, preventing the sink from combining them.
BufferSink-Only Wrapper
A design where the wrapper satisfied only BufferSink was
considered. Buffered sources would copy their data into the
sink’s prepared buffers via prepare + buffer_copy + commit.
This was rejected because:
-
Every buffered source pays a copy that native
WriteSinkforwarding avoids. When the source is a memory-mapped file and the sink is a socket, this eliminates the zero-copy path entirely. -
The
buffer_copystep becomes the bottleneck for large transfers, dominating what would otherwise be a pure I/O operation. -
Buffered sources that produce scatter-gather buffer sequences (multiple non-contiguous regions) must copy each region individually into prepared buffers, losing the ability to pass the entire scatter-gather list to a
writevsystem call.
Separate Wrapper Types
A design with two distinct wrappers (any_buffer_sink satisfying
only BufferSink and any_write_sink satisfying only WriteSink)
was considered. The caller would choose which wrapper to construct
based on its data-production pattern. This was rejected because:
-
The caller and the sink are often decoupled. An HTTP server framework provides the sink; the user provides the body producer. The framework cannot know at compile time whether the user will call
prepare/commitorwrite/write_eof. -
Requiring two wrapper types forces the framework to either pick one (losing the other pattern) or expose both (complicating the API).
-
A single wrapper that satisfies both concepts lets the framework hand one object to the body producer, which uses whichever interface is natural. No choice is imposed on the framework or the user.
Always Synthesizing WriteSink
A design where the WriteSink operations were always synthesized
from prepare + buffer_copy + commit, even when the concrete
type natively supports WriteSink, was considered. This would
simplify the vtable by removing the nullable write-forwarding
slots. This was rejected because:
-
The buffer copy is measurable. For a concrete type that can accept caller-owned buffers directly (e.g., a socket wrapper with
writevsupport), the synthesized path adds a copy that native forwarding avoids. -
The
write_eof(buffers)atomicity guarantee is lost. The synthesized path must decompose it intoprepare
buffer_copy+commit_eof, which the concrete type cannot distinguish from a non-final commit followed by an emptycommit_eof. This prevents optimizations like coalescing the last data chunk with a chunked-encoding terminator.
Summary
any_buffer_sink satisfies both BufferSink and WriteSink
behind a single type-erased interface. The dual API lets each
data-production pattern use the interface designed for it:
| Producer type | Interface | Data path |
|---|---|---|
Generator (produces on demand) |
|
Writes directly into sink’s internal storage. Zero copy. |
Buffered source (data already in memory) |
|
Buffers propagate through the vtable. Zero copy when the concrete
type natively supports |
The dispatch is determined at construction time through nullable vtable slots. At runtime, a single pointer check selects the native or synthesized path. Neither pattern pays for the other’s existence.