Write Stream Design: A Side-by-Side Analysis
Both Capy and Cobalt allow you to write a non-template coroutine algorithm that operates on a type-erased write stream. The function signatures look similar:
// Capy
capy::task<> my_algo(capy::any_write_stream& stream);
// Cobalt
cobalt::task<> my_algo(cobalt::io::write_stream& stream);
Both designs solve the same problem. The caller passes a type-erased stream, and the algorithm writes to it without knowing the concrete type. The differences lie in how each library defines the stream abstraction, specifies its contract, propagates context, and manages allocation.
Each section below examines one design choice and its technical consequences.
Task Requirements
Capy formally defines what makes a task type conforming. Two C++20 concepts form a refinement hierarchy:
IoAwaitable
|
v
IoRunnable
IoAwaitable is the base. It requires a single syntactic property — the await_suspend signature must accept an io_env parameter containing the execution environment:
template<typename A>
concept IoAwaitable =
requires(A a, std::coroutine_handle<> h, io_env const* env)
{
a.await_suspend(h, env);
};
IoRunnable refines IoAwaitable with operations needed to start a task from non-coroutine contexts: handle(), release(), exception(), and result().
template<typename T>
concept IoRunnable =
IoAwaitable<T> &&
requires { typename T::promise_type; } &&
requires(T& t, T const& ct, typename T::promise_type const& cp)
{
{ ct.handle() } noexcept
-> std::same_as<std::coroutine_handle<typename T::promise_type>>;
{ cp.exception() } noexcept -> std::same_as<std::exception_ptr>;
{ t.release() } noexcept;
} &&
(std::is_void_v<decltype(std::declval<T&>().await_resume())> ||
requires(typename T::promise_type& p) { p.result(); });
Context injection (set_environment, set_continuation) is handled internally by the promise through await_suspend and is not part of any concept.
Each concept has documented syntactic requirements, semantic requirements, conforming signatures, and examples. A user who wants to create a custom task type can read the concept definition and know exactly what to provide. The compiler enforces the syntactic requirements at constraint-check time.
Cobalt’s Approach
Cobalt uses an associator pattern inherited from Asio. The full documentation for this pattern, from cobalt/doc/design/associators.adoc, reads:
cobaltuses theassociatorconcept of asio, but simplifies it. That is, it has three associators that are member functions of an awaiting promise.
const executor_type & get_executor()(alwaysexecutor, must return by const ref)
allocator_type get_allocator()(alwayspmr::polymorphic_allocator<void>)
cancellation_slot_type get_cancellation_slot()(must have the same IF asasio::cancellation_slot)
cobaltuses concepts to check if those are present in itsawait_suspendfunctions.That way custom coroutines can support cancellation, executors and allocators.
In a custom awaitable you can obtain them like this:
struct my_awaitable
{
bool await_ready();
template<typename Promise>
void await_suspend(std::coroutine_handle<Promise> h)
{
if constexpr (requires (Promise p) {p.get_executor();})
handle_executor(h.promise().get_executor());
if constexpr (requires (Promise p) {p.get_cancellation_slot();})
if ((cl = h.promise().get_cancellation_slot()).is_connected())
cl.emplace<my_cancellation>();
}
void await_resume();
};
The associators are optional member functions on the promise. Each awaitable probes for them independently using if constexpr. This design treats executor, allocator, and cancellation support as independent, opt-in capabilities rather than a single bundled requirement.
| Aspect | Capy | Cobalt |
|---|---|---|
Task requirements |
Named concepts ( |
Implicit associators probed via |
Specification |
Documented syntactic + semantic requirements |
Prose description of three member functions |
Compiler enforcement |
Constraint failure at concept check |
Associators are optional; absent ones use defined defaults |
Refinement hierarchy |
Yes (three levels) |
|
User-authored task types |
Implement against documented concept |
Follow associator pattern (documented in design guide) |
Context Propagation
The task requirements directly determine how executor and cancellation context reach child operations.
Capy’s IoAwaitable protocol passes the execution environment as an explicit parameter to await_suspend:
auto await_suspend(std::coroutine_handle<> h, io_env const* env);
The executor and stop token flow forward structurally through the call chain via io_env. If a task’s machinery does not provide them, the code does not compile. There is no fallback.
Cobalt’s approach probes the calling promise. The relevant code from cobalt/detail/task.hpp:
template<typename Promise>
std::coroutine_handle<void> await_suspend(std::coroutine_handle<Promise> h)
{
// ...
if constexpr (requires {h.promise().get_cancellation_slot();})
if ((cl = h.promise().get_cancellation_slot()).is_connected())
cl.emplace<forward_cancellation>(self->promise->signal);
if constexpr (requires {h.promise().get_executor();})
self->promise->exec.emplace(h.promise().get_executor());
else
self->promise->exec.emplace(this_thread::get_executor());
// ...
}
Two things happen when a promise does not provide an associator:
-
If
get_executor()is absent, the code falls back tothis_thread::get_executor()— a thread-local global. The child task receives the executor most recently set on the current thread. -
If
get_cancellation_slot()is absent, the cancellation wiring block is skipped. The child task operates without cancellation support.
Both behaviors are intentional. Cobalt treats associators as optional capabilities — a task without get_cancellation_slot() simply does not propagate cancellation to its children. This is a design choice that favors flexibility: tasks can participate in the system without implementing every associator. The trade-off is that omitting an associator produces no compile-time diagnostic, so the behavior difference must be understood by the author of the custom task.
In Capy, the equivalent of "no cancellation" is passing std::stop_token{} (a never-stop token) explicitly. Both designs support uncancellable operations; they differ in whether that choice is expressed through presence of a parameter or absence of a member function.
| Aspect | Capy | Cobalt |
|---|---|---|
Mechanism |
Explicit |
Promise probing via |
Missing executor |
Compile error |
Falls back to |
Missing cancellation |
Compile error (pass |
Skipped by design (child uses no cancellation) |
Diagnostic on omission |
Yes (constraint failure) |
No (optional by design) |
Buffer Sequences
Cobalt’s write_stream accepts buffers through a concrete type:
struct write_stream
{
virtual ~write_stream() = default;
virtual write_op write_some(const_buffer_sequence buffer) = 0;
};
const_buffer_sequence is a fixed type that accepts either a single asio::const_buffer or a std::span<const asio::const_buffer>:
struct const_buffer_sequence
{
const_buffer_sequence(asio::const_buffer head);
const_buffer_sequence(asio::mutable_buffer head);
template<typename T>
requires (std::constructible_from<
std::span<const asio::const_buffer>, const T&>)
const_buffer_sequence(const T& value);
const_buffer_sequence(std::span<const asio::const_buffer> spn);
// ...
};
This is a subset of Asio’s full ConstBufferSequence concept. Buffer sequence types that do not convert to const_buffer or span<const asio::const_buffer> cannot be passed through this interface.
Capy’s WriteStream concept requires write_some to accept any ConstBufferSequence:
template<typename T>
concept WriteStream =
requires(T& stream, const_buffer_archetype buffers)
{
{ stream.write_some(buffers) } -> IoAwaitable;
// ...
};
The type-erased wrapper any_write_stream also models the WriteStream concept. Its write_some is a template that accepts any ConstBufferSequence:
template<ConstBufferSequence CB>
auto
any_write_stream::write_some(CB buffers);
Both the concept and the wrapper accept full buffer sequences. Type erasure does not narrow the interface.
| Aspect | Capy | Cobalt |
|---|---|---|
|
|
|
Accepted buffer types |
Any |
|
Type-erased wrapper accepts full concept |
Yes |
|
Custom buffer sequence types |
Accepted |
Must convert to supported representations |
Semantic Specification
Cobalt’s entire documentation for write_stream, from cobalt/doc/reference/io/stream.adoc, is:
A stream is an io object that allows reads and writes, such as a tcp socket.
Followed by the struct definition:
struct write_stream
{
virtual ~write_stream() = default;
virtual write_op write_some(const_buffer_sequence buffer) = 0;
};
The documentation describes what a stream is but does not specify behavioral details for write_some: empty buffer handling, error reporting conventions, partial write guarantees, buffer consumption order, or buffer lifetime assumptions. These details are left to the implementor’s judgment or inferred from Asio conventions.
Capy’s WriteStream concept includes semantic requirements in the concept’s documentation:
// From capy/concept/write_stream.hpp
// Semantic Requirements:
//
// If buffer_size( buffers ) > 0, the operation writes one or more
// bytes of data to the stream from the buffer sequence:
//
// On success: !ec, and n is the number of bytes written.
// On error: ec, and n is 0.
//
// If buffer_empty( buffers ) is true, the operation completes
// immediately. !ec, and n is 0.
//
// Buffers in the sequence are written completely before proceeding
// to the next buffer.
//
// Buffer Lifetime:
//
// The caller must ensure that the memory referenced by buffers
// remains valid until the co_await expression returns.
The concept also includes a coroutine-specific warning about buffer lifetime:
// Warning: When implementing coroutine member functions, prefer
// accepting buffer sequences by value rather than by reference.
// Buffer sequences passed by reference may become dangling if
// the caller's stack frame is destroyed before the coroutine
// completes.
| Specification | Capy | Cobalt |
|---|---|---|
Empty buffer behavior |
Documented (immediate completion, no error) |
|
Error reporting semantics |
Documented ( |
|
Partial write guarantees |
Documented (buffers consumed in order) |
|
Buffer lifetime requirements |
Documented |
|
Coroutine buffer lifetime warning |
Yes |
|
Conforming signature examples |
Yes |
Operation Implementation
Cobalt’s write_stream::write_some returns write_op, a concrete operation type. The write_op struct from cobalt/io/ops.hpp:
struct write_op final : op<system::error_code, std::size_t>
{
const_buffer_sequence buffer;
using implementation_t =
void(void*, const_buffer_sequence,
completion_handler<system::error_code, std::size_t>);
using try_implementation_t =
void(void*, const_buffer_sequence,
handler<system::error_code, std::size_t>);
write_op(const_buffer_sequence buffer,
void* this_,
implementation_t* implementation,
try_implementation_t* try_implementation = nullptr);
void initiate(
completion_handler<system::error_code, std::size_t> handler) final;
void ready(
handler<system::error_code, std::size_t> handler) final;
};
Implementing write_stream requires constructing a write_op with two function pointers and a void*. The full documentation for these function pointers, from cobalt/doc/reference/io/ops.adoc, is:
Most functionality in this wrapper is implemented with operations.
They are type-erase, but not by using
virtual. This makes devirtualization easier. Theimplementationfunction must be provided, where astry_implementation_tis optional and will be used in thereadyfunction. Both will be called withvoid *thisas the first parameter.
The documentation describes the mechanical role of each function pointer but does not specify what the implementation function must do with the buffer, what completion semantics to follow, how to report errors through the completion_handler, or under what conditions try_implementation should complete synchronously. Implementors can look to the existing I/O wrappers (e.g., stream_socket) as reference implementations.
In Capy, the implementation contract lives in the WriteStream concept definition. A type satisfies WriteStream by providing a write_some member function template that returns an IoAwaitable decomposing to (error_code, std::size_t). The semantic requirements are part of the concept. There is no separate operation type to construct and no function pointers to provide.
| Aspect | Capy | Cobalt |
|---|---|---|
How to implement |
Satisfy the |
Construct |
Contract location |
Concept definition (javadoc) |
Prose paragraph in ops reference |
Implementation function semantics |
Specified (in concept) |
Inferred from existing wrappers |
Intermediate types required |
None (concept is the contract) |
|
Awaitable Allocation
Every co_await on a write_op in Cobalt allocates a fixed-size buffer in the coroutine frame. The chain is:
-
write_stream::write_somereturnswrite_op -
write_op final : op<system::error_code, std::size_t> -
op<>::operator co_await()returnsop<>::awaitable -
op<>::awaitablecontains a 4096-byte SBO buffer:
// cobalt/op.hpp
struct awaitable : awaitable_base
{
char buffer[BOOST_COBALT_SBO_BUFFER_SIZE]; // default: 4096
detail::sbo_resource resource{buffer, sizeof(buffer)};
awaitable(op<Args...>* op_) : awaitable_base(op_, &resource) {}
// ...
};
write_op is final. The return type of write_stream::write_some is fixed. Subclasses of write_stream cannot change the return type, the allocation strategy, or the SBO buffer size. Every co_await stream.write_some(buf) places this 4096-byte awaitable in the coroutine frame regardless of whether the underlying implementation needs it.
Capy’s any_write_stream takes a different approach. The constructor preallocates storage sized exactly to the wrapped stream’s awaitable type:
// capy/io/any_write_stream.hpp
template<WriteStream S>
requires (!std::same_as<std::decay_t<S>, any_write_stream>)
any_write_stream::any_write_stream(S s)
: vt_(&vtable_for_impl<S>::value)
{
// ...
storage_ = ::operator new(sizeof(S));
stream_ = ::new(storage_) S(std::move(s));
// Preallocate the awaitable storage
cached_awaitable_ = ::operator new(vt_->awaitable_size);
// ...
}
After construction, each co_await stream.write_some(buf) reuses this preallocated storage. There is no per-operation allocation and no fixed-size buffer in the coroutine frame.
If users prefer a different allocation strategy, they can write algorithms directly against the WriteStream concept and build their own wrapper. The concept and the type-erased wrapper are separate layers. Cobalt’s abstract base class fuses the abstraction and the allocation strategy into one type.
| Aspect | Capy | Cobalt |
|---|---|---|
Per-operation allocation |
None (preallocated at construction) |
4096-byte SBO buffer per |
Storage sizing |
Exact (sized to wrapped type) |
Fixed ( |
Configurable by subclass |
N/A (concept + wrapper are separate) |
No ( |
Custom allocation strategy |
Write against |
Not possible (return type is fixed) |
Concept vs. Abstract Base Class
The preceding sections each examined a specific design choice. A common thread runs through them.
Cobalt’s write_stream is an abstract base class. The abstraction and the runtime wrapper are the same type. Writing against the abstraction means using virtual dispatch. The return type (write_op), the buffer parameter type (const_buffer_sequence), the allocation strategy (4096-byte SBO), and the context propagation mechanism (promise probing) are all fixed by the base class definition.
Capy separates the abstraction from the wrapper. WriteStream is a C++20 concept:
template<typename T>
concept WriteStream =
requires(T& stream, const_buffer_archetype buffers)
{
{ stream.write_some(buffers) } -> IoAwaitable;
requires awaitable_decomposes_to<
decltype(stream.write_some(buffers)),
std::error_code, std::size_t>;
};
any_write_stream is a type-erased wrapper that satisfies this concept. It is one possible reification, not the only one. Users can:
-
Write generic algorithms constrained by
WriteStream— these work with any conforming stream, with no virtual dispatch overhead. -
Use
any_write_streamwhen runtime polymorphism is needed — it provides type erasure with preallocated awaitable storage. -
Build a custom wrapper with a different allocation or dispatch strategy — the concept defines the contract independently of any particular wrapper.
This separation is the architectural root of the differences examined in this document. It is what enables full buffer sequence support at the type-erased layer, formal semantic specification in a concept definition, user-selectable allocation strategies, and explicit context propagation through await_suspend parameters.
| Aspect | Capy | Cobalt |
|---|---|---|
Abstraction mechanism |
C++20 concept ( |
Abstract base class ( |
Runtime wrapper |
Separate ( |
Same type as abstraction |
Generic algorithms (no type erasure) |
Yes (constrain on concept) |
No (must use virtual dispatch) |
Custom wrappers |
Yes (concept is independent) |
No (locked to |
Summary
| Design Choice | Capy | Cobalt |
|---|---|---|
Task requirements |
Named concept hierarchy ( |
Implicit associators ( |
Context propagation |
Explicit |
Promise probing via |
Buffer sequence support |
Full |
|
Semantic specification |
Documented in concept (empty buffers, errors, ordering, lifetime) |
Struct definition; semantics follow Asio conventions |
Operation implementation |
Satisfy concept; wrapper handles the rest |
Construct |
Awaitable allocation |
Preallocated at construction (exact size) |
4096-byte SBO per |
Abstraction mechanism |
Concept (compile-time) + wrapper (runtime), separable |
Abstract base class (fused) |